实用的js方法及手写部分内置方法

644 阅读20分钟

说明

本文主要撰写比较适用、耐用,及常见处理业务的js方法,同时会对部分内置的js方法进行手写,旨在一方面掌握这些提高效率的js方法,另一方面对内置方法的手写剖析,加深大家对方法的封装逻辑。

本文属于长期贴,会在发布的前提下进行不定期更新,为了避免错乱,该文章的目录结构会根据更新点划分。本人会在实际开发中尽量多的学习和掌握新的知识点,并不断完善该文章的内容。

读者如有发现本文在编写过程中有不对的、不恰当的地方,望及时告知,不吝感谢!

一、更新一(2022.05.17)

1.实现数组和对象的forEach

我们平时开发中经常在使用forEach,那么有没有思考过forEach内部是怎么实现的呢?了解真相才能实现真正的自由,现在我们就要剖析一下forEach内部的逻辑。

须知:

  • 内置的forEach只能遍历数组,遍历对象时报错;
  • 手写的方法支持遍历数组和对象;
  • 手写的forEach只是单纯的实现功能方法,并没有挂载原型上;

本方法手写内容思路源于axios源码中,可至utils的工具函数查看。

const _toString = Object.prototype.toString;

//封装判断是否是数组的方法
function isArray(value) {
  return _toString.call(value) === "[object Array]";
}

//封装forEach
function forEach(val, fn) {
  // (1)null和undefined时,直接返回,不做处理
  if (val === null || val === undefined) {
    return;
  }
  // (2)如果不是对象,则转换成数组类型
  if (typeof val !== "object") {
    val = [val];
  }
  // (3)分别处理数组和对象的情况
  if (isArray(val)) {
    for (let i = 0, j = val.length; i < j; i++) {
      //回调函数内this指向改为null
      fn.call(null, val[i], i, val);
    }
  } else {
    for (const k in val) {
      // for in 遍历对象是包括了原型链上的可枚举属性,使用hasOwnProperty只选择实例对象上的可枚举属性
      if (val.hasOwnProperty(k)) {
        //回调函数内this指向改为null
        fn.call(null, val[k], k, val);
      }
    }
  }
}

// 例子:
const arr1 = [1, 2, 3, 4, 5];
const arr2 = { id: 1, name: "阿离", age: 18 };

forEach(arr1, (item, index, arr) => {
  console.log(item, index, arr);
});
// 1 0 [ 1, 2, 3, 4, 5 ]
// 2 1 [ 1, 2, 3, 4, 5 ]
// 3 2 [ 1, 2, 3, 4, 5 ]
// 4 3 [ 1, 2, 3, 4, 5 ]
// 5 4 [ 1, 2, 3, 4, 5 ]

forEach(arr2, (item, index, arr) => {
  console.log(item, index, arr);
});
// 1 id { id: 1, name: '阿离', age: 18 }
// 阿离 name { id: 1, name: '阿离', age: 18 }
// 18 age { id: 1, name: '阿离', age: 18 }

2.手写call()方法

我们可以使用call方法执行某函数,并显示调用改变该方法内部this的指向。该方法的语法和作用与 apply() 方法类似,只有一个区别,就是 call() 方法接受的是一个参数列表,而 apply() 方法接受的是一个包含多个参数的数组call()方法详情可至MDN查阅。

// 手写call
Function.prototype.alCall = function (thisArg, ...argArray) {
  // 1.获取函数
  var fn = this;
  // 2.处理this
  thisArg = thisArg !== null && thisArg !== undefined ? Object(thisArg) : window;
  // 3.处理函数
  thisArg.fn = fn;
  // 4.执行函数
  var result = thisArg.fn(...argArray);
  delete thisArg.fn;
  return result;
};

function sum(num1, num2) {
  return num1 + num2;
}

var result = sum.alCall({ name: "aaa" }, 20, 40); // 60
var result = sum.alCall("西西", 1, 40); // 41

3.手写apply()方法

上面有提到apply()call()都能改变函数this指向,并且说明了两者的区别,此处不再赘述。apply()方法详情可至MDN查阅。

// 1.手写apply
//注意apply参数就是以数组或者类数组存在,因此不能用扩展运算符展开argArray
Function.prototype.alApply = function (thisArg, argArray) {
  // 2.获取执行函数
  var fn = this;
  // 3.处理传递的指定thisArg
  thisArg = thisArg !== null && thisArg !== undefined ? Object(thisArg) : window;
  // 4.把执行的函数的this隐式指向指定的参数
  thisArg.fn = fn;
  // 5.处理参数
  argArray = argArray ? argArray : [];
  // 6.执行函数(实际执行的函数接收的参数需要使用扩展运算符展开)
  var result = thisArg.fn(...argArray);
  // 7.移除this上的fn属性
  delete thisArg.fn;
  // 8.返回值
  return result;
};
// 执行函数
function fn() {
  console.log(this);
}
function sum(num1, num2) {
  console.log(this);
  return num1 + num2;
}

fn.alApply({ name: "ali" }); // this => { name: 'ali'}
var result = sum.alApply("aaa", [20, 30]); // this => [String: 'aaa']
console.log(result); // 50

4.手写bind()方法

bind()方法同样可以改变函数的this指向,但是并不会像call()apply()一样会直接执行函数。bind() 方法创建一个新的函数,在 bind() 被调用时,这个新函数的 this 被指定为 bind() 的第一个参数,而其余参数将作为新函数的参数,供调用时使用。bind()方法详情可至MDN查阅。

//手写bind
Function.prototype.alBind = function (thisArg, ...argArray) {
  // 1.获取函数
  var fn = this;
  // 2.获取this
  thisArg = thisArg !== null && thisArg !== undefined ? Object(thisArg) : window;
  // 3.返回函数(bind方法返回的是新函数)
  return function (...arg) {
    // 4.处理函数this
    thisArg.fn = fn;
    // 5.获取最终参数(包括执行bind时接收的其他参数,即执行新函数接收的参数)
    var finalArgs = [...argArray, ...arg];
    var result = thisArg.fn(...finalArgs);
    delete thisArg.fn;
    return result;
  };
};

function sum(num1, num2, num3, num4) {
  console.log(this);
  return num1 + num2;
}

var foo = sum.alBind({ name: "xixi" }); 
var result1 = foo(10, 20, 30, 40); // this => { name: "xixi" }, result => 30
var result2 = foo( 30, 40); // this => { name: "xixi" }, result => 70

二、更新二(2022.08.07)

1.实现async/await的简单原理

我们平时开发,经常会使用async + await来对Promise进行简化操作,即使用同步的代码简化Promise对于异步的处理,提高代码的可读性。

async 和 await 是 ES2016 新增两个关键字,它们借鉴了 ES2015 中生成器在实际开发中的应用,目的是简化 Promise api 的使用,并非是替代 Promise。

async和await本质上是generator生成器的语法糖,既然我们需要剖析async和await实现的基本原理,那么必然需要我们对generator生成器有一定的了解。当然,此处并不会对生成器进行展开,读者可自行查阅资料了解什么是迭代器、什么是可迭代对象以及什么是生成器。

模拟案例:上一次异步操作的返回值拼接指定的字符串作为第二次异步操作的参数。

  1. url: 'ali' -> res: ali
  2. url: res + 'aaa' -> res: aliaaa
  3. url: res + 'bbb' -> res: aliaaabbb

异步代码:

function requestData(url) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(url);
      //reject(111);
    }, 2000);
  });
}

实现1:Promise的嵌套

requestData("ali").then((res) => {
  console.log(res);
  requestData(res + "aaa").then((res) => {
    console.log(res);
    requestData(res + "bbb").then((res) => {
      console.log(res);
    });
  });
});

实现2:Promise的链式调用

requestData("ali")
  .then((res) => {
    console.log(res);
    return requestData(res + "aaa");
  })
  .then((res) => {
    console.log(res);
    return requestData(res + "bbb");
  })
  .then((res) => {
    console.log(res);
  });

实现3:generator + Promise

// 用户实际操作代码
function* getData() {
  const res1 = yield requestData("ali");
  console.log(res1);
  const res2 = yield requestData(res1 + "aaa");
  console.log(res2);
  const res3 = yield requestData(res2 + "bbb");
  console.log(res3);
}

const generator = getData();
generator.next().value.then((res) => {
  generator.next(res).value.then((res) => {
    generator.next(res).value.then((res) => {
      console.log(res);
    });
  });
});

最终实现:对实现3进行改造,封装自执行函数,并加上异常捕获

// 用户实际操作代码
function* getData() {
  try {
    const res1 = yield requestData("ali");
    console.log(res1);
    const res2 = yield requestData(res1 + "aaa");
    console.log(res2);
    const res3 = yield requestData(res2 + "bbb");
    console.log(res3);
  } catch (error) {
    console.log(error, 124);
  }
}

// 自执行函数,当然此处模拟是手动执行的(下面的自执行函数本质上是async帮我们操作的,用户并不需要关心,此处为了剖析原理才写出)
function exceGenerator(genFn) {
  let generator = genFn();
  function exce(res) {
    const result = generator.next(res);
    if (result.done) {
      return result.value;
    } else {
      result.value
        .then((res) => {
          exce(res);
        })
        .catch((err) => {
          console.log(222);
          generator.throw(err);
        });
    }
  }
  exce(); //初始化第一次执行
}

exceGenerator(getData);

总结:

  1. 我们只需要把 * 换成async,把yield换成await即可;
  2. 此处略过async返回值是Promise的实现,只分析内部异步转同步的实现;
  3. 我们并不需要关心自执行函数,async内部会创建和执行自执行函数;

所以,本质使用async代码如下:

async function getData() {
  try {
    const res1 = await requestData("ali");
    console.log(res1);
    const res2 = await requestData(res1 + "aaa");
    console.log(res2);
    const res3 = await requestData(res2 + "bbb");
    console.log(res3);
  } catch (error) {
    console.log(error, 124);
  }
}

2.手写防抖函数debounce

当我们频繁触发某个事件,例如input输入事件,我们在触发事件时拿到输入值去请求后端接口,如果我们每一次输入值变化都执行请求,那么必然会导致请求频繁调用,而实际情况是我们只需要当输入完之后请求即可,即在指定时间内,如果值持续变化,我们不进行请求,直到指定时间内值不再变化我们再执行请求。

这就是防抖。

为了方便读者理解,在此我会循序渐进的完善防抖函数,即会展示从基础版到完整版的过程。

另外,此处不会展示对应的html和注册事件的js代码,读者如需测试,可自行编写,并调用防抖函数即可。

(1)、基础版

/**
 *
 * @param {*} fn 需要执行防抖的事件函数
 * @param {*} delay 指定防抖的延时时间
 * @returns
 */
const debounce = function (fn, delay = 1000) {
  let timer = null;
  return function (...arg) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, arg);
    }, delay);
  };
};

基础版简洁明了,故不再解释。

(2)、增加立即执行版本

在基础版本基础上,如果实际需求要求在一次完整的防抖过程中,触发的时候就立刻执行对应事件,后续的持续触发再进行防抖延时等待,那么就需要我们对基础版本扩展功能。

/**
 *
 * @param {*} fn 需要执行防抖的事件函数
 * @param {*} delay 指定防抖的延时时间
 * @param {*} immediate 是否立即执行
 * @returns
 */
const debounce = function (fn, delay = 1000, immediate = false) {
  let timer = null;
  let isInvoke = false;
  return function (...arg) {
     // 立即执行
    if (immediate && !isInvoke) {
      fn.apply(this, arg);
      isInvoke = true;
    } else {
    // 后续触发
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        fn.apply(this, arg);
        isInvoke = false;
      }, delay);
    }
  };
};

(3)、增加取消功能版本

有时候我们在防抖延时等待几秒后最后执行事件的时候,突然不想这个事件被执行了,那么就需要扩展一个取消的操作。

/**
 *
 * @param {*} fn 需要执行防抖的事件函数
 * @param {*} delay 指定防抖的延时时间
 * @param {*} immediate 是否立即执行
 * @returns
 */
const debounce = function (fn, delay = 1000, immediate = false) {
  let timer = null;
  let isInvoke = false;
  const _debounce = function (...arg) {
    if (immediate && !isInvoke) {
      fn.apply(this, arg);
      isInvoke = true;
    } else {
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        fn.apply(this, arg);
        isInvoke = false;
        timer = null;
      }, delay);
    }
  };
  // 添加取消功能
  _debounce.cancel = function () {
    console.log("取消");
    if (timer) clearTimeout(timer);
    timer = null;
    isInvoke = false;
  };
  return _debounce;
};

(4)、带有返回值的版本:使用回调函数

其实,我们实际开发中使用的防抖函数,基于上面的带有取消功能的版本,就已经可以满足需求了。因为实际开发中,我们很少会去拿这个fn触发函数的返回值,大部分逻辑在这个函数内部实现的,就算需要值,也完全可以在外面定义变量,在函数内部去赋值,所以说,拿fn返回值是完全没有必要的。

所以,郑重声明,带有返回值的版本读者完全可以直接跳过。当然,我们在这里就多此一举的列出这个版本了。

/**
 *
 * @param {*} fn 需要执行防抖的事件函数
 * @param {*} delay 指定防抖的延时时间
 * @param {*} immediate 是否立即执行
 * @param {*} resultCallback 返回值处理函数
 * @returns
 */
const debounce = function (fn, delay = 1000, immediate = false, resultCallback) {
  let timer = null;
  let isInvoke = false;
  const _debounce = function (...arg) {
    // 立即执行
    if (immediate && !isInvoke) {
      const result = fn.apply(this, arg);
      // 回调函数形式返回值
      if (resultCallback) resultCallback(result);
      isInvoke = true;
    } else {
      // 非立即执行
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        const result = fn.apply(this, arg);
        // 回调函数形式返回值
        if (resultCallback) resultCallback(result);
        isInvoke = false;
        timer = null;
      }, delay);
    }
  };
  // 添加取消功能
  _debounce.cancel = function () {
    if (timer) clearTimeout(timer);
    timer = null;
    isInvoke = false;
  };
  return _debounce;
};

使用回调函数形式的返回值,只是相比于之前多传了一个函数参数,并在得到返回值时回调即可。

(5)、带有返回值的版本:使用Promise

此版本拿返回值我们使用Promise的resolve传递,外面可用then获取,关于返回值的其他说明同(4)。

/**
 *
 * @param {*} fn 需要执行防抖的事件函数
 * @param {*} delay 指定防抖的延时时间
 * @param {*} immediate 是否立即执行
 * @returns
 */
const debounce = function (fn, delay = 1000, immediate = false) {
  let timer = null;
  let isInvoke = false;
  const _debounce = function (...arg) {
    return new Promise((resolve, reject) => {
      // 立即执行
      if (immediate && !isInvoke) {
        const result = fn.apply(this, arg);
        // Promise形式返回值
        resolve(result);
        isInvoke = true;
      } else {
        // 非立即执行
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
          const result = fn.apply(this, arg);
          // Promise形式返回值
          resolve(result);
          isInvoke = false;
          timer = null;
        }, delay);
      }
    });
  };
  // 添加取消功能
  _debounce.cancel = function () {
    if (timer) clearTimeout(timer);
    timer = null;
    isInvoke = false;
  };
  return _debounce;
};

此处需要注意的是,如果用该版本的防抖,则在调用时得手动处理this,例子如下:

// 其他代码省略
const input = document.querySelector(".input");
const btn = document.querySelector(".btn");

// 2.防抖
// 真正执行的函数:
const eventFn = function (event) {
    console.log("正在输入:", event.target.value)
    return `这是返回值888`
};

// 使用Promise的方式返回值:
const debounceFn = debounce(eventFn, 3000, true);
// 使用Promise获取返回值就需要创建临时中间处理函数,并手动修正this指向
const tempCallback = (...arg) => {
    debounceFn.apply(input, arg).then(res => {
        console.log(res)
    })
}
// 输入事件:
input.oninput = tempCallback;
// 点击事件取消防抖:
btn.onclick = function () {
    debounceFn.cancel()
};

3.手写节流函数throttle

和防抖一样,节流依旧是处理优化同一事件多次触发的情况,和防抖的多次触发最终只执行1次回调函数不同,节流是以固定的时间频率去触发1次回调函数,即不管你触发多少次事件,在一个固定的时间内我只执行一次回调函数。

和防抖函数不同,节流函数这边有定时器版本和时间戳版本。

定时器版本

这里我们简单写一下定时器版本,定时器版本每一个时间段会触发一次回调,但是不好扩展第一次触发就执行回调的功能,因为你无法确认这个立即执行的状态,由于回调是按固定频率执行的,所以没法对当前触发事件是整个节流过程的第一次还是固定频率周期的第一次,所以下面并不会扩展立即执行的功能。

/**
 *
 * @param {*} fn 回调函数
 * @param {*} delay 延时时间
 * @returns
 */
function throttle(fn, delay = 500) {
  let timer = null;
  const _throttle = function (...arg) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, arg);
        timer = null;
      }, delay);
    }
  };

  // 取消功能:
  _throttle.cancel = function () {
    if (timer) clearTimeout(timer);
    timer = null;
  };
  return _throttle;
}

以上是定时器版本。

读者须知:其实如果上面的节流功能已经能满实际足需求,那么直接使用即可,相比于时间戳版本,定时器版本的理解会比较容易点。

时间戳版本

我们重点讲一下时间戳版本,时间戳版本的理解和设计会相对难点,涉及到上一次和下一次的触发时间计算,及首次触发回调和末次触发回调功能的扩展,其中首尾触发回调的功能在理解上会比较难,读者如果是为了研究提升思维,建议深入理解,如果为了满足开发需求,则可略过,掌握其中的基础版本即可。

(1)、基础版

由于基础版没有控制首次立即执行和末次执行,默认最后一次不会执行,所以并不需要加取消功能。

/**
 *
 * @param {*} fn 回调函数
 * @param {*} delay 延时时间
 * @returns
 */
function throttle(fn, delay = 500) {
  // 上一次触发的时间戳(初始默认0)
  let lastTime = 0;
  const _throttel = function (...arg) {
    // 获取当前触发的时间戳
    let nowTime = Date.now();
    // 由于lastTime初始化上一次为0,则时差肯定大于delay,所以初始化默认执行一次,如没特殊要求,默认不让第一次执行
    if (lastTime === 0) lastTime = nowTime;
    // 最后一次触发是不会等待时间回调的,因为本质上是拿最后一次和上一次的时差和延时做的比较
    // 计算还需多久才执行回调:delay - (nowTime - lastTime);
    let remainTime = delay - (nowTime - lastTime);

    // 表示过了delay时间了,可以执行回调
    if (remainTime <= 0) {
      fn.apply(this, arg);
      lastTime = nowTime; // 更新上一次的时间戳
    }
  };
}

(2)、首次立即执行版本

/**
 *
 * @param {*} fn 回调函数
 * @param {*} delay 延时时间
 * @param {*} option leading:首次立即执行
 * @returns
 */
function throttle(fn, delay = 500, option = { leading: false }) {
  // 上一次触发的时间戳(初始默认0)
  let lastTime = 0;
  const { leading } = option;
  const _throttel = function (...arg) {
    // 获取当前触发的时间戳
    let nowTime = Date.now();
    // 由于lastTime初始化上一次为0,则时差肯定大于delay,所以初始化默认执行一次,如没特殊要求,默认不让第一次执行
    if (lastTime === 0 && !leading) lastTime = nowTime;
    // 最后一次触发是不会等待时间回调的,因为本质上是拿最后一次和上一次的时差和延时做的比较
    // 计算还需多久才执行回调:delay - (nowTime - lastTime);
    let remainTime = delay - (nowTime - lastTime);

    // 表示过了delay时间了,可以执行回调
    if (remainTime <= 0) {
      fn.apply(this, arg);
      lastTime = nowTime; // 更新上一次的时间戳
    }
  };

  // 取消:
  _throttel.cancel = function () {};
  return _throttel;
}

相比于基础版,只是多传递了leading参数,决定是否首次执行回调。当如果为true时,则不执行lastTime = nowTime;nowTime - 0肯定比delay大,所以会立即执行一次回调。

(3)、末次执行回调

默认上面的方式末次触发事件结束是不会等待当前等待时间接收再次执行回调的,除非末次触发的时间点刚刚好是等待时间的结束点,那么delay - (nowTime - lastTime)才会等于0,再次执行回调。

所以我们需要手动去扩展这个功能。

/**
 *
 * @param {*} fn 回调函数
 * @param {*} delay 延时时间
 * @param {*} option leading:首次是否立即执行; trailing:末次是否执行
 * @returns
 */
function throttle(fn, delay = 500, option = { leading: false, trailing: false }) {
  // 上一次触发的时间戳(初始默认0)
  let lastTime = 0;
  let timer = null;
  const { leading, trailing } = option;
  const _throttel = function (...arg) {
    // 获取当前触发的时间戳
    let nowTime = Date.now();
    // 由于lastTime初始化上一次为0,则时差肯定大于delay,所以初始化默认执行一次,如没特殊要求,默认不让第一次执行
    if (lastTime === 0 && !leading) lastTime = nowTime;
    // 最后一次触发是不会等待时间回调的,因为本质上是拿最后一次和上一次的时差和延时做的比较
    // 计算还需多久才执行回调:delay - (nowTime - lastTime);
    let remainTime = delay - (nowTime - lastTime);

    // 表示过了delay时间了,可以执行回调
    if (remainTime <= 0) {
      console.log("测试");
      fn.apply(this, arg);
      lastTime = nowTime; // 更新上一次的时间戳
      // 当前次触发的时间点如果刚好等于定时器触发时间点则清除定时器
      if (timer) {
          clearTimeout(timer);
          timer = null;
      }
    } else {
      // 每次触发时间差小于delay时都会进入该判断
      if (!trailing) return;
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        fn.apply(this, arg);
        timer = null;
        // 注意:下面代码之所以要加是因为remainTime是计算的这一次和上一次的时差,这是手动触发的,
        // 而定时器等待执行的remainTime是自动的,假设延时是5秒,上一次是2秒执行,所以定时器计算3秒后执行,
        // 这是固定的,但是假设下次触发是3.2秒后进来,即第5.2秒,重新计算发现remainTime= -0.2,小于delay的5秒
        // 就会执行一次fn,但是由于定时器在3秒后,即第5秒已经执行了,所以这里就会执行两次了。为了避免这种情况,
        // 当开启trailing时,后续的所有频率触发都由定时器接管,而为了实现这个效果,就得让lastTime更新为定时器触发
        // 时的时间,这样计算的remainTime>0就不会再执行fn;
        // 补充:其实开启了trailing后,if(remainTime <= 0)内的逻辑只会在leading开启时执行唯一一次,如果leading也没开则永远不会执行;
        lastTime = leading ? nowTime : 0;
      }, remainTime);
    }
  };

  // 取消:
  _throttel.cancel = function () {
    if (timer) clearTimeout(timer);
    timer = null;
    lastTime = 0;
  };
  return _throttel;
}

其实光看我上面的注释也知道这块的逻辑性还是比较强的,因为每一次计算remainTime都是基于上一次和这一次的时差得到的,但是当前次时间戳是属于人为主动触发事件的操作时间,而下面定时器是基于上一次remainTime设置的,不存在变数,而人为的就会存在误差,所以定时器永远会不晚于上面判断执行的回调,除非人为触发的事件时间点和定时器执行的时间点一致。

(4)、返回值:回调函数

跟前面防抖一样,其实实际开发中我们很少会去从这里拿返回值,所以如果前面版本掌握了,下面的可以自行略过。

当然,我们在这里还是贴下代码,为了页面简洁,下面代码会移除大部分注释,注释详情可查看上面版本的标注。

/**
 *
 * @param {*} fn 回调函数
 * @param {*} delay 延时时间
 * @param {*} option leading:首次是否立即执行; trailing:末次是否执行
 * @param {*} resultCallback 返回值的回调函数
 * @returns
 */
function throttle(fn, delay = 500, option = { leading: false, trailing: false }, resultCallback) {
  let lastTime = 0;
  let timer = null;
  const { leading, trailing } = option;
  
  const _throttel = function (...arg) {
    let nowTime = Date.now();
    if (lastTime === 0 && !leading) lastTime = nowTime;
    let remainTime = delay - (nowTime - lastTime);
    
    if (remainTime <= 0) {
      const result = fn.apply(this, arg);
      resultCallback(result);
      lastTime = nowTime; 
      if (timer) {
          clearTimeout(timer);
          timer = null;
      }
    } else {
      if (!trailing) return;
      if (timer) clearTimeout(timer); 
      timer = setTimeout(() => {
        const result = fn.apply(this, arg);
        resultCallback(result);
        timer = null;
        lastTime = leading ? nowTime : 0;
      }, remainTime);
    }
  };

  // 取消:
  _throttel.cancel = function () {
    if (timer) clearTimeout(timer);
    timer = null;
    lastTime = 0;
  };
  return _throttel;
}

(5)、返回值:Promise

/**
 *
 * @param {*} fn 回调函数
 * @param {*} delay 延时时间
 * @param {*} option leading:首次是否立即执行; trailing:末次是否执行
 * @returns
 */
function throttle(fn, delay = 500, option = { leading: false, trailing: false }) {
  let lastTime = 0;
  let timer = null;
  const { leading, trailing } = option;
  
  const _throttel = function (...arg) {
    return new Promise((resolve, reject) => {
      let nowTime = Date.now();
      if (lastTime === 0 && !leading) lastTime = nowTime;
      let remainTime = delay - (nowTime - lastTime);
      if (remainTime <= 0) {
        const result = fn.apply(this, arg);
        resolve(result);
        lastTime = nowTime; 
        if (timer) {
          clearTimeout(timer);
          timer = null;
        }
      } else {
        if (!trailing) return;
        if (timer) clearTimeout(timer); 
        timer = setTimeout(() => {
          const result = fn.apply(this, arg);
          resolve(result);
          timer = null;
          lastTime = leading ? nowTime : 0;
        }, remainTime);
      }
    });
  };

  // 取消:
  _throttel.cancel = function () {
    if (timer) clearTimeout(timer);
    timer = null;
    lastTime = 0;
  };
  return _throttel;
}

相较于回调函数的返回值,使用Promise时无非把返回值的传递使用resolve仅此而已。

当然还是得强调下使用Promise时需要创建中间函数,代码如下:

// 其他代码略
const input = document.querySelector(".input");
const btn = document.querySelector(".btn");

// 真正执行的函数:
const eventFn = function (event) {
    console.log("正在输入:", event.target.value)
    return `这是返回值888`
};
// Promise返回值:
const throttleFn = throttle(eventFn, 2000, {
    leading: true,
    trailing: true
})
const tempCallback = function (...arg) {
    throttleFn.apply(input, arg).then(res => {
        console.log("返回值:", res)
    })
}
input.oninput = tempCallback;

// 触发的事件:
btn.onclick = function () {
    throttleFn.cancel()
};

4.深拷贝

平时大家在开发中肯定拷贝过对象,有时候浅拷贝,有时候深拷贝,浅拷贝不必多说,方法很多,那么深拷贝呢?很多时候会用loadsh或者underscore等第三方库,或者直接使用JSON的stringfy和parse两个方法。

今天我们就来手写一下自己的深拷贝方法。

在此之前说一下JSON这两个方法进行深拷贝的缺点,其实大部分时候我们需要深拷贝的数据并不复杂,这个时候完全可以使用JSON这两个方法,但是有时候就会比较局限,因为JSON这两个方法实现深拷贝有缺点:

  • 值为函数、Symbol、undefined等的属性会被忽略;
  • Set、Map结构数据会直接返回成空对象;
  • 循环引用报错(即属性值为本身);

下面我们实现下自己的深拷贝方法。

(1)、基础版

基础版不包含Set、Map、Symbol、循环引用。

// 判断是否是对象
function isObject(value) {
  const valueType = typeof value;
  return valueType !== null && (valueType === "object" || valueType === "function");
}

// 深拷贝函数
function deepClone(originValue) {
  // 判断函数:为啥isObject再写一遍function?因为isObject是通用方法
  if (typeof originValue === "function") {
    return originValue;
  }

  // 判断不是对象类型:
  if (!isObject(originValue)) {
    return originValue;
  }

  // 创建返回值数据:
  const newValue = Array.isArray(originValue) ? [] : {};
  
  // 其余情况:
  for (const key in originValue) {
    newValue[key] = deepClone(originValue[key]);
  }

  return newValue;
}

基础版,非常清晰明了,如果需要拷贝的数据不包含Set、Map、Symbol、循环引用,那么用上面基础版完全够了。

(2)、包含Set、Map、Symbol

// 判断是否是对象
function isObject(value) {
  const valueType = typeof value;
  return valueType !== null && (valueType === "object" || valueType === "function");
}

function deepClone(originValue) {
  // 判断Symbol类型(值是Symbol):
  if (typeof originValue === "symbol") {
    // 如果希望值本身是相等的可直接返回,如想新建一个一样描述的symbol则,新建
    // return originValue;
    return Symbol(originValue.description);
  }

  // 判断函数:为啥isObject再写一遍function?因为isObject是通用方法
  if (typeof originValue === "function") {
    return originValue;
  }

  // 判断不是对象类型:
  if (!isObject(originValue)) {
    return originValue;
  }

  // 判断Set:不能用typeof判断,要用instanceof判断
  if (originValue instanceof Set) {
    let set = new Set();
    for (const item of originValue) {
      set.add(deepClone(item));
    }
    return set;
  }

  // 判断map:
  if (originValue instanceof Map) {
    let map = new Map();
    for (const [key, value] of originValue) {
      map.set(deepClone(key), deepClone(value));
    }
    return map;
  }

  // 创建返回值数据:
  const newValue = Array.isArray(originValue) ? [] : {};

  // 补充:Symbol 作为属性名,遍历对象的时候,该属性不会出现在for...in、for...of循环中,
  // 也不会被Object.keys()、Object.getOwnPropertyNames()、JSON.stringify()返回。
  // 处理Symbo类型(属性名是Symbol):暂时未处理键值键名都为Symbol情况
  const symbolKeys = Object.getOwnPropertySymbols(originValue);
  for (const sKey of symbolKeys) {
    newValue[sKey] = deepClone(originValue[sKey]);
  }

  // 其余情况:
  for (const key in originValue) {
    newValue[key] = deepClone(originValue[key]);
  }

  return newValue;
}

相比于基础版,只是加了对Set、Map、Symbol三个类型的判断,需要注意的是由于这三个类型存储的数据本身也可能是引用数据类型,所以对存储的值又进行了递归拷贝。

(3)、包含循环引用

先分析下循环引用的处理思路。

  • 按上面的代码来拷贝,当遇到循环引用时肯定会无限递归导致栈溢出,因此需要处理这种情况;
  • 理想情况是发现当前拷贝的originValue值是之前已经处理过的引用类型参数,即之前已经用deepClone接收过的引用类型参数时,表明是循环引用;
  • 此时我们只需要直接把之前拷贝的值返回即可,同基础数据一样处理,不让其再次递归;
  • 那么显然我们需要一个容器去存储在递归拷贝中每一个引用类型的参数实际拷贝后的数据,然后每次都判断当前处理的引用参数是否已经在这个容器中,如果存在,则直接把值取出返回;
  • 这个容器我们采用WeakMap对象,而不是Map,因为WeakMap是弱引用,我们不采用全局变量,否则多次实行深拷贝会导致该变量不断变大,因此采用局部变量;

分析完了直接上代码:

// 判断是否是对象
function isObject(value) {
  const valueType = typeof value;
  return valueType !== null && (valueType === "object" || valueType === "function");
}

function deepClone(originValue, wMap = new WeakMap()) {
  // 判断Symbol类型(值是Symbol):
  if (typeof originValue === "symbol") {
    // 如果希望值本身是相等的可直接返回,如想新建一个一样描述的symbol则,新建
    // return originValue;
    return Symbol(originValue.description);
  }

  // 判断函数:为啥isObject再写一遍function?因为isObject是通用方法
  if (typeof originValue === "function") {
    return originValue;
  }

  // 判断不是对象类型:
  if (!isObject(originValue)) {
    return originValue;
  }

  // 判断Set:不能用typeof判断,要用instanceof判断
  if (originValue instanceof Set) {
    let set = new Set();
    for (const item of originValue) {
      set.add(deepClone(item, wMap));
    }
    return set;
  }

  // 判断map:
  if (originValue instanceof Map) {
    let map = new Map();
    for (const [key, value] of originValue) {
      map.set(deepClone(key, wMap), deepClone(value, wMap));
    }
    return map;
  }

  // 创建返回值数据:
  const newValue = Array.isArray(originValue) ? [] : {};

  // 补充:Symbol 作为属性名,遍历对象的时候,该属性不会出现在for...in、for...of循环中,
  // 也不会被Object.keys()、Object.getOwnPropertyNames()、JSON.stringify()返回。
  // 处理Symbo类型(属性名是Symbol):暂时未处理键值键名都为Symbol情况
  const symbolKeys = Object.getOwnPropertySymbols(originValue);
  for (const sKey of symbolKeys) {
    newValue[sKey] = deepClone(originValue[sKey], wMap);
  }

  // 判断是否循环引用:循环引用直接返回该值不进行递归
  if (wMap.has(originValue)) {
    return wMap.get(originValue);
  }
  // 其余情况:
  //执行到这里的所有对象都用WeakMap存储,用来判断该对象是否在后续的递归中循环引用
  wMap.set(originValue, newValue);

  for (const key in originValue) {
    newValue[key] = deepClone(originValue[key], wMap);
  }

  return newValue;
}

从本质上讲,我们只是简单的加了下面的几句代码:

  // 判断是否循环引用:循环引用直接返回该值不进行递归
  if (wMap.has(originValue)) {
    return wMap.get(originValue);
  }
  // 存储引用值
  wMap.set(originValue, newValue);

其余补充的代码无非是考虑到Set、Map、Symbol等数据中存储的也可能是引用数据,这点需要注意。

以下附完整的测试代码:

// 测试代码:
let s1 = Symbol(1);
let s2 = Symbol(2);
let set = new Set([1, 2, 3]);
let o1 = { a: 1, b: 2 };
let map1 = new Map();
map1.set(o1, "123");
map1.set("aaa", o1);
set.add(o1);

const obj = {
  name: "阿离",
  age: 18,
  friends: ["老吴", "西西", "龟霸", "毛豆"],
  info: {
    address: "龟城",
    like: "Tottenham",
  },
  test: function () {
    console.log(111);
  },
  [s1]: "abc",
  s2: s2,
  set: set,
  map: map1,
};

obj.self = obj;
set.add(obj);
map1.set(obj, 11);
map1.set(222, obj);

const newObj = deepClone(obj);

obj.info.address = "豫章";
o1.a = 3;

console.log(newObj);

5.手写事件总线

本方法只会写简单的事件总线,其实读者掌握了事件总线的设计思路,自然而然对于事件总线功能的扩展势必手到擒来的。

class ALEventBus {
  constructor() {
    /**
     * 属性名: 事件名称 eventName
     * 属性值: 数组集合 => 对象包含两个属性,回调函数,及该回调指定的this
     */
    this.eventBus = {};
  }
  // 订阅
  on(eventName, eventCallback, thisArg) {
    let handlers = this.eventBus[eventName];
    if (!handlers) {
      handlers = [];
      this.eventBus[eventName] = handlers;
    }
    handlers.push({
      eventCallback,
      thisArg,
    });
  }
  // 发布
  emit(eventName, ...payload) {
    let handlers = this.eventBus[eventName];
    if (!handlers) return;
    handlers.forEach((handler) => {
      handler.eventCallback.apply(handler.thisArg, payload);
    });
  }
  // 移除回调
  off(eventName, handleCallback) {
    const handlers = this.eventBus[eventName];
    if (!handlers) return;
    let newHandlers = [...handlers];
    newHandlers.forEach((handler, index) => {
      if (handleCallback === handler.eventCallback) {
        handlers.splice(index, 1);
      }
    });
  }
}

测试代码如下:

const eventBus = new ALEventBus();
const foo = function (...arg) {
  console.log("监听abc=>", "参数是:", ...arg, "this是:", this);
};
const bar = function (...arg) {
  console.log("bar函数监听abc=>", "参数是:", ...arg, "this是:", this);
};
const info = function (...arg) {
  console.log("info函数监听ali=>", "参数是:", ...arg, "this是:", this);
};

eventBus.on("abc", foo, { name: "阿离" });
eventBus.on("abc", bar, { name: "西西" });
eventBus.on("ali", info, { name: "老吴" });

eventBus.off("abc", foo);

eventBus.emit("abc", 123);
eventBus.emit("ali", 888);

// bar函数监听abc=> 参数是: 123 this是: { name: '西西' }
// info函数监听ali=> 参数是: 888 this是: { name: '老吴' }