JavaScript的奇技淫巧

6,501 阅读9分钟

在 JavaScript 中 数组(Array)随处可见,使用ECMAScript 6 中的新特性 扩展运算符 我们可以做很多很棒事情。

1. 迭代一个空数组

JavaScript 中直接创建的数组是松散的,以至于会有很多坑。试着用数组的构造方法创建一个数组,你就会瞬间明白了。

const arr = new Array(4);
[undefined, undefined, undefined, undefined]
// 谷歌浏览器中是 [empty x 4]

你会发现,通过一个松散的数组去循环调用一些转换是非常难的。

const arr = new Array(4);
arr.map((elem, index) => index);
[undefined, undefined, undefined, undefined]

想要解决这个问题,你可以使用在创建新数组的时候使用 Array.apply

const arr = Array.apply(null, new Array(4));
arr.map((elem, index) => index);
[0, 1, 2, 3]

2. 给方法传一个空参数

如果你想调用一个方法,并不填其中的一个参数时,JavaScript 就会报错。

method('parameter1', , 'parameter3'); // Uncaught SyntaxError: Unexpected token ,

一个我们常用的解决方法是传递 null 或 undefined。

method('parameter1', null, 'parameter3') // or
method('parameter1', undefined, 'parameter3');

根据 ES6 中对扩展运算符的介绍,有一个更简洁的方法可以将空参数传递给一个方法。正如上面所提到的,数组是松散的,所以给它传空值是可以的,我们正是用到了这个优点。

method(...['parameter1', , 'parameter3']); // 代码执行了...

3. 数组去重

我一直不明白为什么数组不提供一个内置函数可以让我们方便的取到去重以后的值。扩展运算符帮到了我们,使用扩展运算符配合 Set可以生成一个不重复的数组。

const arr = [...new Set([1, 2, 3, 3])];
// [1, 2, 3]

4.从后向前获取数组元素

如果你想从后向前获取一个数组的元素,可以这样写:

var arr = [1, 2, 3, 4]

console.log(arr.slice(-1)) // [4]
console.log(arr.slice(-2)) // [3, 4]
console.log(arr.slice(-3)) // [2, 3, 4]
console.log(arr.slice(-4)) // [1, 2, 3, 4]

5.短路条件句

如果你想在某个条件逻辑值为true时,执行某个函数,就像这样:

if (condition) {
  dosomething()
}

这时,你可以这样子运用短路:

condition && dosomething()

6.用操作符 “||” 来设置默认值

如果你必须给一个变量赋默认值,可以简单的这样写:

var a

console.log(a) // undefined
a = a || 'default value'
console.log(a) // default value
a = a || 'new value'
console.log(a) // default value

7.在相等比较中使用 Object.is()

我们都知道 JavasSript 是弱类型的,并且当我们使用==作比较时,在一些情况下由于类型转换或者说“把两个操作数中的一个转换成另一个,然后再比较”,会出现意想不到的结果。就像这样:

0 == ' ' //true
null == undefined //true
[1] == true //true

因此 JavaScript 中给我们提供了全等操作符 ===, 它比不全等操作符更加严格并且不会发生类型转换。但是用 === 来进行比较并不是最好的解决方案。你可能会得到:

NaN === NaN //false

ES6 中提供了新的 Object.is() 方法,它具有 === 的一些特点,而且更好、更精确,在一些特殊案例中表现的很好:

Object.is(0 , ' '); //false
Object.is(null, undefined); //false
Object.is([1], true); //false
Object.is(NaN, NaN); //true

8.给一个函数 Bind 对象

我们经常需要将一个对象绑定到一个方法的 this 上。在 JS 中,如果你想要调用一个函数并指定它的 this 时可以使用 bind 方法。

Bind 语法

fun.bind(thisArg[, arg1[, arg2[, ...]]])

参数

thisArg

当绑定函数被调用时,该参数会作为原函数运行时的 this 指向。

arg1, arg2, …

当绑定函数被调用时,这些参数将置于实参之前传递给被绑定的方法。

返回值

返回由指定的this值和初始化参数改造的原函数拷贝

JS 中的实例

const myCar = {
 brand: 'Ford',
 type: 'Sedan',
 color: 'Red'
};

const getBrand = function () {
 console.log(this.brand);
};

const getType = function () {
 console.log(this.type);
};

const getColor = function () {
 console.log(this.color);
};

getBrand(); // object not bind,undefined

getBrand(myCar); // object not bind,undefined

getType.bind(myCar)(); // Sedan

let boundGetColor = getColor.bind(myCar);
boundGetColor(); // Red

9.获取文件拓展名

解决方法 1: 正则表达式

function getFileExtension1(filename) {
  return (/[.]/.exec(filename)) ? /[^.]+$/.exec(filename)[0] : undefined;
}

解决方法 2: String的split方法

function getFileExtension2(filename) {
  return filename.split('.').pop();
}

这两种解决方法不能解决一些边缘情况,这有另一个更加强大的解决方法。

解决方法 3: String的slice、lastIndexOf方法

function getFileExtension3(filename) {
  return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2);
}

console.log(getFileExtension3(''));                            // ''
console.log(getFileExtension3('filename'));                    // ''
console.log(getFileExtension3('filename.txt'));                // 'txt'
console.log(getFileExtension3('.hiddenfile'));                 // ''
console.log(getFileExtension3('filename.with.many.dots.ext')); // 'ext'

这是如何实现的呢?

  • String.lastIndexOf() 方法返回指定值(本例中的'.')在调用该方法的字符串中最后出现的位置,如果没找到则返回 -1。
  • 对于'filename'和'.hiddenfile',lastIndexOf的返回值分别为0和-1无符号右移操作符(»>) 将-1转换为4294967295,将-2转换为4294967294,这个方法可以保证边缘情况时文件名不变。
  • String.prototype.slice() 从上面计算的索引处提取文件的扩展名。如果索引比文件名的长度大,结果为""。

10.预防unapply攻击

重写内置对象的原型方法,外部代码可以通过重写代码达到暴漏和修改已绑定参数的函数。这在es5的方法下使用polyfill时是一个严重的安全问题。

// bind polyfill 示例
function bind(fn) {
  var prev = Array.prototype.slice.call(arguments, 1);
  return function bound() {
    var curr = Array.prototype.slice.call(arguments, 0);
    var args = Array.prototype.concat.apply(prev, curr);
    return fn.apply(null, args);
  };
}


// unapply攻击
function unapplyAttack() {
  var concat = Array.prototype.concat;
  Array.prototype.concat = function replaceAll() {
    Array.prototype.concat = concat; // restore the correct version
    var curr = Array.prototype.slice.call(arguments, 0);
    var result = concat.apply([], curr);
    return result;
  };
}

上面的函数声明忽略了函数bind的prev参数,意味着调用unapplyAttack之后首次调用.concat将会抛出错误。

使用Object.freeze,可以使对象不可变,你可以防止任何内置对象原型方法被重写。

(function freezePrototypes() {
  if (typeof Object.freeze !== 'function') {
    throw new Error('Missing Object.freeze');
  }
  Object.freeze(Object.prototype);
  Object.freeze(Array.prototype);
  Object.freeze(Function.prototype);
}());

点击这里,你可以阅读更多关于unapply攻击。

11.Javascript多维数组扁平化

下面是将多位数组转化为单一数组的三种不同方法。

var arr = [[1, 2],[3, 4, 5], [6, 7, 8, 9]];

期望结果:

[1, 2, 3, 4, 5, 6, 7, 8, 9]

解决方案1:使用concat()和apply()

var newArr = [].concat.apply([], arr);
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

解决方案2:使用reduce()

var newArr = arr.reduce(function(prev, curr) { 
  return prev.concat(curr); 
},[]);
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

解决方案3:使用 ES6 的展开运算符

var newArr = [].concat(...arr);
console.log(newArr);
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

12. 函数中如何使用可选参数(包括可选回调函数)

实例函数中第2个与第3个参数为可选参数

function example( err, optionA, optionB, callback ) {
        // 使用数组取出arguments
        var args = new Array(arguments.length);
        for(var i = 0; i < args.length; ++i) {
            args[i] = arguments[i];
        };
        
        // 第一个参数为错误参数
        // shift() 移除数组中第一个参数并将其返回
        err = args.shift();

        // 如果最后一个参数是函数,则它为回调函数
        // pop() 移除数组中最后一个参数并将其返回
        if (typeof args[args.length-1] === 'function') { 
            callback = args.pop();
        }
        
        // 如果args中仍有元素,那就是你需要的可选参数
        // 你可以像这样一个一个的将其取出:
        if (args.length > 0) optionA = args.shift(); else optionA = null;
        if (args.length > 0) optionB = args.shift(); else optionB = null;

        // 像正常一样继续:检查是否有错误
        if (err) { 
            return callback && callback(err);
        }
        
        // 打印可选参数
        console.log('optionA:', optionA);
        console.log('optionB:', optionB);
        console.log('callback:', callback);

        /* 你想做的逻辑 */

    }

    // ES6语法书写更简短
    function example(...args) {
        // 第一个参数为错误参数
        const err = args.shift();
        // 如果最后一个参数是函数,则它为回调函数
        const callback = (typeof args[args.length-1] === 'function') ? args.pop() : null;

        // 如果args中仍有元素,那就是你需要的可选参数你可以像这样一个一个的将其取出:
        const optionA = (args.length > 0) ? args.shift() : null;
        const optionB = (args.length > 0) ? args.shift() : null;
        // ... 重复取更多参数

        if (err && callback) return callback(err);

        /* 你想做的逻辑 */
    }

13. 使用 tap 来快速 debug

在这里 tap 是一个可以用来快速调试、链式调用、匿名函数,还可以打印任何你想打印的东西的函数。

function tap(x) {
    console.log(x);
    return x;
}

为什么我们不用 console.log 这个老方式了?让我来一段伪代码:

bankTotalsByClient(bankInfo(1, banks), table)
            .filter(c => c.balance > 25000)
            .sort((c1, c2) => c1.balance <= c2.balance ? 1 : -1 )
            .map(c =>
                 console.log(`${c.id} | ${c.taxNumber} (${c.name}) => ${c.balance}`));

现在,加入你从这个链式调用中没有得到任何返回。 在哪里除了问题呢?或许 bank_info 没有返回东西,我们需要监听(tap)它:

bankTotalsByClient(tap(bankInfo(1, banks)), table)

基于我们特殊的实现,它可能会打印一些东西,也可能什么也不打印。 我们假设,打印出来的东西是正确的,因此, bankInfo 没有问题。

我们需要继续调试下一个函数, filter.

 .filter(c => tap(c).balance > 25000)

我们可以得到 c 吗?如果可以,说明 bankTotalsByClient 运行正常。 可能是 filter 内的条件有问题?

.filter(c => tap(c.balance > 25000))

我们发现除了 false 没有打印其他东西,所以说明没有一个 client > 25000, 这就是为什么方法什么也没返回的原因。

(附) 更先进的 tap

function tap(x, fn = x => x) {
    console.log(fn(x));
    return x;
}

让我们来看一下一个更强大,如果我们想在监听(tap)之前事先做一些操作应该怎么办?比如,我们只想方位某个对象特定的参数,位于一个逻辑运算,等等。使用上面的方法,在调用的时候增加一个额外参数,这个函数在被监听(tap)的时候就会被执行。

tap(3, x => x + 2) === 3; // 5

14. 反转字符串

const reverseString = string => [...string].reverse().join('')
reverseString('Hello Word!') // "!dlroW olleH"

15. 返回任意数字之和

const sumNumbers = (...arr) => [...arr].reduce((accumulator, currentValue) => accumulator + currentValue, 0)
sumNumbers(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) // 55
sumNumbers(...[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) // 55
sumNumbers(1) // 1

16. 防抖和节流

在进行窗口的resize、scroll,输入框内容校验或搜索等操作时,如果事件处理函数调用的频率无限制,会加重浏览器的负担,导致用户体验非常糟糕。此时我们可以采用debounce(防抖)和throttle(节流)的方式来减少调用频率,同时又不影响实际效果。

函数防抖(debounce)

当持续触发事件时,一定时间段内没有再触发事件,事件处理函数才会执行一次,如果设定的时间到来之前,又一次触发了事件,就重新开始延时。举个例子,持续触发scroll事件时,并不执行handle函数,当1000毫秒内没有触发scroll事件时,才会延时触发scroll事件。

// 防抖函数
function debounce(fn, delay) {
    let timeout = null
    return function () {
        if (timeout) {
            clearTimeout(timeout)
        }
        timeout = setTimeout(fn, delay)
    }
}

// 事件处理函数
function handle() {    
    console.log(Math.random())
}

window.addEventListener('scroll', debounce(handle, 1000))

当持续触发scroll事件时,事件处理函数handle只在停止滚动1000毫秒之后才会调用一次,也就是说在持续触发scroll事件的过程中,事件处理函数handle一直没有执行。

函数节流(throttle)

持续触发事件时,保证一定时间段内只调用一次事件处理函数。节流通俗解释就比如我们水龙头放水,阀门一打开,水哗哗的往下流,秉着勤俭节约的优良传统美德,我们要把水龙头关小点,最好是如我们心意按照一定规律在某个时间间隔内一滴一滴的往下滴。比如,持续触发scroll事件时,并不立即执行handle函数,每隔1000毫秒才会执行一次handle函数。

函数节流主要有两种实现方法:时间戳和定时器。接下来分别用两种方法实现throttle

节流throttle代码(时间戳):

// 节流代码
function throttle(fn, delay) {
    let prev = Date.now()
    return function() {
        let _this = this
        let args = arguments
        let now = Date.now()
        if (now - prev >= delay) {
            fn.apply(_this, args)
            prev = Date.now()
        }
    }
}
// 事件处理函数
function handle() {            
  console.log(Math.random());        
}
window.addEventListener('scroll', throttle(handle, 1000));

当高频事件触发时,第一次会立即执行(给scroll事件绑定函数与真正触发事件的间隔一般大于delay,如果你非要在网页加载1000毫秒以内就去滚动网页的话,我也没办法,而后再怎么频繁地触发事件,也都是每delay时间才执行一次。而当最后一次事件触发完毕后,事件也不会再被执行了 (最后一次触发事件与倒数第二次触发事件的间隔小于delay,为什么小于呢?因为大于就不叫高频了。

节流throttle代码(定时器):

function throttle(fn, delay) {
    let timeout = null
    return function() {
        let _this = this
        let args = arguments
        if (!timeout) {
            timeout = setTimeout(function(){
                fn.apply(_this, args)
                timeout = null
            },delay)
        }
    }
}
// 事件处理函数
function handle() {
    console.log(Math.random())
}
window.addEventListener('scroll', throttle(handle, 1000));

触发事件的时候,我们设置一个定时器,再次触发事件的时候,如果定时器存在,就不执行,直到delay时间后,定时器执行执行函数,并且清空定时器,这样就可以设置下个定时器。当第一次触发事件时,不会立即执行函数,而是在delay秒后才执行。而后再怎么频繁触发事件,也都是每delay时间才执行一次。当最后一次停止触发后,由于定时器的delay延迟,可能还会执行一次函数。

节流中用时间戳或定时器都是可以的。更精确地,可以用时间戳+定时器,当第一次触发事件时马上执行事件处理函数,最后一次触发事件后也还会执行一次事件处理函数。

节流throttle代码(时间戳+定时器):

function throttle(fn, delay) {
    let timeout = null
    let startTime = Date.now()
    return function() {
        let _this = this
        let args = arguments
        let curTime = Date.now()
        clearTimeout(timeout)
        let remaining = delay - (curTime - startTime)
        if (remaining <= 0) {
            fn.apply(_this, args)
            startTime = Date.now()
        } else {
            timeout = setTimeout(fn, remaining)
        }
    }
}

// 事件处理函数
function handle() {
    console.log(Math.random());
}

window.addEventListener('scroll', throttle(handle, 1000));

在节流函数内部使用开始时间startTime、当前时间curTime与delay来计算剩余时间remaining,当remaining<=0时表示该执行事件处理函数了(保证了第一次触发事件就能立即执行事件处理函数和每隔delay时间执行一次事件处理函数)。如果还没到时间的话就设定在remaining时间后再触发 (保证了最后一次触发事件后还能再执行一次事件处理函数)。当然在remaining这段时间中如果又一次触发事件,那么会取消当前的计时器,并重新计算一个remaining来判断当前状态。

总结

函数防抖:将几次操作合并为一次操作进行。原理是维护一个计时器,规定在delay时间后触发函数,但是在delay时间内再次触发的话,就会取消之前的计时器而重新设置。这样一来,只有最后一次操作能被触发。

函数节流:使得一定时间内只触发一次函数。原理是通过判断是否到达一定时间来触发函数。

区别: 函数节流不管事件触发有多频繁,都会保证在规定时间内一定会执行一次真正的事件处理函数,而函数防抖只是在最后一次事件后才触发一次函数。 比如在页面的无限加载场景下,我们需要用户在滚动页面时,每隔一段时间发一次 Ajax 请求,而不是在用户停下滚动页面操作时才去请求数据。这样的场景,就适合用节流技术来实现。

本片文章部分内容引自github.com/loverajoel/…