Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS-函数 #99

Open
yaofly2012 opened this issue Feb 10, 2020 · 3 comments
Open

JS-函数 #99

yaofly2012 opened this issue Feb 10, 2020 · 3 comments

Comments

@yaofly2012
Copy link
Owner

yaofly2012 commented Feb 10, 2020

Function

一、概述

一般通过函数字面量创建函数:

function Func(){}

构成的原型链关系:
image

  1. 每个函数都具有prototype属性(除了箭头函数和Function.prototype),并且该属性含有constructor属性,属性值指向本身。创建一个函数的大致流程是这样的(结合上例):
var Func = Object.create(Function.prototype); // 创建函数对象
Func.prototype = { // 创建函数对象的prototype属性
    constructor: Func // 给prototype属性赋值
} 
  1. JS中函数也是对象(函数对象),函数是一种可调用的对象。Function.prototype是所有函数的原型。
    Object也是函数,它的原型也是Function.prototype
Function.prototype.isPrototypeOf(Function); // true
Function.prototype.isPrototypeOf(Object); // true

内置的对象Date, String, Regexp, Array等也都是函数,同样原型都是Function.prototype

二、定义

2.1 字面量:声明方式

function add(x, y) {
    return x + y;
}

函数声明提升

JS-ES6-let/const & 变量生命周期

语句块里声明函数

看到两个问题:

  1. 美团前端二面,读代码题求解
  2. Function declarations inside if/else statements?

总结下来就是:

  1. 不要这样做,非严格模式下各个浏览器(甚至同版本浏览器在是否严格模式下)实现行为不一致;
  • 块内声明的函数会提示,但只在块内;
  • 块外部也可以访问,就像是块所在函数声明了函数变量,块内顶部使用函数表达式定义函数。
    但是如果存在命名冲突,则规则就比较凌乱了,看看上面引用的资料。
function getNum() {
  console.log(1, typeof temp) // 1 "undefined"
  for (var i = 0; i < 1; i++) {
    console.log(2, typeof temp)    //2 "function"
    function temp(i) {}
    temp(i)
  }
  console.log(3, typeof temp) // 3 "function"
}

getNum();
  1. 严格模式下(测试Chrome,FireFox, IE)行为一致:
    函数作用域只在块作用域里,并且发生声明提升也局限在块作用域里
;(function() {
    "use strict"
    var a = 0;
    if (true) {
        a = 1;
        function a() {}
        a = 21;
        console.log(a); // 21
    }
    console.log(a); // 0
})()
  1. JSLint发现“语句块里声明函数”也会报错的,可以看看解释"Function declarations should not be placed in blocks"

2.2 字面量:定义表达式

var add = function (x, y) {
    return x + y;
}
  1. 函数定义表达式函数名称可以省略(匿名函数表达式);
  2. 如果指定了函数名(命名函数表达式,Named Function Expression),则产生两个特性:
  • 函数名字变量作用域只限于函数体内,外部无法访问;
  • 函数名字变量是只读的。
// 作用域
var add = function Add(x, y) {
    console.log(Add.length)  // 函数体内可以访问
    return x + y;
}

add(1, 2)
Add(1, 2) // ReferenceError: Add is not defined

//
(function b() {
    console.log(b);
    b = 2; // b是只读的赋值无效,严格模式会抛异常
    console.log(b);
})() 

2.3 Function构造函数/工厂函数

很少遇到把,主要用于创建动态函数。

  1. Function()等价于new Function()
// 等价Function('a', 'b', 'return a + b');
const sum = new Function('a', 'b', 'return a + b');

sum instanceof Function // true

console.log(sum(2, 6)); // 8
  1. 函数定义表达式一种形式,不过不同于字面量函数定义表达式,Function构造函数创建的函数执作用域是全局作用域。
var a = 2, b = 5;

;(() => {
    var a = 12, b =15;
    var sum = new Function('return a + b');
    console.log(sum()); // 7, 不是27
})()

三、函数调用方式

3.1 额外变量

函数执行时会有两个额外的变量:

1. this:

函数执行的上下文,根据调用方式不同取值逻辑不同。

2. arguments

函数调用实参列表(伪数组),一般通过Array.prototype.slice.call(arguments)转成数组。

3.2 四种调用方式

1. 函数方式

  1. this取值逻辑
  • ES3下是全局对象
  • ES5严格模式下是undefined, 非严格模式下是全局对象

2. 方法方式

  1. this取值逻辑即为调用的对象

3. new方式

  1. 执行逻辑和this取值逻辑参考new方式创建对象

只有是构造函数才可以通过new方式调用,否则抛TypeError异常。

var A = () => {}
// TypeError: A is not a constructor
var a = new A();
  1. 不可以作为构造函数的函数有:
  • 箭头函数
  • class成员方法,静态成员方法
  • Function.prototype
  • Symbol函数

其实内置函数对象都不可以作为构造函数的,除非显示的说明。并且不可以作为构造函数的内置函数都没有prototype属性的。
ecma-262: Built-in Function Objects

Built-in function objects that are not identified as constructors do not implement the [[Construct]] internal method unless otherwise specified in the description of a particular function.
Built-in functions that are not constructors do not have a prototype property unless otherwise specified in the description of a particular function

如何判断一个函数是否可以作为构造函数 ?
没有内置的方法判断,可以利用new调用是否抛异常判断:

function isConstructor(f) {
  try {
    new f();
  } catch (err) {
    // verify err is the expected error and then
     if (err.message.indexOf('is not a constructor') >= 0) {
      return false;
    }
  }
  return true;
}

这个方式是存在缺点的,会额外调用f函数,可能会造成副作用。所以如果一个函数如果不支持new方式构造,最好函数内部增加判断,针对new方式调用报错。

如何判断一个函数正在通过new方式调用 ?

function foo() {
  if (this instanceof foo) {
    throw new Error("Don't call 'foo' with new");
  }
}

4. apply/call方式

  1. 指定this变量
  2. 区分apply/call

四、Function API

Function本身就是个构造函数,用于创建函数对象的,也就是说Function是所有函数(除了Function.prototype)的构造函数。

(() => {}) instanceof Function; // true
(function () {}) instanceof Function; // true
Object instanceof Function; // true
Function instanceof Function; // true, `Function`本身就是个构造函数

4.1 Function.prototype.apply(thiArg[, argsArray])

留意从 ECMAScript 第5版开始argsArray可以是伪数组。

4.2 Function.prototype.call(thisArg, thisArg, arg1, arg2, ...)

主要区分apply

While the syntax of this function is almost identical to that of call(), the fundamental difference is that call() accepts an argument list, while apply() accepts a single array of arguments.

既然两者之间的差别这么小,为什么要同时提供这两个函数?

4.3 Function.prototype.bind(thisArg[, arg1[, arg2[, ...]]])

bind方法和apply/call类似,但有着本质的区别,bind方法不会执行函数,只是返回一个绑定指定this和参数柯里化的新函数(bind的两大功能)。

如何写bind方法的polyfill ???

首先罗列下bind的功能:

  1. 绑定this变量【必须实现】;
  2. 偏函数应用(不是柯里化~_~)【必须实现】;
    2.1 这里隐含一个功能即: 偏函数化后的bound函数length属性的值也跟着变化,不过功能很少用。【非必须】
  3. bound函数可以new方式调用,并且当new方式调用bound函数时,忽略指定的this变量;【可以不实现】
  4. 原函数的prototype属性也在new方式调用创建的对象的原型链上。这侧面说明了bound函数的prototype属性是原函数的实例。【可以不实现】
function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function() { 
  return `${this.x},${this.y}`; 
};

var p = new Point(1, 2);


var emptyObj = {};
var YAxisPoint = Point.bind(emptyObj, 0/*x*/);

var axisPoint = new YAxisPoint(5);
console.log(axisPoint.toString());                    // '0,5'

console.log(axisPoint instanceof Point);              // true
console.log(axisPoint instanceof YAxisPoint);         // true
console.log(new YAxisPoint(17, 42) instanceof Point); // true

// bound函数的`prototype`属性也是原函数的实例
console.log(YAxisPoint.prototype instanceof Point);   // true

实现
了解bind的功能后就可以写polyfill了,当然了功能越复杂,性能就越差,所以有时候只实现常用功能也是不错的选择。

版本1:

只实现绑定this和偏函数(一般用于回调函数绑定this)

function bind(thisArg) {
    var slice = Array.prototype.slice;
    var preArgs = slice.call(arguments, 1);
    var func = this;
    
    // 类型判断
    if(typeof func !== 'function') {
        throw new TypeError('Function.prototype.bind just from function')
    }

    function bound() {
        var args = preArgs.concat(slice.call(arguments));
        var result = func.apply(thisArg, args);        
        return result;
    }

    return bound
}

// Test
Function.prototype.bind =bind 
function add(a, b) {
    return +Object(a + b);
}

var add2 = add.bind(null, 2);
var r = new add2(3);

console.log(add2(3)); // 5
console.log(r) // {}
console.log(r instanceof add2) // true
console.log(r instanceof add) // false
console.log(add2.prototype instanceof add) // false

版本2:

增加new方式调用处理。

function bind(thisArg) {
    var slice = Array.prototype.slice;
    var preArgs = slice.call(arguments, 1);
    var func = this;
    
    // 类型判断
    if(typeof func !== 'function') {
        throw new TypeError('Function.prototype.bind just from function')
    }

    function bound() {
        var args = preArgs.concat(slice.call(arguments));
        var result;

        // new方式调用,则模拟new行为
        if(this instanceof bound) {            
            result = func.apply(this, args);
            if(Object(result) !== result) {
                result = this;
            }
        } else {
            result = func.apply(thisArg, args);
        }
                
        return result;
    }

    // 原函数的prototype属性也在new方式调用创建的对象的原型链上
    var EmptyFunc = function(){}
    EmptyFunc.prototype = func.prototype;
    bound.prototype = new EmptyFunc();

    return bound
}

// Test
Function.prototype.bind = bind
function add(a, b) {
    return +Object(a + b);
}

var add2 = add.bind(null, 2);
var r = new add2(3);

console.log(add2(3)); // 5
console.log(r) // {}
console.log(r instanceof add2) // true
console.log(r instanceof add) // true
console.log(add2.prototype instanceof add) // true

不过没有实现更新bound函数的length属性值。这个功能太少用了,如果非要实现得借助eval或则Function动态创建函数了。

版本3:完全实现

function bind(thisArg) {
    var slice = Array.prototype.slice;
    var preArgs = slice.call(arguments, 1);
    var func = this;
    
    // 类型判断
    if(typeof func !== 'function') {
        throw new TypeError('Function.prototype.bind just from function');
    }

    function binder() {
        var args = preArgs.concat(slice.call(arguments));
        var result;

        // new方式调用,则模拟new行为
        if(this instanceof bound) {            
            result = func.apply(this, args);
            if(Object(result) !== result) {
                result = this;
            }
        } else {
            result = func.apply(thisArg, args);
        }
                
        return result;
    }

    // bound函数的`prototype`属性是原函数的实例
    var EmptyFunc = function(){}
    EmptyFunc.prototype = func.prototype;
    binder.prototype = new EmptyFunc();

    // 动态创建bound函数
    
    // 1.创建形参列表。兼容传递过多的preArgs实参数量
    var restArgsCount = Math.max(0, func.length - preArgs.length);
    var restArgs = [];
    for(var i = 0; i < restArgsCount; ++i) {
        restArgs.push('$' + i);
    }

    /**
    * 2. 利用Function创建函数
    * apply函数ES5已经支持类数组了,也可以不用传递slice参数
    * 因为`Function`创建的函数作用域在全局作用域,所以这里并没有直接创建bound 函数,而是先利用`Function`创建个匿名函数,然后在显示的吧`binder`作为参数传入,再生产bound 函数。
用eval: `var bound = eval('(function(' + restArgs + '){ return binder.apply(this, slice.call(arguments))})');`
    */ 
    var bound = Function('binder', 'slice', 'return function(' + restArgs + '){ return binder.apply(this, slice.call(arguments))}')(binder, slice);

    return bound
}

// Test
Function.prototype.bind = bind
function add(a, b) {
    return +Object(a + b);
}

var add2 = add.bind(null, 2);
var r = new add2(3);

console.log(add2.length) // 1
console.log(add2(3));
console.log(r)
console.log(r instanceof add2)
console.log(r instanceof add)
console.log(add2.prototype instanceof add)

npm function-bind已经提供完整的解决方案。

五、研究Function.prototype属性

Function.prototype属性作为所有函数的原型,具有一些特殊的特性。

  1. Function.prototype也是个函数,并且是个空函数(即也没返回值)
console.log(typeof Function.prototype)  // "function"
console.log(Function.prototype()) // "undefined"
  1. Function.prototype函数没有实现内部函数[[Construct]],也没有prototype属性,
  • 无法作为构造函数
// TypeError: Function.prototype is not a constructor
new Function.prototype()
  • 不能出作为instanceof的右值
// TypeError: Function has non-object prototype 'undefined' in instanceof check
Object instanceof Function.prototype

六、深度概念

  1. 闭包
  2. CallStack
  3. 执行上下文
  4. 作用域链

参考

  1. MDN bind函数
  2. MDN 闭包
  3. How to check if a Javascript function is a constructor
  4. MDN Functions指南
@yaofly2012 yaofly2012 added the JS label Feb 10, 2020
@yaofly2012
Copy link
Owner Author

yaofly2012 commented Feb 11, 2020

闭包

了解闭包,先了解函数调用栈,执行上下文

一、作用域

闭包概念太抽象了。先学习下作用域。控制着变量的可见性和生命周期。

1. JS是基于词法作用域的,并且只有:

- 全局作用域
- 函数作用域

2. 变量声明提升:

函数作用域里所有的变量在函数体内都可以见的,JS引擎在编译代码时会把变量声明全部提前到函数体顶部。

(function() {
  console.log(a) // undefined  
  var a = 1;
  console.log(a) // 1

})()

3. 作用域链(scope chain):

  1. 形成:
    JS的最顶层是全局对象(全局作用域),其内部可以定义函数,形成函数作用域,函数内又可以嵌套函数,最终会形成逐层嵌套的作用域链。
  2. 新增节点
    image
    图片来自
  • 定义的函数都会关联一个作用域链,作用域中所有的变量都保存在作用域链中。
  • 函数执行时会创建一个新的对象存储函数的局部变量,并把这个对象存在作用域链中。
  1. 作用域链使得外层作用域的局部变量对内层作用域是可见的。

4. 变量解析:

函数体内变量查找的过程:函数体内引用变量时会沿着作用域链逐个向上查找,直到找到,或者未找到(抛ReferenceError,这个跟原型找属性行为不一样)。即函数内可以访问外部作用域里的变量。

二、什么是闭包?

理解闭包首先要理解作用域,作用域链和变量解析过程。

  1. 来自JS白皮书的定义:

    函数体内的局部变量会保存在函数作用域内,这种特性称为闭包

    作用域是链式的,这样内部函数内就可以访问到外部函数体内定义的变量了(变量解析过程)。

  2. 来自Douglas语言精粹的定义:

    函数可以访问它被创建是所处的上下文环境,这被称为闭包

  3. 来自MDN的解释

    函数与对其状态即词法环境(lexical environment)的引用共同构成闭包(closure),闭包可以让你从内部函数访问外部函数作用域。

  4. 好多直接简单粗暴的把闭包就是函数。

综上构成闭包的三个条件:

  1. 函数内部可以嵌套函数;
  2. 内部函数可以访问外部函数声明的局部变量;
  3. 函数可以作为函数的返回值,即可以作为右值。
    函数作为JS一等公民

三、为什么需要闭包?

编码中我们经常需要在内存中持久化变量。局部变量无法共享和长久的保存,而全局变量可能造成变量污染,所以我们希望有一种机制既可以长久的保存变量又不会造成全局污染。
闭包机制解决了变量在内存中持久化问题

四、一些应用场景

有函数的地方就有闭包

  1. 私有变量
  2. 唯一ID生成
    组件库的zIndex生成
  3. 高阶函数
  4. 柯里化
  5. 记忆函数

五、缺点

  1. 函数内部变量不能被释放,会占用更多的内存,甚至不恰当的用法容易造成内存泄露;
  2. 外部函数执行时,内部函数都是重新创建,对性能也会造成影响;
function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype = {
  getName: function() {
    return this.name;
  },
  getMessage: function() {
    return this.message;
  }
};

所以尽量只在必须时,才使用闭包。

参考

  1. MDN bind函数
  2. MDN 闭包
  3. 知乎 图解JS闭包
  4. JS执行可视化工具,可以查看闭包

@yaofly2012
Copy link
Owner Author

yaofly2012 commented Feb 12, 2020

练习

1. call 和 apply 的区别是什么,哪个性能更好一些

  1. 都是调用函数,但是参数不同;
  • apply针对多个参数的case更方便些;
  • call则更直观,性能更好。优先使用call,多个参数也可利用解构赋值简化写法,可完全代替了apply
  1. 至于性能calapply更好些,
    lodash源码
/**
   * A faster alternative to `Function#apply`, this function invokes `func`
   * with the `this` binding of `thisArg` and the arguments of `args`.
   *
   * @private
   * @param {Function} func The function to invoke.
   * @param {*} thisArg The `this` binding of `func`.
   * @param {Array} args The arguments to invoke `func` with.
   * @returns {*} Returns the result of `func`.
   */
  function apply(func, thisArg, args) {
    switch (args.length) {
      case 0: return func.call(thisArg);
      case 1: return func.call(thisArg, args[0]);
      case 2: return func.call(thisArg, args[0], args[1]);
      case 3: return func.call(thisArg, args[0], args[1], args[2]);
    }
    return func.apply(thisArg, args);
  }

2. 请写出如下代码的打印结果

function Foo() {
    Foo.a = function() {
        console.log(1)
    }
    this.a = function() {
        console.log(2)
    }
}
Foo.prototype.a = function() {
    console.log(3)
}
Foo.a = function() {
    console.log(4)
}
Foo.a(); // 1
const obj = new Foo();
obj.a(); // 2
Foo.a(); // 3

3. 什么是防抖和节流?有什么区别?如何实现?

如下

4. memorize函数。

所谓记忆函数就是相同的输入,直接返回结果,不用再执行一次函数。有几点注意:

  1. 是函数的输入只有实参吗?
    还有this变量,相同的实参,不同的this也可能会造成不同的返回值。
  2. 如何比较两次实参是相等的?
    arguments是个类数组对象。不可以直接比较,得遍历逐个比较实参。但是采用什么比较策略呢?毕竟JS有绝对相等,同值相等算法。比较明智方式让用户自定义。
  3. 每种调用case(this+实参)都缓存吗?
    这个要看具体都需求了。如果都缓存,怎么生成唯一key呢?又怎么控制缓存过大?

实现1:只缓存最新都调用case

function memorize(func, isArgumentsEqual) {
    var lastThis;
    var lastArgs;
    var lastResult;

    function isArgumentsEqualDefault(lastArgs, currentArgs) {
        if(!lastArgs || !currentArgs || lastArgs.length !== currentArgs.length) {
            return false;
        }

        var length = lastArgs.length;
        for(var i = 0; i < length; ++i) {
            if(lastArgs[i] !== currentArgs[i]) {
                return false;
            }
        }

        return true;
    }

    // 如果没指定比较函数,则取默认的
    if(isArgumentsEqual == null) {
        isArgumentsEqual = isArgumentsEqualDefault;
    }

    return function memorized() {       
        if(this === lastThis && isArgumentsEqual(lastArgs, arguments)) {           
            return lastResult;
        }
        // 先调用,
        lastResult = func.apply(this, arguments);
        // 如果正常执行函数,则再缓存`this`和`arguments`
        lastArgs = arguments;
        lastThis = this;

        return lastResult;
    }
}

function add(a, b) {
    console.log(`this.name=${this.name}`)
    return a + b;
}

var memorizedAdd = memorize(add);

console.log(memorizedAdd(1,2))
console.log(memorizedAdd(1,2))

var a = {
    name: 'john'
}
// 修改this
console.log(memorizedAdd.call(a, 1,2))
console.log(memorizedAdd.call(a, 1,2))

类似的库memoize-one

实现2:缓存所有调用case
如何给“this+arguments”生成唯一key呢?

  1. JSON.stringify
    对于函数实参就无能为力啊,并且如果this比较大(如全局变量)也不合适。

  2. toString
    函数是可以了,但是对象的toString结果都一样啊,得遍历属性join所有toString结果。
    性能跟不上吧。

非要实现的话只能根据实际情况选择的合适的方式了。最好还是使用“只缓存最新都调用case”。

@yaofly2012
Copy link
Owner Author

yaofly2012 commented Nov 4, 2020

防抖和节流

一、防抖(debounce)

指定时间内,方法只能执行一次,多余的事件直接忽略掉(underscore, lodash库的debounce都是这样做的)。而这个时间的计算,是从最后一次触发监听事件开始算起。
比如:

  • 按关闭电梯门的按钮时,电梯并不会里面关门,而是等一等看看是否有人要上电梯(即操作要延迟执行);
  • resize事件(如果只是看resize后最终的效果,可以利用debounce);
  • 提交按钮防抖操作(一般操作需要立马执行)。

1.2 基于概念实现:

实现debounce的关键点是如何计算delay时间(实现throttle的关键点也一样)。

function debounce(cb, delay) {
    var timeoutId;
    return function debounced() {
        // 每次调用都清除上次的延迟,然后重新创建延迟
        timeoutId&& clearTimeout(timeoutId);
        timeoutId= setTimeout(() => {
            cb.apply(this, arguments);
        }, delay)
    }
}

1.3 各种实现中增加一些额外功能:

  1. 前缘(或者“immediate”)
    是先执行操作后等待,还是先等待后执行操作。

前缘debounce:
image

延迟debounce:
image

两者计算delay时间的方式是一样的,唯一的区别就是触发调用函数的时间点

  1. 取消操作
    比如在计算延迟过程中组件卸载等导致的了清除操作。

带有"取消操作"和“前缘”的防抖方法(非一次写出,多次优化的结果):

function debounce(cb, delay, immediate) {
    var timeoutId;
    // cancel操作,只依赖timeoutId,所以应避免cancel函数放在debounced函数里声明(这回导致每次调用debounced都会重新声明定义cancel)
    var cancel = debounced.cancel = function cancel() {
        if(timeoutId) {
            clearTimeout(timeoutId);
            timeoutId= null;
        }
    }

    function debounced() {
        var args = arguments;
        var self = this;    
         
        function action() {
            cb.apply(self, args);
        }      
         
        // 利用timeoutId标记是否已经调用了cb
        if(immediate && !timeoutId) {
            action();
        }

        // 清除上一个delay
        cancel();

        /*
        * 开启新的delay
        * immediate=true表示delay取消操作,否则delay函数执行。
        */
        timeoutId= setTimeout(immediate ? cancel : action, delay)       
    }
        
    return debounced;
}

上面实现存在个问题,它是利用setTimeout创建/取消延时的,而不是动态计算延时。这回导致如果连续的操作中存在耗时运算,会导致setTimeout回调不能及时触发。

function sleep(delay) {
    var pre = Date.now();
    while(Date.now() - pre < delay) {}
}

var debounced = debounce((a, b) => {
     console.log(`a+b=${a+b}`)
     return a + b;
}, 100, true)

// Case1
debounced(1, 23)
sleep(delay) // 同步方式延迟,导致`debounced`里的`setTimeout(immediate ? cancel : action, delay)`回调函数不会被执行
debounced(1, 24) // 虽然时间过去600ms了,但是还是被忽略掉了。

// Case2
debounced(1, 25)
setTimeout(() => {
    debounced(1, 26) // 改成异步,就可以触发了
}, 600)

不过这样的实现相对简单清晰,并且绝大部分情况不会存在问题。
underscore debounce也是采用类似方式。

二、节流(throttle)

节流本质是为了降低函数执行的频率。
完整的解释是指定时间内(执行时间窗口),方法只能执行一次,而这个时间的计算是从上次执行方法开始算起。

舍弃和保留*:

  1. 舍弃:多余的事件直接忽略掉,即降低了函数执行的频率
  2. 保留:this和实参需要使用最新的。

underscore, lodash库的throttle都是这样做的

应用场景:
上划展示更多商品时调用接口的频率会降低,一般处理scroll, resize, touchmove, mousemove等事件的处理函数。

2.1 基于概念的实现

function throttle(fn, delay) {
    let previous = 0;
    return function throttled() {
        const now = Date.now();
        const remain = delay - (now - previous);
        if(remain <= 0) {
            previous = now;
            fn.apply(this, arguments);
        }
    }
}
  1. 逻辑简单,只是实现了leading方式调用(即时间窗口开始调用);
  2. 注意leading方式的throttle并不需要setTimeout
  3. underscorejs throttle的判断条件居然有点不同:if (remaining <= 0 || remaining > wait) { !!!
    remaining > wait什么时候为true呢?
    场景:已经返回throttled函数后,修改系统的时间为之前的时间(即导致now小于previous)。
    参考When is the 'remaining > wait' conditional statement ever true in underscore.js's implementation of throttle?

2.2 改进:增加trailing调用控制

  1. trailing方式调用时(即时间窗口结尾调用),delay的时间得是动态的,因为在一个时间段内,后调用的throttled函数被delay时间就得短;
  2. 函数被延迟执行了,但是执行函数的时候该选用首次触发时的this和实参,还是最后一次触发的呢(即最新的)?
    应该选中最新的;
  3. 实现需要借助setTimeout,所以最好加个cancel函数。
function throttle(fn, delay, trailing) {
    let previous = 0, timeoutId, context, args;

    const cancel = throttled.cancel = () => {                                
        timeoutId && clearTimeout(timeoutId);
        timeoutId = context = args = null;
    }

    function throttled() {        
        const now = Date.now();
        context = this;
        args = arguments;             
        // 首次执行trailing的throttled
        if(!previous && trailing) {
            previous = now;
        }
        const remain = delay - (now - previous);            
        if(remain <= 0) {
            // 处理leading方式 OR trailing方式的边界(即setTimeout的回调被延时执行了)
            previous = now;
            fn.apply(context, args);
            // 处理trailing方式的边界
            cancel();
        } else if(!timeoutId && trailing) {
            // 处理trailing方式(延时remain后执行)
            timeoutId = setTimeout(() => {   
                previous = Date.now();             
                fn.apply(context, args);
                cancel();
            }, remain);
        }
    }
    return throttled
}
  1. 关于cancel方法的位置可以放在throttled函数外部,不必想underscorejs那样。免得每次调用throttled函数都创建一次cancel函数。
  2. 注意下trailing方式的编辑case。

2.3 再改进:精简下代码

了解trailing实现的原理后精简下代码:

function throttle(fn, delay, trailing) {
    let previous = 0, timeoutId, context, args;

    const cancel = throttled.cancel = () => {                                
        timeoutId && clearTimeout(timeoutId);
        timeoutId = context = args = null;
    }

    function throttled() {        
        const now = Date.now();
        context = this;
        args = arguments;             
        // 首次执行trailing的throttled
        if(!previous && trailing) {
            previous = now;
        }
        const remain = delay - (now - previous);            
        if(remain <= 0) {
            action();
        } else if(!timeoutId && trailing) {
            // 处理trailing方式(延时remain后执行)
            timeoutId = setTimeout(action, remain);
        }

        function action() {
            previous = Date.now();
            fn.apply(context, args);
            cancel();
        }
    }
    return throttled
}

总结

image
从上面的描述我们可以理解到防抖和节流都是控制“指定时间内,方法只能执行一次”,它们的区别在于如何计算时间间隔:

  • debounce从最后一次调用debounced函数开始算起(新触发的会覆盖上一次触发的);
    防抖动
  • throttle是从上次调用throttled方法开始算起(新出发的时间 - 上次触发的时间)。
    节流,控制频率

实现合一?
lodash确实合一了,但是代码可读性很差。

requestAnimationFrame 代替throttle?

requestAnimationFrame 可以用于throttle(callback, 16)
CSS tricks: Debouncing and Throttling Explained Through Examples

参考

  1. 别只掌握基础的防抖和节流了
  2. 性能优化之节流(throttling)与防抖(debounce)
  3. 模拟防抖节流
  4. lodash/undercore源码:
  1. CSS tricks: Debouncing and Throttling Explained Through Examples
  2. csdn 防抖(debounce) 和 节流(throttling)
    图画的不错

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant