JavaScript 核心原理精讲 - 前美团前端技术专家 - 拉勾教育

我们都知道,比较常用的数组方法有 push、pop、slice、map 和 reduce 等。上一讲我带你剖析了 sort 方法以及 V8 源码中关于排序的内容,本讲则会围绕这几个常用方法,并结合 V8 的源代码带你手写这些方法的底层实现。

那么,为了方便你更好地理解本讲的内容,在课程开始前请你先回想一下:

  1. reduce 方法里面的参数都是什么作用?
  2. push 和 pop 的底层逻辑是什么样的呢?

带着思考,我们开始今天的学习。

push 方法的底层实现

为了更好地实现 push 的底层方法,你可以先去 ECMA 的官网去查一下关于 push 的基本描述(链接:ECMA 数组的 push 标准),我们看下其英文的描述,如下所示。

  1. When the push method is called with zero or more arguments, the following steps are taken:
  2. 1. Let O be ? ToObject(this value).
  3. 2. Let len be ? LengthOfArrayLike(O).
  4. 3. Let argCount be the number of elements in items.
  5. 4. If len + argCount > 2^53 - 1, throw a TypeError exception.
  6. 5. For each element E of items, do
  7. a. Perform ? Set(O, ! ToString(F(len)), E, true).
  8. b. Set len to len + 1.
  9. 6. Perform ? Set(O, "length", F(len), true).
  10. 7. Return F(len).

从上面的描述可以看到边界判断逻辑以及实现的思路,根据这段英文,我们将其转换为容易理解代码,如下所示。

  1. Array.prototype.push = function(...items) {
  2. let O = Object(this);
  3. let len = this.length >>> 0;
  4. let argCount = items.length >>> 0;
  5. if (len + argCount > 2 ** 53 - 1) {
  6. throw new TypeError("The number of array is over the max value")
  7. }
  8. for(let i = 0; i < argCount; i++) {
  9. O[len + i] = items[i];
  10. }
  11. let newLength = len + argCount;
  12. O.length = newLength;
  13. return newLength;
  14. }

从上面的代码可以看出,关键点就在于给数组本身循环添加新的元素 item,然后调整数组的长度 length 为最新的长度,即可完成 push 的底层实现。

其中关于长度的部分需要做无符号位移,无符号位移在很多源码中你都会看到。关于为什么一些变量要进行无符号位移,你可以自己研究一下,比如在 Stack Overflow 中有一些高票的回答,这里就不占用篇幅了。下面我们再看来一下 pop 的实现。

pop 方法的底层实现

同样我们也一起来看下 pop 的底层实现,你也可以先去 ECMA 的官网去查一下关于 pop 的基本描述(链接:ECMA 数组的 pop 标准),我们还是同样看下英文的描述。

  1. When the pop method is called, the following steps are taken:
  2. 1. Let O be ? ToObject(this value).
  3. 2. Let len be ? LengthOfArrayLike(O).
  4. 3. If len = 0, then
  5. Perform ? Set(O, "length", +0F, true).
  6. Return undefined.
  7. 4. Else,
  8. Assert: len > 0.
  9. Let newLen be F(len - 1).
  10. Let index be ! ToString(newLen).
  11. Let element be ? Get(O, index).
  12. Perform ? DeletePropertyOrThrow(O, index).
  13. Perform ? Set(O, "length", newLen, true).
  14. Return element.

从上面的描述可以看到边界判断逻辑以及实现的思路,根据上面的英文,我们同样将其转换为可以理解的代码,如下所示。

  1. Array.prototype.pop = function() {
  2. let O = Object(this);
  3. let len = this.length >>> 0;
  4. if (len === 0) {
  5. O.length = 0;
  6. return undefined;
  7. }
  8. len --;
  9. let value = O[len];
  10. delete O[len];
  11. O.length = len;
  12. return value;
  13. }

其核心思路还是在于删掉数组自身的最后一个元素,index 就是数组的 len 长度,然后更新最新的长度,最后返回的元素的值,即可达到想要的效果。另外就是在当长度为 0 的时候,如果执行 pop 操作,返回的是 undefined,需要做一下特殊处理。

看完了 pop 的实现,我们再来看一下 map 方法的底层逻辑。

map 方法的底层实现

同样你可以去 ECMA 的官网去查一下关于 map 的基本描述(链接:ECMA 数组的 map 标准),请看英文的表述。

  1. When the map method is called with one or two arguments, the following steps are taken:
  2. 1. Let O be ? ToObject(this value).
  3. 2. Let len be ? LengthOfArrayLike(O).
  4. 3. If IsCallable(callbackfn) is false, throw a TypeError exception.
  5. 4. Let A be ? ArraySpeciesCreate(O, len).
  6. 5. Let k be 0.
  7. 6. Repeat, while k < len,
  8. a. Let Pk be ! ToString(F(k)).
  9. b. Let kPresent be ? HasProperty(O, Pk).
  10. c. If kPresent is true, then
  11. Let kValue be ? Get(O, Pk).
  12. Let mappedValue be ? Call(callbackfn, thisArg, « kValue, F(k), O »).
  13. Perform ? CreateDataPropertyOrThrow(A, Pk, mappedValue).
  14. d. Set k to k + 1.
  15. 7. Return A.

同样的,根据上面的英文,我们将其转换为可理解的代码,如下所示。

  1. Array.prototype.map = function(callbackFn, thisArg) {
  2. if (this === null || this === undefined) {
  3. throw new TypeError("Cannot read property 'map' of null");
  4. }
  5. if (Object.prototype.toString.call(callbackfn) != "[object Function]") {
  6. throw new TypeError(callbackfn + ' is not a function')
  7. }
  8. let O = Object(this);
  9. let T = thisArg;
  10. let len = O.length >>> 0;
  11. let A = new Array(len);
  12. for(let k = 0; k < len; k++) {
  13. if (k in O) {
  14. let kValue = O[k];
  15. let mappedValue = callbackfn.call(T, KValue, k, O);
  16. A[k] = mappedValue;
  17. }
  18. }
  19. return A;
  20. }

有了上面实现 push 和 pop 的基础思路,map 的实现也不会太难了,基本就是再多加一些判断,循环遍历实现 map 的思路,将处理过后的 mappedValue 赋给一个新定义的数组 A,最后返回这个新数组 A,并不改变原数组的值。

我们在 “07 | 数组原理(上):帮你梳理眼花缭乱的数组 API” 中也介绍过数据的方法分类,遍历类型的方法最后返回的都是一个新数组,并不改变原有数组的值,这点你需要牢记。

最后我们来看看 reduce 的实现。

reduce 方法的底层实现

ECMA 官网关于 reduce 的基本描述(链接:ECMA 数组的 pop 标准),如下所示。

  1. When the reduce method is called with one or two arguments, the following steps are taken:
  2. 1. Let O be ? ToObject(this value).
  3. 2. Let len be ? LengthOfArrayLike(O).
  4. 3. If IsCallable(callbackfn) is false, throw a TypeError exception.
  5. 4. If len = 0 and initialValue is not present, throw a TypeError exception.
  6. 5. Let k be 0.
  7. 6. Let accumulator be undefined.
  8. 7. If initialValue is present, then
  9. Set accumulator to initialValue.
  10. 8. Else,
  11. Let kPresent be false.
  12. Repeat, while kPresent is false and k < len,
  13. Let Pk be ! ToString(F(k)).
  14. Set kPresent to ? HasProperty(O, Pk).
  15. If kPresent is true, then
  16. Set accumulator to ? Get(O, Pk).
  17. Set k to k + 1.
  18. If kPresent is false, throw a TypeError exception.
  19. 9. Repeat, while k < len,
  20. Let Pk be ! ToString(F(k)).
  21. Let kPresent be ? HasProperty(O, Pk).
  22. If kPresent is true, then
  23. Let kValue be ? Get(O, Pk).
  24. Set accumulator to ? Call(callbackfn, undefined, « accumulator, kValue, F(k), O »).
  25. Set k to k + 1.
  26. 10. Return accumulator.

还是将其转换为我们自己的代码,如下所示。

  1. Array.prototype.reduce = function(callbackfn, initialValue) {
  2. if (this === null || this === undefined) {
  3. throw new TypeError("Cannot read property 'reduce' of null");
  4. }
  5. if (Object.prototype.toString.call(callbackfn) != "[object Function]") {
  6. throw new TypeError(callbackfn + ' is not a function')
  7. }
  8. let O = Object(this);
  9. let len = O.length >>> 0;
  10. let k = 0;
  11. let accumulator = initialValue;
  12. if (accumulator === undefined) {
  13. throw new Error('Each element of the array is empty');
  14. for(; k < len ; k++) {
  15. if (k in O) {
  16. accumulator = O[k];
  17. k++;
  18. break;
  19. }
  20. }
  21. }
  22. for(;k < len; k++) {
  23. if (k in O) {
  24. accumulator = callbackfn.call(undefined, accumulator, O[k], O);
  25. }
  26. }
  27. return accumulator;
  28. }

根据上面的代码及注释,有几个关键点你需要重点关注:

  1. 初始值默认值不传的特殊处理;
  2. 累加器以及 callbackfn 的处理逻辑。

这两个关键问题处理好,其他的地方和上面几个方法实现的思路是基本类似的,你要学会举一反三。

总结

到这里,本讲的内容就先告一段落了。这一讲内容虽少,但却是你必须要掌握的内容。

这一讲中,我把 JS 的 push 、pop、map、reduce 的底层方法挨个带你实现了一遍,希望你能对此形成一套自己的思路。我所提供的实现代码,虽然不能完全和 V8 源码中实现的代码媲美,但是在正常的使用中,你如果自己能实现到这个程度,基本也可以满足要求了。

讲到这里,我再贴一下 V8 数组关于各种方法的实现源码地址,如下表所示。

数组方法 V8 源码地址
pop V8 源码 pop 的实现
push V8 源码 push 的实现
map V8 源码 map 的实现
slice V8 源码 slice 的实现
filter V8 源码 filter 的实现

关于本讲内容没有提到的代码及方法,你可以根据自己的兴趣,尝试着实现其中的某个方法。

同时也希望你能够多思考日常工作中都有哪些经常用到的 JS 方法,并且去研究其底层源代码的实现逻辑,找机会自己实现一遍,来整体提升你的 JavaScript 的编程能力和对底层的理解能力。

下一讲我们将会进入一个全新的模块——JS 的异步编程篇,期待你能从中学习到更多的东西。每天进步一点点,加油!