前端进阶算法 -- 003

84 阅读22分钟

1. js中~~| 的妙用

1.1 js中的按位取反运算符~

  JS按位取反运算符~,是对一个表达式执行位非(求非)运算。如1 = -2,-3=2,~true=-2,~false=-1

1.2 如何按位取反计算

  按位取反的运算规则步骤:

1.21 十进制转成原码

  转成二进制原码,最高位是符号位,0为正数,1为负数

    十进制   ---->  原码
      1   ---->  00000001
     -1   ---->  10000001

1.22原码转成反码

  正数的反码就是原码,负数的反码是符号位不变,其余位取反

    十进制  ---->   原码    ---->  反码
      1    ----> 00000001 ----> 00000001
     -1    ----> 10000001 ----> 11111110

1.23 反码转成补码

  正数的补码还是原码,负数的补码是在反码的基础上加1

    十进制  ---->    原码   ---->   反码   ---->  补码
      1    ----> 00000001 ----> 00000001 ----> 00000001
     -1    ----> 10000001 ----> 11111110 ----> 11111111

1.24 补码取反得原码

  正整数补码取反之后符号位置为1,是一个负整数,所以再按照负整数计算补码的方式逆运算得到原码

  逆运算得到原码,首先将取反的补码转成反码,公式:反码=补码 - 1,然后将反码转成原码,符号位不变,其他位取反

  十进制  ---->    原码    ---->   反码    ---->   补码     ---->  补码取反   ---->  取反补码转成反码  ---->  转成原码
    1   ----> 00000001  ----> 0000001  ---->  00000001  ----> 11111110   ---->   11111101    ---->  10000010

  负整数补码取反之后符号位置为0,是一个正整数,因正整数的反码与补码就是本身,所以不需要再进行逆运算

  十进制  ---->    原码    ---->   反码     ---->   补码      ---->   补码取反得原码  
    -1  ---->  10000001  ----> 11111110  ---->  11111111   ---->     00000000    

1.25 将原码转成二进制

 十进制  ---->    原码    ---->   反码    ---->   补码     ---->  补码取反   ---->  取反补码转成反码  ---->  转成原码  ---->  转成二进制
    1   ----> 00000001  ----> 0000001  ---->  00000001  ----> 11111110   ---->   11111101    ---->  10000010 ---->   -2
  十进制  ---->    原码    ---->   反码     ---->   补码      ---->   补码取反得原码   ---->  转成二进制
    -1  ---->  10000001  ----> 11111110  ---->  11111111   ---->    00000000     ---->     0

  所以,1=-2,-1=0

1.3 使用 ~ 和!的区别

  findIndex是查询是否在数组中,存在则返回索引,不存在返回-1,通过~取反,得到的效果与使用!相同,但是两者返回的值不同。

  ~ 返回的是一个整数类型

  !返回的是一个boolean类型

 let arr = [1,2,3,4,5];
 console.log(~arr.findIndex(d=>d===1)?'1存在':'1不存在');  //1存在
 console.log(~arr.findIndex(d=>d===6)?'6存在':'6不存在');   //6不存在
 console.log(~arr.findIndex(d=>d===6))      //0
 console.log(!arr.findIndex(d=>d===6))      //false

1.4 取反再取反~~的作用

  操作符~, 是按位取反的意思,表面上~~(取反再取反)没有意义,实际上在JS中可以将浮点数变成整数。

 console.log(~~1.11);          //1
 console.log(~~-25.11);       //-25

1.41 双~的用法

~~它代表双非按位取反运算符,是比Math.floor()更快的方法。需要注意,对于正数,它向下取整;对于负数,向上取整;非数字取值为0,它具体的表现形式为:

~~null;      // => 0
~~undefined; // => 0
~~Infinity;  // => 0
--NaN;       // => 0
~~0;         // => 0
~~{};        // => 0
~~[];        // => 0
~~(1/0);     // => 0
~~false;     // => 0
~~true;      // => 1
~~1.9;       // => 1
~~-1.9;      // => -1

1.5 |的用法,通常用来取整

1.2|0   // 1
1.8|0   // 1
-1.2|0  // -1

2. 模拟实现 new 操作符

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。new 关键字会进行如下的操作:

  1. 创建一个空的简单JavaScript对象(即{});
  2. 链接该对象(即设置该对象的构造函数)到另一个对象 ;
  3. 将步骤1新创建的对象作为this的上下文 ;
  4. 如果该函数没有返回对象,则返回this

代码实现:

function new_object() {
  // 创建一个空的对象
  let obj = new Object()
  // 获得构造函数
  let Con = [].shift.call(arguments)
  // 链接到原型 (不推荐使用)
  obj.__proto__ = Con.prototype
  // 绑定 this,执行构造函数
  let result = Con.apply(obj, arguments)
  // 确保 new 出来的是个对象
  return typeof result === 'object' ? result : obj
}

警告: 通过现代浏览器的操作属性的便利性,可以改变一个对象的 [[Prototype]] 属性, 这种行为在每一个JavaScript引擎和浏览器中都是一个非常慢且影响性能的操作,使用这种方式来改变和继承属性是对性能影响非常严重的,并且性能消耗的时间也不是简单的花费在 obj.__proto__ = ... 语句上, 它还会影响到所有继承来自该 [[Prototype]] 的对象,如果你关心性能,你就不应该在一个对象中修改它的 [[Prototype]]。相反, 创建一个新的且可以继承 [[Prototype]] 的对象,推荐使用 Object.create()

—MDN

所以进一步优化 new 实现:

// 优化后 new 实现
function create() {
  // 1、获得构造函数,同时删除 arguments 中第一个参数
  Con = [].shift.call(arguments);
  // 2、创建一个空的对象并链接到原型,obj 可以访问构造函数原型中的属性
  let obj = Object.create(Con.prototype);
  // 3、绑定 this 实现继承,obj 可以访问到构造函数中的属性
  let ret = Con.apply(obj, arguments);
  // 4、优先返回构造函数返回的对象
  return ret instanceof Object ? ret : obj;
};

3. 解析 call/apply 原理,并手写 call/apply 实现

3.1 Function.prototype.call()

call() 方法调用一个函数, 其具有一个指定的 this 值和多个参数(参数的列表)。

func.call(thisArg, arg1, arg2, ...)

它运行 func,提供的第一个参数 thisArg 作为 this,后面的作为参数。

看一个简单的例子:

function sayWord() {
  var talk = [this.name, 'say', this.word].join(' ');
  console.log(talk);
}

var bottle = {
  name: 'bottle', 
  word: 'hello'
};

// 使用 call 将 bottle 传递为 sayWord 的 this
sayWord.call(bottle); 
// bottle say hello

所以,call 主要实现了以下两个功能:

  • call 改变了 this 的指向
  • bottle 执行了 sayWord 函数

3.11 模拟实现 call

模拟实现 call 有三步:

  • 将函数设置为对象的属性
  • 执行函数
  • 删除对象的这个属性
Function.prototype.call = function (context) {
  // 将函数设为对象的属性
  // 注意:非严格模式下, 
  //   指定为 null 和 undefined 的 this 值会自动指向全局对象(浏览器中就是 window 对象)
  //   值为原始值(数字,字符串,布尔值)的 this 会指向该原始值的自动包装对象(用 Object() 转换)
  context = context ? Object(context) : window; 
  context.fn = this;
    
  // 执行该函数
  let args = [...arguments].slice(1);
  let result = context.fn(...args);
    
  // 删除该函数
  delete context.fn
  // 注意:函数是可以有返回值的
  return result;
}

3.2 Function.prototype.apply()

apply() 方法调用一个具有给定 this 值的函数,以及作为一个数组(或类似数组对象)提供的参数。

func.apply(thisArg, [argsArray])

它运行 func 设置 this = context 并使用类数组对象 args 作为参数列表。

例如,这两个调用几乎相同:

func(1, 2, 3);
func.apply(context, [1, 2, 3])

两个都运行 func 给定的参数是 1,2,3。但是 apply 也设置了 this = context

call 和 apply 之间唯一的语法区别是 call 接受一个参数列表,而 apply 则接受带有一个类数组对象

需要注意:Chrome 14 以及 Internet Explorer 9 仍然不接受类数组对象。如果传入类数组对象,它们会抛出异常。

3.21 模拟实现 apply

Function.prototype.apply = function (context, arr) {
    context = context ? Object(context) : window; 
    context.fn = this;
  
    let result;
    if (!arr) {
        result = context.fn();
    } else {
        result = context.fn(...arr);
    }
      
    delete context.fn
    return result;
}

4. 解析 bind 原理,并手写 bind 实现

4.1 bind()

bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

— MDN

bind 方法与 call / apply 最大的不同就是前者返回一个绑定上下文的函数,而后两者是直接执行了函数。

例子:

let value = 2;
let foo = {
    value: 1
};
function bar(name, age) {
    return {
		value: this.value,
		name: name,
		age: age
    }
};

bar.call(foo, "Jack", 20); // 直接执行了函数
// {value: 1, name: "Jack", age: 20}

let bindFoo1 = bar.bind(foo, "Jack", 20); // 返回一个函数
bindFoo1();
// {value: 1, name: "Jack", age: 20}

let bindFoo2 = bar.bind(foo, "Jack"); // 返回一个函数
bindFoo2(20);
// {value: 1, name: "Jack", age: 20}

通过上述代码可以看出 bind 有如下特性:

  • 1、指定 this
  • 2、传入参数
  • 3、返回一个函数
  • 4、柯里化

4.2 模拟实现:

Function.prototype.bind = function (context) {
    // 调用 bind 的不是函数,需要抛出异常
    if (typeof this !== "function") {
      throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
    }
    
    // this 指向调用者
    var self = this;
    // 实现第2点,因为第1个参数是指定的this,所以只截取第1个之后的参数
    var args = Array.prototype.slice.call(arguments, 1); 
    
    // 实现第3点,返回一个函数
    return function () {
        // 实现第4点,这时的arguments是指bind返回的函数传入的参数
        // 即 return function 的参数
        var bindArgs = Array.prototype.slice.call(arguments);
        // 实现第1点
        return self.apply( context, args.concat(bindArgs) );
    }
}

但还有一个问题,bind 有以下一个特性:

一个绑定函数也能使用 new 操作符创建对象:这种行为就像把原函数当成构造器,提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。

来个例子说明下:

let value = 2;
let foo = {
    value: 1
};
function bar(name, age) {
    this.habit = 'shopping';
    console.log(this.value);
    console.log(name);
    console.log(age);
}
bar.prototype.friend = 'kevin';

let bindFoo = bar.bind(foo, 'Jack');
let obj = new bindFoo(20);
// undefined
// Jack
// 20

obj.habit;
// shopping

obj.friend;
// kevin

上面例子中,运行结果 this.value 输出为 undefined ,这不是全局 value 也不是 foo 对象中的 value ,这说明 bind 的 this 对象失效了,new 的实现中生成一个新的对象,这个时候的 this 指向的是 obj 。

这个可以通过修改返回函数的原型来实现,代码如下:

Function.prototype.bind = function (context) {
    // 调用 bind 的不是函数,需要抛出异常
    if (typeof this !== "function") {
      throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
    }
    
    // this 指向调用者
    var self = this;
    // 实现第2点,因为第1个参数是指定的this,所以只截取第1个之后的参数
    var args = Array.prototype.slice.call(arguments, 1);
    
    // 创建一个空对象
    var fNOP = function () {};
    
    // 实现第3点,返回一个函数
    var fBound = function () {
        // 实现第4点,获取 bind 返回函数的参数
        var bindArgs = Array.prototype.slice.call(arguments);
        // 然后同传入参数合并成一个参数数组,并作为 self.apply() 的第二个参数
        return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs));
        // 注释1
    }
    
    // 注释2
    // 空对象的原型指向绑定函数的原型
    fNOP.prototype = this.prototype;
    // 空对象的实例赋值给 fBound.prototype
    fBound.prototype = new fNOP();
    return fBound;
}

注释1 :

  • 当作为构造函数时,this 指向实例,此时 this instanceof fBound 结果为 true ,可以让实例获得来自绑定函数的值,即上例中实例会具有 habit 属性。
  • 当作为普通函数时,this 指向 window ,此时结果为 false ,将绑定函数的 this 指向 context

注释2 :

  • 修改返回函数的 prototype 为绑定函数的 prototype,实例就可以继承绑定函数的原型中的值,即上例中 obj 可以获取到 bar 原型上的 friend
  • 至于为什么使用一个空对象 fNOP 作为中介,把 fBound.prototype 赋值为空对象的实例(原型式继承),这是因为直接 fBound.prototype = this.prototype 有一个缺点,修改 fBound.prototype 的时候,也会直接修改 this.prototype ;其实也可以直接使用ES5的 Object.create() 方法生成一个新对象,但 bind 和 Object.create() 都是ES5方法,部分IE浏览器(IE < 9)并不支

注意:  bind() 函数在 ES5 才被加入,所以并不是所有浏览器都支持,IE8 及以下的版本中不被支持,如果需要兼容可以使用 Polyfill 来实现

5. 浅拷贝和深拷贝,实现 Object 的深拷贝.

5.1 在js中,对象是引用类型。如果给一个变量赋值给一个对象的时候,这时候变量和对象都是指向同一个引用,即

let obj1 = {a: 1}
let obj2 = obj1

console.log(obj2.a) // 1
obj2.a = 2
console.log(obj2.a) // 2
console.log(obj1.a) // 2

由于指向的是同一个引用,即 obj2 属性的值变化了,那么 obj1 也会跟着变化。

5.2 深拷贝的实现

5.21 JSON.parse(JSON.stringify(object))

通常可以使用 JSON.parse(JSON.stringify(object)) 来解决

let obj1 = {
    a: 1,
    b: {
        c: 2
    }
}
let obj2 = JSON.parse(JSON.stringify(obj1))
obj2.b.c = '3'
console.log(obj1.b.c) // 2

但是使用 JSON.parse(JSON.stringify(object)) 也有弊端

  1. 会忽略 undefined
  2. 会忽略 symbol
  3. 不能序列化函数
  4. 不能解决循环引用的对象

5.22 typeof + 递归

遍历对象、数组直到里边都是基本数据类型,然后再去复制,就是深拷贝

// 递归判断是否对象和数组
function deepClone(obj, target) {
    if(!obj) return

    for(let key in obj) {
        if(obj.hasOwnProperty(key)) {
            if(Array.isArray(key) || (typeof obj[key] === 'object' && obj[key] !== null)) {
                target[key] = []
                
                deepClone(obj[key], target[key])
            } else {
                target[key] = obj[key]
            }
        }
    }

    return target
}

5.23 Object.prototype.toString.call + 递归

function checkedType(target) {
  return Object.prototype.toString.call(target).slice(8, -1)
}
//实现深度克隆---对象/数组
function clone(target) {
  //判断拷贝的数据类型
  //初始化变量result 成为最终克隆的数据
  let result, targetType = checkedType(target)
  if (targetType === 'Object') {
    result = {}
  } else if (targetType === 'Array') {
    result = []
  } else {
    return target
  }
  //遍历目标数据
  for (let i in target) {
    //获取遍历数据结构的每一项值。
    let value = target[i]
    //判断目标结构里的每一值是否存在对象/数组
    if (checkedType(value) === 'Object' ||
      checkedType(value) === 'Array') { //对象/数组里嵌套了对象/数组
      //继续遍历获取到value值
      result[i] = clone(value)
    } else { //获取到value值是基本的数据类型或者是函数。
      result[i] = value;
    }
  }
  return result
}

6. 节流函数 throttle

debounce 与 throttle 是开发中常用的高阶函数,作用都是为了防止函数被高频调用,换句话说就是,用来控制某个函数在一定时间内执行多少次。

6.1 使用场景

比如绑定响应鼠标移动、窗口大小调整、滚屏等事件时,绑定的函数触发的频率会很频繁。若稍处理函数微复杂,需要较多的运算执行时间和资源,往往会出现延迟,甚至导致假死或者卡顿感。为了优化性能,这时就很有必要使用 debounce 或 throttle 了。

6.2 debounce 与 throttle 区别

防抖 (debounce) :多次触发,只在最后一次触发时,执行目标函数。

节流(throttle):限制目标函数调用的频率,比如:1s内不能调用2次。

6.3 手写一个 throttle

实现方案有以下两种:

  • 第一种是用时间戳来判断是否已到执行时间,记录上次执行的时间戳,然后每次触发事件执行回调,回调中判断当前时间戳距离上次执行时间戳的间隔是否已经达到时间差(Xms) ,如果是则执行,并更新上次执行的时间戳,如此循环。
  • 第二种方法是使用定时器,比如当 scroll 事件刚触发时,打印一个 hello world,然后设置个 1000ms 的定时器,此后每次触发 scroll 事件触发回调,如果已经存在定时器,则回调不执行方法,直到定时器触发,handler 被清除,然后重新设置定时器。

这里我们采用第一种方案来实现,通过闭包保存一个 previous 变量,每次触发 throttle 函数时判断当前时间和 previous 的时间差,如果这段时间差小于等待时间,那就忽略本次事件触发。如果大于等待时间就把 previous 设置为当前时间并执行函数 fn。

// fn 是需要执行的函数
// wait 是时间间隔
const throttle = (fn, wait = 50) => {
  // 上一次执行 fn 的时间
  let previous = 0
  // 将 throttle 处理结果当作函数返回
  return function(...args) {
    // 获取当前时间,转换成时间戳,单位毫秒
    let now = +new Date()
    // 将当前时间和上一次执行函数的时间进行对比
    // 大于等待时间就把 previous 设置为当前时间并执行函数 fn
    if (now - previous > wait) {
      previous = now
      fn.apply(this, args)
    }
  }
}

// DEMO
// 执行 throttle 函数返回新函数
const betterFn = throttle(() => console.log('fn 函数执行了'), 1000)
// 每 10 毫秒执行一次 betterFn 函数,但是只有时间差大于 1000 时才会执行 fn
setInterval(betterFn, 10)

6.4 underscore 源码解读

上述代码实现了一个简单的节流函数,不过 underscore 实现了更高级的功能,即新增了两个功能

  • 配置是否需要响应事件刚开始的那次回调( leading 参数,false 时忽略)
  • 配置是否需要响应事件结束后的那次回调( trailing 参数,false 时忽略)
const throttle = function(func, wait, options) {
  var timeout, context, args, result;
  
  // 上一次执行回调的时间戳
  var previous = 0;
  
  // 无传入参数时,初始化 options 为空对象
  if (!options) options = {};

  var later = function() {
    // 当设置 { leading: false } 时
    // 每次触发回调函数后设置 previous 为 0
    // 不然为当前时间
    previous = options.leading === false ? 0 : _.now();
    
    // 防止内存泄漏,置为 null 便于后面根据 !timeout 设置新的 timeout
    timeout = null;
    
    // 执行函数
    result = func.apply(context, args);
    if (!timeout) context = args = null;
  };

  // 每次触发事件回调都执行这个函数
  // 函数内判断是否执行 func
  // func 才是我们业务层代码想要执行的函数
  var throttled = function() {
    
    // 记录当前时间
    var now = _.now();
    
    // 第一次执行时(此时 previous 为 0,之后为上一次时间戳)
    // 并且设置了 { leading: false }(表示第一次回调不执行)
    // 此时设置 previous 为当前值,表示刚执行过,本次就不执行了
    if (!previous && options.leading === false) previous = now;
    
    // 距离下次触发 func 还需要等待的时间
    var remaining = wait - (now - previous);
    context = this;
    args = arguments;
    
    // 要么是到了间隔时间了,随即触发方法(remaining <= 0)
    // 要么是没有传入 {leading: false},且第一次触发回调,即立即触发
    // 此时 previous 为 0,wait - (now - previous) 也满足 <= 0
    // 之后便会把 previous 值迅速置为 now
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        
        // clearTimeout(timeout) 并不会把 timeout 设为 null
        // 手动设置,便于后续判断
        timeout = null;
      }
      
      // 设置 previous 为当前时间
      previous = now;
      
      // 执行 func 函数
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    } else if (!timeout && options.trailing !== false) {
      // 最后一次需要触发的情况
      // 如果已经存在一个定时器,则不会进入该 if 分支
      // 如果 {trailing: false},即最后一次不需要触发了,也不会进入这个分支
      // 间隔 remaining milliseconds 后触发 later 方法
      timeout = setTimeout(later, remaining);
    }
    return result;
  };

  // 手动取消
  throttled.cancel = function() {
    clearTimeout(timeout);
    previous = 0;
    timeout = context = args = null;
  };

  // 执行 _.throttle 返回 throttled 函数
  return throttled;
};

7. 防抖函数 debounce

7.1 防抖函数 debounce 指的是某个函数在某段时间内,无论触发了多少次回调,都只执行最后一次

实现原理就是利用定时器,函数第一次执行时设定一个定时器,之后调用时发现已经设定过定时器就清空之前的定时器,并重新设定一个新的定时器,如果存在没有被清空的定时器,当定时器计时结束后触发函数执行。

// fn 是需要防抖处理的函数
// wait 是时间间隔
function debounce(fn, wait = 50) {
    // 通过闭包缓存一个定时器 id
    let timer = null
    // 将 debounce 处理结果当作函数返回
    // 触发事件回调时执行这个返回函数
    return function(...args) {
        // this保存给context
        const context = this
      	// 如果已经设定过定时器就清空上一次的定时器
        if (timer) clearTimeout(timer)
      
      	// 开始设定一个新的定时器,定时器结束后执行传入的函数 fn
        timer = setTimeout(() => {
            fn.apply(context, args)
        }, wait)
    }
}

// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000)
// 停止滑动 1 秒后执行函数 () => console.log('fn 防抖执行了')
document.addEventListener('scroll', betterFn)

7.2 不过 underscore 中的 debounce 还有第三个参数:immediate 。这个参数是做什么用的呢?

传参 immediate 为 true, debounce会在 wait 时间间隔的开始调用这个函数 。(注:并且在 wait 的时间之内,不会再次调用。)在类似不小心点了提交按钮两下而提交了两次的情况下很有用。

把 true 传递给 immediate 参数,会让 debounce 在 wait 时间开始计算之前就触发函数(也就是没有任何延时就触发函数),而不是过了 wait 时间才触发函数,而且在 wait 时间内也不会触发(相当于把 fn 的执行锁住)。 如果不小心点了两次提交按钮,第二次提交就会不会执行。

那我们根据 immediate 的值来决定如何执行 fn 。如果是 immediate 的情况下,我们立即执行 fn ,并在 wait 时间内锁住 fn 的执行, wait 时间之后再触发,才会重新执行 fn ,以此类推。

// immediate 表示第一次是否立即执行
function debounce(fn, wait = 50, immediate) {
    let timer = null
    return function(...args) {
        // this保存给context
        const context = this
        if (timer) clearTimeout(timer)
      
      	// immediate 为 true 表示第一次触发后执行
      	// timer 为空表示首次触发
        if (immediate && !timer) {
            fn.apply(context, args)
        }
      	
        timer = setTimeout(() => {
            fn.apply(context, args)
        }, wait)
    }
}

// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000, true)
// 第一次触发 scroll 执行一次 fn,后续只有在停止滑动 1 秒后才执行函数 fn
document.addEventListener('scroll', betterFn)

7.3 underscore 源码解析

看完了上文的基本版代码,感觉还是比较轻松的,现在来学习下 underscore 是如何实现 debounce 函数的,学习一下优秀的思想,直接上代码和注释,本源码解析依赖于 underscore 1.9.1 版本实现。

// 此处的三个参数上文都有解释
_.debounce = function(func, wait, immediate) {
  // timeout 表示定时器
  // result 表示 func 执行返回值
  var timeout, result;

  // 定时器计时结束后
  // 1、清空计时器,使之不影响下次连续事件的触发
  // 2、触发执行 func
  var later = function(context, args) {
    timeout = null;
    // if (args) 判断是为了过滤立即触发的
    // 关联在于 _.delay 和 restArguments
    if (args) result = func.apply(context, args);
  };

  // 将 debounce 处理结果当作函数返回
  var debounced = restArguments(function(args) {
    if (timeout) clearTimeout(timeout);
    if (immediate) {
      // 第一次触发后会设置 timeout,
      // 根据 timeout 是否为空可以判断是否是首次触发
      var callNow = !timeout;
      timeout = setTimeout(later, wait);
      if (callNow) result = func.apply(this, args);
    } else {
    	// 设置定时器
      timeout = _.delay(later, wait, this, args);
    }

    return result;
  });

  // 新增 手动取消
  debounced.cancel = function() {
    clearTimeout(timeout);
    timeout = null;
  };

  return debounced;
};

// 根据给定的毫秒 wait 延迟执行函数 func
_.delay = restArguments(function(func, wait, args) {
  return setTimeout(function() {
    return func.apply(null, args);
  }, wait);
});

相比上文的基本版实现,underscore 多了以下几点功能。

  • 1、函数 func 的执行结束后返回结果值 result
  • 2、定时器计时结束后清除 timeout ,使之不影响下次连续事件的触发
  • 3、新增了手动取消功能 cancel
  • 4、immediate 为 true 后只会在第一次触发时执行,频繁触发回调结束后不会再执行

8. setTimeout 实现机制与原理

let setTimeout = (fn, timeout, ...args) => {
  // 初始当前时间
  const start = +new Date()
  let timer, now
  const loop = () => {
    timer = window.requestAnimationFrame(loop)
    // 再次运行时获取当前时间
    now = +new Date()
    // 当前运行时间 - 初始当前时间 >= 等待时间 ===>> 跳出
    if (now - start >= timeout) {
      fn.apply(this, args)
      window.cancelAnimationFrame(timer)
    }
  }
  window.requestAnimationFrame(loop)
}

function showName(){ 
    console.log("Hello")
}
let timerID = setTimeout(showName, 1000);
// 在 1 秒后打印 “Hello”

注意:JavaScript 定时器函数像 setTimeout 和 setInterval 都不是 ECMAScript 规范或者任何 JavaScript 实现的一部分。 定时器功能由浏览器实现,它们的实现在不同浏览器之间会有所不同。  定时器也可以由 Node.js 运行时本身实现。

在浏览器里主要的定时器函数是作为 Window 对象的接口,Window 对象同时拥有很多其他方法和对象。该接口使其所有元素在 JavaScript 全局作用域中都可用。这就是为什么你可以直接在浏览器控制台执行 setTimeout 。

在 node 里,定时器是 global 对象的一部分,这点很像浏览器中的 Window 。你可以在 Node里看到定时器的源码 这里 ,在浏览器中定时器的源码在 这里 。

9. 手写 async/await 的实现

Async是如何被 JavaScript 实现的

await 内部实现了 generator,其实 await 就是 generator 加上 Promise的语法糖,且内部实现了自动执行 generator。如果你熟悉 co 的话,其实自己就可以实现这样的语法糖。

/**
 * async/await 实现
 * @param {*} generatorFunc 
 */
function asyncToGenerator(generatorFunc) {
  // 返回的是一个新的函数
  return function(...args) {
    // 先调用generator函数 生成迭代器
    // 对应 var gen = testG()
    const gen = generatorFunc.apply(this, args)

    // 返回一个promise 因为外部是用.then的方式 或者await的方式去使用这个函数的返回值的
    // var test = asyncToGenerator(testG)
    // test().then(res => console.log(res))
    return new Promise((resolve, reject) => {
    
      // 内部定义一个step函数 用来一步一步的跨过yield的阻碍
      // key有next和throw两种取值,分别对应了gen的next和throw方法
      // arg参数则是用来把promise resolve出来的值交给下一个yield
      function step(key, arg) {
        let genResult
        
        // 这个方法需要包裹在try catch中
        // 如果报错了 就把promise给reject掉 外部通过.catch可以获取到错误
        try {
          genResult = gen[key](arg)
        } catch (error) {
          return reject(error)
        }

        // gen.next() 得到的结果是一个 { value, done } 的结构
        const { value, done } = genResult

        if (done) {
          // 如果已经完成了 就直接resolve这个promise
          // 这个done是在最后一次调用next后才会为true
          // 以本文的例子来说 此时的结果是 { done: true, value: 'success' }
          // 这个value也就是generator函数最后的返回值
          return resolve(value)
        } else {
          // 除了最后结束的时候外,每次调用gen.next()
          // 其实是返回 { value: Promise, done: false } 的结构,
          // 这里要注意的是Promise.resolve可以接受一个promise为参数
          // 并且这个promise参数被resolve的时候,这个then才会被调用
          return Promise.resolve(
            // 这个value对应的是yield后面的promise
            value
          ).then(
            // value这个promise被resove的时候,就会执行next
            // 并且只要done不是true的时候 就会递归的往下解开promise
            // 对应gen.next().value.then(value => {
            //    gen.next(value).value.then(value2 => {
            //       gen.next() 
            //
            //      // 此时done为true了 整个promise被resolve了 
            //      // 最外部的test().then(res => console.log(res))的then就开始执行了
            //    })
            // })
            function onResolve(val) {
              step("next", val)
            },
            // 如果promise被reject了 就再次进入step函数
            // 不同的是,这次的try catch中调用的是gen.throw(err)
            // 那么自然就被catch到 然后把promise给reject掉啦
            function onReject(err) {
              step("throw", err)
            },
          )
        }
      }
      step("next")
    })
  }
}

var getData = () => new Promise(resolve => setTimeout(() => resolve('data'), 1000));
function* testG() {
  const data = yield getData();
  console.log('data: ', data);
  const data2 = yield getData();
  console.log('data2: ', data2);
  return 'success';
}

var gen = asyncToGenerator(testG);
gen().then(res => console.log(res));

10. 简易版 useState 实现

10.1 基础版 useState

简单实现:只是数组

通过数组实现,初始化的时候,创建两个数组:states 与 setters ,设置光标 cursor 为 0

  • 第一次调用 useState 时,创建一个 setter 函数放入 setters 中,并初始化一个 state 放入 states 中
  • 之后每次重新渲染时,都会重置光标 cursor 为 0 ,通过 cursor 从states 与 setters 获取 [state, setter] 返回

每次更新 state 时,都是通过调用 setter 函数修改对应的 state 值,这种对应关系是通过 cursor 闭包来实现的

代码实现:

let states = []
let setters = []
let firstRun = true
let cursor = 0

//  使用工厂模式生成一个 createSetter,通过 cursor 指定指向的是哪个 state
function createSetter(cursor) {
  return function(newVal) { // 闭包
    states[cursor] = newVal
  }
}

function useState(initVal) {
  // 首次
  if(firstRun) {
    states.push(initVal)
    setters.push(createSetter(cursor))
    firstRun = false
  }
  let state = states[cursor]
  let setter = setters[cursor]
  // 光标移动到下一个位置
  cursor++
  // 返回
  return [state, setter]
}

使用

function App() {
  // 每次重置 cursor
  cursor = 0
  return <RenderFunctionComponent />
}
function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi");
  const [lastName, setLastName] = useState("Yardley");

  return (
    <Button onClick={() => setFirstName("Fred")}>Fred</Button>
  );
}

10.2 进阶版 useState

简单实现:只是链表

在真实的 React Hooks 中,这种关系其实是通过链表实现的,首先我们需要明确以下内容:

在 React 中最多会同时存在两棵 Fiber 树。当前屏幕上显示内容对应的 Fiber 树称为 current Fiber 树,正在内存中构建的 Fiber 树称为 workInProgress Fiber 树。current Fiber 树中的 Fiber 节点被称为 current fiber ,workInProgress Fiber 树中的 Fiber 节点被称为 workInProgress fiber ,他们通过 alternate 属性连接。

// workInProgressHook 指针,指向当前 hook 对象
let workInProgressHook = null
// workInProgressHook fiber,这里指的是 App 组件
let fiber = {
  stateNode: App, // App 组件
  memoizedState: null // hooks 链表,初始为 null
}
// 是否是首次渲染
let isMount = true

function schedule() {
  workInProgressHook = fiber.memoizedState
  const app = fiber.stateNode()
  isMount = false
  return app
}

function useState(initVal) {
  let hook
  // 首次会生成 hook 对象,并形成链表结构,绑定在 workInProgress 的 memoizedState 属性上
  if(isMount) {
    // 每个 hook 对象,例如 state hook、memo hook、ref hook 等
    hook = {
      memoizedState: initVal, // 当前state的值,例如 useState(initVal)
      action: null, // update 函数
      next: null // 因为是采用链表的形式连接起来,next指向下一个 hook
    }
    // 绑定在 workInProgress 的 memoizedState 属性上
    if(!fiber.memoizedState) {
      // 如果是第一个 hook 对象
      fiber.memoizedState = hook
    } else {
      // 如果不是, 将 hook 追加到链尾
      workInProgressHook.next = hook
    }
    // 指针指向当前 hook,链表尾部,最新 hook
    workInProgressHook = hook
  } else {
    // 拿到当前的 hook
    hook = workInProgressHook
    // workInProgressHook 指向链表的下一个 hook
    workInProgressHook = workInProgressHook.next
  }
  // 状态更新,拿到 current hook,调用 action 函数,更新到最新 state
  let baseState = hook.memoizedState
  // 执行 update
  if(hook.action) {
    // 更新最新值
    let action = hook.action
    // 如果是 setNum(num=>num+1) 形式
    if(typeof action === 'function') {
      baseState = action(baseState)
    } else {
      baseState = action
    }
    // 清空 action
    hook.action = null
  } 
  // 更新最新值
  hook.memoizedState = baseState
  // 返回最新值 baseState、dispatchAction
  return [baseState, dispatchAction(hook)]
}

// action 函数
function dispatchAction(hook) {
  return function (action) {
    hook.action = action
  }
}

10.3 优化版:useState 是如何更新的

更近一步,其实 useState 有两个阶段,负责初始化的 mountState 与负责更新的 updateState,在 mountState 阶段会创建一个 state hook.queue 对象,保存负责更新的信息(包含 pending,待更新队列),以及一个负责更新的函数 dispatchAction (就是 setNum ,第三个参数就是 queue

// 因此,实际的 hook 是这样的
// 每个 hook 对象,例如 state hook、memo hook、ref hook 等
let hook = {
  memoizedState: initVal, // 当前state的值,例如 useState(initVal)
  queue: {
    pending: null
  }, // update 待更新队列, 链表的形式存储
  next: null // 因为是采用链表的形式连接起来,next指向下一个 hook
}

// 调用updateNum实际上调用这个,queue就是当前hooks对应的queue。
function dispatchAction(queue, action) {
	// 每一个任务对应一个update
  const update = {
    // 更新执行的函数
    action,
    // 与同一个Hook的其他更新形成链表
    next: null,
  };
  // ...
}

每次更新的时候(updateState)都会创建一个 update 对象,里面记录了此次更新的信息,然后将此update 放入待更新的 pending 队列中,最后,dispatchAction 判断当前 fiber 没有处于更新阶段

  • 如果处于渲染阶段,那么不需要我们在更新当前函数组件,只需要更新一下当前 update 的expirationTime 即可
  • 没有处于更新阶段,获取最新的 state ,和上一次的 currentState ,进行浅比较
    • 如果相等,那么就退出
    • 不相等,那么调用 scheduleUpdateOnFiber 调度渲染当前 fiber