07-数组原理(上):帮你梳理眼花缭乱的数组 API

我在上一讲为你剖析了闭包这个难点,带你了解了作用域、闭包产生的原因及表现形式。那么这一讲,我们一起来手工实现一个 JSON.stringify 的方法。

这个方法能够站在全局考察你对 JS 各种数据类型理解的深度,对各种极端的边界情况处理能力,以及 JS 的编码能力。之所以将这篇作为这一模块的进阶,是因为我想把整个数据类型的知识点串起来,让你理解得更加融会贯通,能够更上一层楼。

在大厂的前端面试过程中,这个题目也经常会被问到。大部分候选人只知道这个方法的作用,而如果让他自己实现一个 JSON.Srtingfy 方法的话,大多数人都不一定能写出来,或者即便能写出来一些,但是考虑的问题又不够全面。

因此你要想夯实自身 JavaScript 的编程基础,通过实践来实现一些 JS API 方法,是非常有必要的,所以这一讲我就来帮你搞懂它。

那么,到底什么是 JSON.stringify 方法?

方法基本介绍

JSON.stringify 是日常开发中经常用到的 JSON 对象中的一个方法,JSON 对象包含两个方法:一是用于解析成 JSON 对象的 parse();二是用于将对象转换为 JSON 字符串方法的 stringify()。下面我们分别来看下两个方法的基本使用情况。

JSON.parse

JSON.parse 方法用来解析 JSON 字符串,构造由字符串描述的 JavaScript 值或对象。该方法有两个参数:第一个参数是需要解析处理的 JSON 字符串,第二个参数是可选参数提供可选的 reviver 函数,用在返回之前对所得到的对象执行变换操作。

该方法的语法为:JSON.parse(text[, reviver])

下面通过一段代码来看看这个方法以及 reviver 参数的用法,如下所示。

const json = '{"result":true, "count":2}';const obj = JSON.parse(json);console.log(obj.count);console.log(obj.result);JSON.parse('{"p": 5}', function (k, v) {if(k === '') return v;     return v * 2;              });                            

上面的代码说明了,我们可以将一个符合 JSON 格式的字符串转化成对象返回;带第二个参数的情况,可以将待处理的字符串进行一定的操作处理,比如上面这个例子就是将属性值乘以 2 进行返回。

下面我们来了解一下 JSON.stringify 的基本情况。

JSON.stringify

JSON.stringify 方法是将一个 JavaScript 对象或值转换为 JSON 字符串,默认该方法其实有三个参数:第一个参数是必选,后面两个是可选参数非必选。第一个参数传入的是要转换的对象;第二个是一个 replacer 函数,比如指定的 replacer 是数组,则可选择性地仅处理包含数组指定的属性;第三个参数用来控制结果字符串里面的间距,后面两个参数整体用得比较少。

该方法的语法为:JSON.stringify(value[, replacer [, space]])

下面我们通过一段代码来看看后面几个参数的妙用,如下所示。

JSON.stringify({ x: 1, y: 2 });JSON.stringify({ x: [10, undefined, function(){}, Symbol('')] })function replacer(key, value) {if (typeof value === "string") {return undefined;}return value;}var foo = {foundation: "Mozilla", model: "box", week: 4, transport: "car", month: 7};var jsonString = JSON.stringify(foo, replacer);console.log(jsonString);JSON.stringify({ a: 2 }, null, " ");"a": 2}"*/JSON.stringify({ a: 2 }, null, "");

从上面的代码中可以看到,增加第二个参数 replacer 带来的变化:通过替换方法把对象中的属性为字符串的过滤掉,在 stringify 之后返回的仅为数字的属性变成字符串之后的结果;当第三个参数传入的是多个空格的时候,则会增加结果字符串里面的间距数量,从最后一段代码中可以看到结果。

下面我们再看下 JSON.stringify 的内部针对各种数据类型的转换方式。

如何自己手动实现?

为了让你更好地理解实现的过程,请你回想一下 “01 | 代码基本功测试(上):JS 的数据类型你了解多少” 中的基本知识,我们当时讲了那么多种数据类型,如果它们都使用这个方法,返回的结果又会是怎么样的呢?

分析各种数据类型及边界情况

我们来分析一下都有哪些数据类型传入,传入了之后会有什么返回,通过分析的结果我们之后才能更好地实现编码。大致的分析汇总如下表所示(可参考 MDN 文档)。

上面这个表中,基本整理出了各种数据类型通过 JSON.stringify 这个方法之后返回对应的值,但是还有一个特殊情况需要注意:对于包含循环引用的对象(深拷贝那讲中也有提到)执行此方法,会抛出错误。

那么根据上面梳理的这个表格,我们来一起看下代码怎么编写吧。

代码逻辑实现

我们先利用 typeof 把基础数据类型和引用数据类型分开,分开之后再根据不同情况来分别处理不同的情况,按照这个逻辑代码实现如下。

function jsonStringify(data) {let type = typeof data;if(type !== 'object') {let result = data;if (Number.isNaN(data) || data === Infinity) {result = "null";} else if (type === 'function' || type === 'undefined' || type === 'symbol') {return undefined;} else if (type === 'string') {result = '"' + data + '"';}return String(result);} else if (type === 'object') {if (data === null) {return "null"  } else if (data.toJSON && typeof data.toJSON === 'function') {return jsonStringify(data.toJSON());} else if (data instanceof Array) {let result = [];data.forEach((item, index) => {if (typeof item === 'undefined' || typeof item === 'function' || typeof item === 'symbol') {result[index] = "null";} else {result[index] = jsonStringify(item);}});result = "[" + result + "]";return result.replace(/'/g, '"');} else {let result = [];Object.keys(data).forEach((item, index) => {if (typeof item !== 'symbol') {if (data[item] !== undefined && typeof data[item] !== 'function' && typeof data[item] !== 'symbol') {result.push('"' + item + '"' + ":" + jsonStringify(data[item]));}}});return ("{" + result + "}").replace(/'/g, '"');}}}

手工实现一个 JSON.stringify 方法的基本代码如上面所示,有几个问题你还是需要注意一下:

  1. 由于 function 返回’null’, 并且 typeof function 能直接返回精确的判断,故在整体逻辑处理基础数据类型的时候,会随着 undefined,symbol 直接处理了;

  2. 由于 01 讲说过 typeof null 的时候返回’object’,故 null 的判断逻辑整体在处理引用数据类型的逻辑里面;

  3. 关于引用数据类型中的数组,由于数组的每一项的数据类型又有很多的可能性,故在处理数组过程中又将 undefined,symbol,function 作为数组其中一项的情况做了特殊处理;

  4. 同样在最后处理普通对象的时候,key (键值)也存在和数组一样的问题,故又需要再针对上面这几种情况(undefined,symbol,function)做特殊处理;

  5. 最后在处理普通对象过程中,对于循环引用的问题暂未做检测,如果是有循环引用的情况,需要抛出 Error;

  6. 根据官方给出的 JSON.stringify 的第二个以及第三个参数的实现,本段模拟实现的代码并未实现,如果有兴趣你可以自己尝试一下。

整体来说这段代码还是比较复杂的,如果在面试过程中让你当场手写,其实整体还是需要考虑很多东西的。当然上面的代码根据每个人的思路不同,你也可以写出自己认为更优的代码,比如你也可以尝试直接使用 switch 语句,来分别针对特殊情况进行处理,整体写出来可能看起来会比上面的写法更清晰一些,这些可以根据自己情况而定。

实现效果测试

上面的这个方法已经实现了,那么用起来会不会有问题呢?我们就用上面的代码,来进行一些用例的检测吧。

上面自己实现的这个 jsonStringify 方法和真正的 JSON.stringify 想要得到的效果是否一样呢?请看下面的测试结果。

let nl = null;console.log(jsonStringify(nl) === JSON.stringify(nl));let und = undefined;console.log(jsonStringify(undefined) === JSON.stringify(undefined));let boo = false;console.log(jsonStringify(boo) === JSON.stringify(boo));let nan = NaN;console.log(jsonStringify(nan) === JSON.stringify(nan));let inf = Infinity;console.log(jsonStringify(Infinity) === JSON.stringify(Infinity));let str = "jack";console.log(jsonStringify(str) === JSON.stringify(str));let reg = new RegExp("\w");console.log(jsonStringify(reg) === JSON.stringify(reg));let date = new Date();console.log(jsonStringify(date) === JSON.stringify(date));let sym = Symbol(1);console.log(jsonStringify(sym) === JSON.stringify(sym));let array = [1,2,3];console.log(jsonStringify(array) === JSON.stringify(array));let obj = {name: 'jack',age: 18,attr: ['coding', 123],date: new Date(),uni: Symbol(2),sayHi: function() {console.log("hi")},info: {sister: 'lily',age: 16,intro: {money: undefined,job: null}}}console.log(jsonStringify(obj) === JSON.stringify(obj));

通过上面这些测试的例子可以发现,我们自己实现的 jsonStringify 方法基本和 JSON.stringify 转换之后的结果是一样的,不难看出 jsonStringify 基本满足了预期结果。

本讲的内容也就先介绍到这里。

总结

这一讲,我利用原理结合实践的方式,带你实现了一个 JSON.stringify 的方法。从中你可以看到,要想自己实现一个 JSON.stringify 方法整体上来说并不容易,它依赖很多数据类型相关的知识点,而且还需要考虑各种边界情况。

希望你多加实践,如果在面试中也让你当场实现一个 JSON.stringify 方法,你才能够轻松应对。

另外,如果把本讲中的题目作为面试题的话,其实是对你的 JS 编码能力的一个很全面的考察,因此对于数据类型的相关知识还是很有必要系统性地学习,尤其是对于 JSON 的这两个方法,不常用的那几个参数你是否有了解?还有引用数据类型中对数组以及普通对象的处理,这部分手写起来会比基础数据类型复杂一些,在一些细节处理上会遇到问题。因此,你要好好理解。

那么讲到这里,第一个模块的内容就介绍完毕了,涉及数据类型相关的知识就暂时告一段落了,马上我们进入全新的第二个模块深入数组的学习。在后续的课时中,我将带领你深入学习 JS 的数组相关知识。我们下一讲再见~

08-数组原理(中):如何理解 JS 的类数组

我在上一讲带你梳理了数组那令人眼花缭乱的各种方法,其实 JS 中一直存在一种类数组的对象,它们不能直接调用数组的方法,但是又和数组比较类似,在某些特定的编程场景中会出现,这会让很多 JS 的初学者比较困惑。那么通过本讲的学习,希望你能更好地去理解类数组原理,以及类数组的转换。

我们先来看看在 JavaScript 中有哪些情况下的对象是类数组呢?主要有以下几种:

  1. 函数里面的参数对象 arguments;

  2. 用 getElementsByTagName/ClassName/Name 获得的 HTMLCollection;

  3. 用 querySelector 获得的 NodeList。

上述这些基本就是你在 JavaScript 编程过程中经常会遇到的,希望通过这一讲,你能够彻底掌握它们。

在课程开始前请你先思考几个问题:

  1. 类数组是否能使用数组的方法呢?

  2. 类数组有哪些方式可以转换成数组?

如果你不能准确地回答出这两个问题,那么就要认真学习了,下面开始进入我们的课程。

类数组基本介绍

arguments

先来重点讲讲 arguments 对象,我们在日常开发中经常会遇到各种类数组对象,最常见的便是在函数中使用的 arguments,它的对象只定义在函数体中,包括了函数的参数和其他属性。我们通过一段代码来看下 arguments 的使用方法,如下所示。

function foo(name, age, sex) {console.log(arguments);console.log(typeof arguments);console.log(Object.prototype.toString.call(arguments));}foo('jack', '18', 'male');

这段代码比较容易,就是直接将这个函数的 arguments 在函数内部打印出来,那么我们看下这个 arguments 打印出来的结果,请看控制台的这张截图。

从结果中可以看到,typeof 这个 arguments 返回的是 object,通过 Object.prototype.toString.call 返回的结果是 ‘[object arguments]’,可以看出来返回的不是 ‘[object array]’,说明 arguments 和数组还是有区别的。

length 属性很好理解,它就是函数参数的长度,我们从打印出的代码也可以看得出来。另外可以看到 arguments 不仅仅有一个 length 属性,还有一个 callee 属性,我们接下来看看这个 callee 是干什么的,代码如下所示。

function foo(name, age, sex) {console.log(arguments.callee);}foo('jack', '18', 'male');

请看这段代码的执行结果。

从控制台可以看到,输出的就是函数自身,如果在函数内部直接执行调用 callee 的话,那它就会不停地执行当前函数,直到执行到内存溢出,有兴趣的话你可以自己试一下。

接下来我们再看看下面这种类数组。

HTMLCollection

HTMLCollection 简单来说是 HTML DOM 对象的一个接口,这个接口包含了获取到的 DOM 元素集合,返回的类型是类数组对象,如果用 typeof 来判断的话,它返回的是’object’。它是及时更新的,当文档中的 DOM 变化时,它也会随之变化。

描述起来比较抽象,还是通过一段代码来看下 HTMLCollection 最后返回的是什么,我们先随便找一个页面中有 form 表单的页面,在控制台中执行下述代码。

var elem1, elem2;elem1 = document.forms[0];elem2 = document.forms.item(0);console.log(elem1);console.log(elem2);console.log(typeof elem1);console.log(Object.prototype.toString.call(elem1));

在这个有 form 表单的页面执行上面的代码,得到的结果如下。


可以看到,这里打印出来了页面第一个 form 表单元素,同时也打印出来了判断类型的结果,说明打印的判断的类型和 arguments 返回的也比较类似,typeof 返回的都是’object’,和上面的类似。

另外需要注意的一点就是 HTML DOM 中的 HTMLCollection 是即时更新的,当其所包含的文档结构发生改变时,它会自动更新。下面我们再看最后一个 NodeList 类数组。

NodeList

NodeList 对象是节点的集合,通常是由 querySlector 返回的。NodeList 不是一个数组,也是一种类数组。虽然 NodeList 不是一个数组,但是可以使用 for…of 来迭代。在一些情况下,NodeList 是一个实时集合,也就是说,如果文档中的节点树发生变化,NodeList 也会随之变化。我们还是利用代码来理解一下 Nodelist 这种类数组。

var list = document.querySelectorAll('input[type=checkbox]');for (var checkbox of list) {checkbox.checked = true;}console.log(list);console.log(typeof list);console.log(Object.prototype.toString.call(list));

从上面的代码执行的结果中可以发现,我们是通过有 CheckBox 的页面执行的代码,在结果可中输出了一个 NodeList 类数组,里面有一个 CheckBox 元素,并且我们判断了它的类型,和上面的 arguments 与 HTMLCollection 其实是类似的,执行结果如下图所示。

好了,现在你已经了解了上面这三种类数组,那么结合这些,我们再看看类数组有哪些应用的场景呢?

类数组应用场景

我在这里为你介绍三种场景,这些也是最常见的。

遍历参数操作

我们在函数内部可以直接获取 arguments 这个类数组的值,那么也可以对于参数进行一些操作,比如下面这段代码,我们可以将函数的参数默认进行求和操作。

function add() {var sum =0,len = arguments.length;for(var i = 0; i < len; i++){sum += arguments[i];}return sum;}add()                           add(1)                          add(1,2)                       add(1,2,3,4);                   

结合上面这段代码,我们在函数内部可以将参数直接进行累加操作,以达到预期的效果,参数多少也可以不受限制,根据长度直接计算,返回出最后函数的参数的累加结果,其他的操作也都可以仿照这样的方式来做。我们再看下一种场景。

定义链接字符串函数

我们可以通过 arguments 这个例子定义一个函数来连接字符串。这个函数唯一正式声明了的参数是一个字符串,该参数指定一个字符作为衔接点来连接字符串。该函数定义如下。

function myConcat(separa) {var args = Array.prototype.slice.call(arguments, 1);return args.join(separa);}myConcat(", ", "red", "orange", "blue");myConcat("; ", "elephant", "lion", "snake");myConcat(". ", "one", "two", "three", "four", "five");

这段代码说明了,你可以传递任意数量的参数到该函数,并使用每个参数作为列表中的项创建列表进行拼接。从这个例子中也可以看出,我们可以在日常编码中采用这样的代码抽象方式,把需要解决的这一类问题,都抽象成通用的方法,来提升代码的可复用性。

下面我们再看另外一种使用场景。

传递参数使用

我在第 4 讲中已经介绍过了 apply 和 call 的使用访问,结合这一讲的内容,我们来研究一下,如果再结合 arguments,还能实现什么?可以借助 arguments 将参数从一个函数传递到另一个函数,请看下面这个例子。

function foo() {bar.apply(this, arguments);}function bar(a, b, c) {console.log(a, b, c);}foo(1, 2, 3)   

上述代码中,通过在 foo 函数内部调用 apply 方法,用 foo 函数的参数传递给 bar 函数,这样就实现了借用参数的妙用。你可以结合这个例子再思考一下,对于 foo 这样的函数可以灵活传入参数数量,通过这样的代码编写方式是不是也可以实现一些功能的拓展场景呢?

如何将类数组转换成数组

在互联网大厂的面试中,类数组转换成数组这样的题目经常会问,也会问你 arguments 的相关问题,那么结合本讲的内容,下面我带你思考一下如何将类数组转换为数组。大致的实现方式有两种,我将依次讲解。

类数组借用数组方法转数组

apply 和 call 方法之前我们有详细讲过,类数组因为不是真正的数组,所以没有数组类型上自带的那些方法,我们就需要利用下面这几个方法去借用数组的方法。比如借用数组的 push 方法,请看下面的一段代码。

var arrayLike = { 0: 'java',1: 'script',length: 2} Array.prototype.push.call(arrayLike, 'jack', 'lily'); console.log(typeof arrayLike); console.log(arrayLike);

从中可以看到,arrayLike 其实是一个对象,模拟数组的一个类数组,从数据类型上说它是一个对象,新增了一个 length 的属性。从代码中还可以看出,用 typeof 来判断输出的是’object’,它自身是不会有数组的 push 方法的,这里我们就用 call 的方法来借用 Array 原型链上的 push 方法,可以实现一个类数组的 push 方法,给 arrayLike 添加新的元素。

从控制台的结果可以看出,数组的 push 方法满足了我们想要实现添加元素的诉求。我们再来看下 arguments 如何转换成数组,请看下面这段代码。

function sum(a, b) {let args = Array.prototype.slice.call(arguments);console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);  function sum(a, b) {let args = Array.prototype.concat.apply([], arguments);console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);  

这段代码中可以看到,还是借用 Array 原型链上的各种方法,来实现 sum 函数的参数相加的效果。一开始都是将 arguments 通过借用数组的方法转换为真正的数组,最后都又通过数组的 reduce 方法实现了参数转化的真数组 args 的相加,最后返回预期的结果。

ES6 的方法转数组

对于类数组转换成数组的方式,我们还可以采用 ES6 新增的 Array.from 方法以及展开运算符的方法。那么还是围绕上面这个 sum 函数来进行改变,我们看下用 Array.from 和展开运算符是怎么实现转换数组的,请看下面一段代码的例子。

function sum(a, b) {let args = Array.from(arguments);console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);    function sum(a, b) {let args = [...arguments];console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);    function sum(...args) {console.log(args.reduce((sum, cur) => sum + cur));}sum(1, 2);    

从代码中可以看出,Array.from 和 ES6 的展开运算符,都可以把 arguments 这个类数组转换成数组 args,从而实现调用 reduce 方法对参数进行累加操作。其中第二种和第三种都是用 ES6 的展开运算符,虽然写法不一样,但是基本都可以满足多个参数实现累加的效果。

讲到这里你可以再思考一下这两种类数组转换数组的方法,是不是很好理解呢?

总结

在这一讲中,我把日常开发中有可能遇到的几种类数组分别介绍了一遍,又结合类数组相关的应用场景进行了全方位的讲解,类数组应用场景的几个例子希望能为你的 JS 编码能力的提升带来帮助和启发。最后我又讲了类数组转换成数组的两种方式。

综上你可以看到,类数组这节课的知识点与第 4 讲中 apply、call 还是有紧密联系的,你可以通过下面的表格再重新梳理一下类数组和数组的异同点。

在前端工作中,开发者往往会忽视对类数组的学习,其实在高级 JavaScript 编程中经常需要将类数组向数组转化,尤其是一些比较复杂的开源项目,经常会看到函数中处理参数的写法,例如:[].slice.call(arguments) 这行代码。

通过本讲的学习,希望你以后在阅读别人代码的时候,能更清楚地理解高手们处理类数组的逻辑,以便在面试或者编码中能够轻松应对。

下一讲,我们来聊聊数组扁平化的相关内容,欢迎提前预习,届时我们再好好讨论。

09-数组原理(下):实现数组扁平化的 6 种方式

我在前两讲给你介绍了类数组的相关知识,那么这一讲会结合之前的内容,来聊聊数组相关的应用——如何实现数组扁平化。数组扁平化在一些多维数组的应用场景中会出现,我将围绕 6 种方式来带你实现它。

此外,关于数组除了扁平化也有其他问题,比如数组去重等,也是面试中经常会问到的。本讲的目的是将扁平化作为一个切入点,这种思路对于你解决其他类似的问题也是一个很好的启发。

按照惯例,在课程开始前请你先思考几个问题:

  1. 怎样用最普通的方法解决数组扁平化问题?

  2. ES6 里面是否有一些高级的方法能够直接实现?

下面开始说说什么是数组扁平化,如何实现它呢?

扁平化的实现

数组的扁平化其实就是将一个嵌套多层的数组 array(嵌套可以是任何层数)转换为只有一层的数组。举个简单的例子,假设有个名为 flatten 的函数可以做到数组扁平化,效果如下面这段代码所示。

var arr = [1, [2, [3, 4,5]]];console.log(flatten(arr)); 

其实就是把多维的数组 “拍平”,输出最后的一维数组。那么你知道了效果是什么样的了,下面就尝试着写一个 flatten 函数吧。实现方式有下述几种。

方法一:普通的递归实

普通的递归思路很容易理解,就是通过循环递归的方式,一项一项地去遍历,如果每一项还是一个数组,那么就继续往下遍历,利用递归程序的方法,来实现数组的每一项的连接。我们来看下这个方法是如何实现的,如下所示。

var a = [1, [2, [3, 4, 5]]];function flatten(arr) {let result = [];for(let i = 0; i < arr.length; i++) {if(Array.isArray(arr[i])) {result = result.concat(flatten(arr[i]));} else {result.push(arr[i]);}}return result;}flatten(a);  

从上面这段代码可以看出,最后返回的结果是扁平化的结果,这段代码核心就是循环遍历过程中的递归操作,就是在遍历过程中发现数组元素还是数组的时候进行递归操作,把数组的结果通过数组的 concat 方法拼接到最后要返回的 result 数组上,那么最后输出的结果就是扁平化后的数组。

下面我们来看看另一种实现方式。

方法二:利用 reduce 函数迭代

从上面普通的递归函数中可以看出,其实就是对数组的每一项进行处理,那么我们其实也可以用第 7 讲中说的 reduce 来实现数组的拼接,从而简化第一种方法的代码,改造后的代码如下所示。

var arr = [1, [2, [3, 4]]];function flatten(arr) {return arr.reduce(function(prev, next){return prev.concat(Array.isArray(next) ? flatten(next) : next)}, [])}console.log(flatten(arr));

这段代码在控制台执行之后,也可以得到想要的结果。这里你可以回忆一下之前说的 reduce 的参数问题,我们可以看到 reduce 的第一个参数用来返回最后累加的结果,思路和第一种递归方法是一样的,但是通过使用 reduce 之后代码变得更简洁了,也同样解决了扁平化的问题。

下面我们来看看下一种实现方式。

方法三:扩展运算符实现

这个方法的实现,采用了扩展运算符和 some 的方法,两者共同使用,达到数组扁平化的目的,还是来看一下代码。

var arr = [1, [2, [3, 4]]];function flatten(arr) {while (arr.some(item => Array.isArray(item))) {arr = [].concat(...arr);}return arr;}console.log(flatten(arr)); 

从执行的结果中可以发现,我们先用数组的 some 方法把数组中仍然是组数的项过滤出来,然后执行 concat 操作,利用 ES6 的展开运算符,将其拼接到原数组中,最后返回原数组,达到了预期的效果。

前三种实现数组扁平化的方式其实是最基本的思路,都是通过最普通递归思路衍生的方法,尤其是前两种实现方法比较类似。值得注意的是 reduce 方法,它可以在很多应用场景中实现,由于 reduce 这个方法提供的几个参数比较灵活,能解决很多问题,所以是值得熟练使用并且精通的。

那么除此之外,是否还有其他实现方式呢?请你接着往下看。

方法四:split 和 toString 共同处理

我们也可以通过 split 和 toString 两个方法,来共同实现数组扁平化,由于数组会默认带一个 toString 的方法,所以可以把数组直接转换成逗号分隔的字符串,然后再用 split 方法把字符串重新转换为数组,如下面的代码所示。

var arr = [1, [2, [3, 4]]];function flatten(arr) {return arr.toString().split(',');}console.log(flatten(arr)); 

通过这两个方法可以将多维数组直接转换成逗号连接的字符串,然后再重新分隔成数组,你可以在控制台执行一下查看结果。

下面我们看看 ES6 有什么方式可以直接实现数组的扁平化。

方法五:调用 ES6 中的 flat

我们还可以直接调用 ES6 中的 flat 方法,可以直接实现数组扁平化。先来看下 flat 方法的语法:

arr.flat([depth])

其中 depth 是 flat 的参数,depth 是可以传递数组的展开深度(默认不填、数值是 1),即展开一层数组。那么如果多层的该怎么处理呢?参数也可以传进 Infinity,代表不论多少层都要展开。那么我们来看下,用 flat 方法怎么实现,请看下面的代码。

var arr = [1, [2, [3, 4]]];function flatten(arr) {return arr.flat(Infinity);}console.log(flatten(arr)); 

可以看出,一个嵌套了两层的数组,通过将 flat 方法的参数设置为 Infinity,达到了我们预期的效果。其实同样也可以设置成 2,也能实现这样的效果。

因此,你在编程过程中,发现对数组的嵌套层数不确定的时候,最好直接使用 Infinity,可以达到扁平化。下面我们再来看最后一种场景。

方法六:正则和 JSON 方法共同处理

我们在第四种方法中已经尝试了用 toString 方法,其中仍然采用了将 JSON.stringify 的方法先转换为字符串,然后通过正则表达式过滤掉字符串中的数组的方括号,最后再利用 JSON.parse 把它转换成数组。请看下面的代码。

let arr = [1, [2, [3, [4, 5]]], 6];function flatten(arr) {let str = JSON.stringify(arr);str = str.replace(/(\[|\])/g, '');str = '[' + str + ']';return JSON.parse(str); }console.log(flatten(arr)); 

可以看到,其中先把传入的数组转换成字符串,然后通过正则表达式的方式把括号过滤掉,这部分正则的表达式你不太理解的话,可以看看下面的图片。

通过这个在线网站 https://regexper.com/ 可以把正则分析成容易理解的可视化的逻辑脑图。其中我们可以看到,匹配规则是:全局匹配(g)左括号或者右括号,将它们替换成空格,最后返回处理后的结果。之后拿着正则处理好的结果重新在外层包裹括号,最后通过 JSON.parse 转换成数组返回。

以上就是这六种实现数组扁平化的方式,你可以再思考下看看是否还有更多的实现方式,我们可以交流一下。

总结

本讲的内容就介绍这么多了。我将日常开发中有可能遇到数组扁平化的几种方法分别讲了一遍,又在最后一个方法中给你推荐了一个比较容易理解的正则表达式的分析工具。希望这几种方法能为你提升 JS 编码能力带来帮助和启发。

综上我们可以看到,数组扁平化这节课的知识点结合了数组 API、ES6,以及 JSON 方法的相关知识。你可以通过下面的表格再来看一下这六种方式的代码思路。

表格列举了这几种方法实现的难易程度,以及编码思路的描述。希望你能对这几种方法融会贯通,如果在未来的面试中遇到这样的题目,愿你能够轻松应对,给面试官一个满意的答复。

在日常的前端开发工作中,你往往会在业务开发的时候遇到各种数组问题,所以你要思考最合适的解决方式。其实扁平化只是其中的一个引子,其他涉及数组的相关知识点还有很多。

我最后给你留一个和数组相关的作业:试着写出实现数组去重的方式,看看你能够写出几种呢?

下一讲,我们来说说数组排序,这也是数据结构中必不可少的知识,我们到时见。

10-数组排序(上):如何用 JS 实现各种数组排序

我们上一讲学习了数组扁平化的相关知识,那么这一讲的内容和之前相比,较为独立,要说的是数组排序那些事儿。数组排序是你在 JavaScript 的编程过程中经常会遇到的,也是大厂面试中会考察的,尤其是调用 sort 方法,不过今天我们主要围绕数据结构排队进行讲解,关于 sort 方法的详细剖析我会在下一讲和你探讨。

那么,为了方便你更好地理解本讲的内容,在课程开始前请你先思考几个问题。

  1. 数据结构中稳定的排序算法有哪些?不稳定的排序算法有哪些?

  2. 时间复杂度和空间复杂度分别代表了什么?

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

时间复杂度 & 空间复杂度

在说排序算法之前,你需要重新了解一下时间复杂度和空间复杂度。

关于时间复杂度,我们说的更多的是通过 O(nlogn) 以及 O(n) 等来衡量。其实大多数时候我们对此并未建立形象的认知,到底哪一种算法更快、更好呢?下面是一张时间复杂度的曲线图(来源于 https://gitee.com/webfrontup/javascript-algorithms),方便你来理解。

图中用颜色区分了最优的、一般的以及比较差的时间复杂度,可以看到有这几种分类:Excellent、Good、Fair、Bad、Horrible,通过这张图可以一目了然。因此你在面试或者日常工作中编写代码的时候,要努力将代码的时间复杂度维持在 O(nlogn) 以下,要知道凡是超过 n 平方的时间复杂度都是难以接受的。

此外,关于哪些循环嵌套是 n 平方,哪些是 nlogn,我想你已经有一定的基础认知了,这里我就不过多讲解了。

空间复杂度比较容易理解,就是对一个算法在运行过程中临时占用存储空间大小的度量。有的算法需要占用的临时工作单元数与解决问题的规模有关,如果规模越大,则占的存储单元越多。比如,归并排序和快速排序的空间复杂度就是不太一样的。

有了这样的前提,我们就来研究各种排序的实现方法吧。

各种排序的 JS 实现

数据结构算法中排序有很多种,常见的、不常见的,至少包含十种以上。根据它们的特性,可以大致分为两种类型:比较类排序和非比较类排序。

  • 比较类排序:通过比较来决定元素间的相对次序,其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。

  • 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。

我们通过一张图片来看看这两种分类方式分别包括哪些排序方法。

非比较类的排序在实际情况中用的比较少,故本讲主要围绕比较类排序展开讲解。其实根据排序的稳定性,也可以分为稳定排序和不稳定排序,例如快速排序就是不稳定的排序、冒泡排序就是稳定的排序。我在最后总结的部分会帮助你再次区分。

那么我们先从最简单的排序开始学习吧,先看下冒泡排序。

冒泡排序

冒泡排序是最基础的排序,一般在最开始学习数据结构的时候就会接触它。冒泡排序是一次比较两个元素,如果顺序是错误的就把它们交换过来。走访数列的工作会重复地进行,直到不需要再交换,也就是说该数列已经排序完成。请看下面的代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function bubbleSort(array) {const len = array.lengthif (len < 2) return arrayfor (let i = 0; i < len; i++) {for (let j = 0; j < i; j++) {if (array[j] > array[i]) {const temp = array[j]array[j] = array[i]array[i] = temp}}}return array}bubbleSort(a);  

从上面这段代码可以看出,最后返回的是排好序的结果。因为冒泡排序实在太基础和简单,这里就不过多赘述了。下面我们来看看快速排序法。

快速排序

快速排序的基本思想是通过一趟排序,将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可以分别对这两部分记录继续进行排序,以达到整个序列有序。

请看下面的代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function quickSort(array) {var quick = function(arr) {if (arr.length <= 1) return arrconst len = arr.lengthconst index = Math.floor(len >> 1)const pivot = arr.splice(index, 1)[0]const left = []const right = []for (let i = 0; i < len; i++) {if (arr[i] > pivot) {right.push(arr[i])} else if (arr[i] <= pivot) {left.push(arr[i])}}return quick(left).concat([pivot], quick(right))}const result = quick(array)return result}quickSort(a);

上面的代码在控制台执行之后,也可以得到预期的结果。最主要的思路是从数列中挑出一个元素,称为 “基准”(pivot);然后重新排序数列,所有元素比基准值小的摆放在基准前面、比基准值大的摆在基准的后面;在这个区分搞定之后,该基准就处于数列的中间位置;然后把小于基准值元素的子数列(left)和大于基准值元素的子数列(right)递归地调用 quick 方法排序完成,这就是快排的思路。

下面我们来看看插入排序的实现方式。

插入排序

插入排序算法描述的是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入,从而达到排序的效果。来看一下代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function insertSort(array) {const len = array.lengthlet currentlet prevfor (let i = 1; i < len; i++) {current = array[i]prev = i - 1while (prev >= 0 && array[prev] > current) {array[prev + 1] = array[prev]prev--}array[prev + 1] = current}return array}insertSort(a); 

从执行的结果中可以发现,通过插入排序这种方式实现了排序效果。插入排序的思路是基于数组本身进行调整的,首先循环遍历从 i 等于 1 开始,拿到当前的 current 的值,去和前面的值比较,如果前面的大于当前的值,就把前面的值和当前的那个值进行交换,通过这样不断循环达到了排序的目的。

下面说说选择排序的实现方式。

选择排序

选择排序是一种简单直观的排序算法。它的工作原理是,首先将最小的元素存放在序列的起始位置,再从剩余未排序元素中继续寻找最小元素,然后放到已排序的序列后面…… 以此类推,直到所有元素均排序完毕。请看下面的代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function selectSort(array) {const len = array.lengthlet templet minIndexfor (let i = 0; i < len - 1; i++) {minIndex = ifor (let j = i + 1; j < len; j++) {if (array[j] <= array[minIndex]) {minIndex = j}}temp = array[i]array[i] = array[minIndex]array[minIndex] = temp}return array}selectSort(a); 

这样,通过选择排序的方法同样也可以实现数组的排序,从上面的代码中可以看出该排序是表现最稳定的排序算法之一,因为无论什么数据进去都是 O(n 平方) 的时间复杂度,所以用到它的时候,数据规模越小越好。

下面我们看看堆排序是怎样实现的。

堆排序

堆排序是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质,即子结点的键值或索引总是小于(或者大于)它的父节点。堆的底层实际上就是一棵完全二叉树,可以用数组实现。

根节点最大的堆叫作大根堆,根节点最小的堆叫作小根堆,你可以根据从大到小排序或者从小到大来排序,分别建立对应的堆就可以。请看下面的代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function heap_sort(arr) {var len = arr.lengthvar k = 0function swap(i, j) {var temp = arr[i]arr[i] = arr[j]arr[j] = temp}function max_heapify(start, end) {var dad = startvar son = dad * 2 + 1if (son >= end) returnif (son + 1 < end && arr[son] < arr[son + 1]) {son++}if (arr[dad] <= arr[son]) {swap(dad, son)max_heapify(son, end)}}for (var i = Math.floor(len / 2) - 1; i >= 0; i--) {max_heapify(i, len)}for (var j = len - 1; j > k; j--) {swap(0, j)max_heapify(0, j)}return arr}heap_sort(a); 

从代码来看,堆排序相比上面几种排序整体上会复杂一些,不太容易理解。不过你应该知道两点:一是堆排序最核心的点就在于排序前先建堆;二是由于堆其实就是完全二叉树,如果父节点的序号为 n,那么叶子节点的序号就分别是 2n 和 2n+1。

你理解了这两点,再看代码就比较好理解了。堆排序最后有两个循环:第一个是处理父节点的顺序;第二个循环则是根据父节点和叶子节点的大小对比,进行堆的调整。通过这两轮循环的调整,最后堆排序完成。

下面我们再来看最后一种归并排序。

归并排序

归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。我们先看一下代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];function mergeSort(array) {const merge = (right, left) => {const result = []let il = 0let ir = 0while (il < left.length && ir < right.length) {if (left[il] < right[ir]) {result.push(left[il++])} else {result.push(right[ir++])}}while (il < left.length) {result.push(left[il++])}while (ir < right.length) {result.push(right[ir++])}return result}const mergeSort = array => {if (array.length === 1) { return array }const mid = Math.floor(array.length / 2)const left = array.slice(0, mid)const right = array.slice(mid, array.length)return merge(mergeSort(left), mergeSort(right))}return mergeSort(array)}mergeSort(a); 

从上面这段代码中可以看到,通过归并排序可以得到想要的结果。上面提到了分治的思路,你可以从 mergeSort 方法中看到,通过 mid 可以把该数组分成左右两个数组,分别对这两个进行递归调用排序方法,最后将两个数组按照顺序归并起来。

归并排序是一种稳定的排序方法,和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好得多,因为始终都是 O(nlogn) 的时间复杂度。而代价是需要额外的内存空间。

以上就是今天要介绍的六种实现数组排序的算法,你有兴趣的话可以自己再学习下非比较类排序的那三种方法。

总结

这一讲,我们把平常开发中常见的几种排序方法分别介绍了一遍。我整理了一个表格,汇总了它们各自的时间复杂度和空间复杂度,你可以对比着来回顾一下本讲的内容。

其中你可以看到排序相关的时间复杂度和空间复杂度以及稳定性的情况,如果遇到需要自己实现排序的时候,可以根据它们的空间和时间复杂度综合考量,选择最适合的排序方法。

接下来的一讲我们将要分析 JS 的 sort 方法的实现,这一讲作为下节课的基础,我还是希望你能彻底理解上面的代码,从而提升 JavaScript 的编程能力和理解能力。本讲所说的各种排序算法的 JS 实现只是其中的引子,下节课的内容会相对难一些。如果你还是对个别排序算法不理解,可以试着自己多实现几遍,就会发现其中很多细节和你只是看一遍是不一样的。

感谢学习,我们下一讲再见。

11-数组排序(下):sort 排序方法的实现原理

我在上一讲为你介绍了用 JS 实现数组的各种排序,那么这一讲作为进阶,我要带你了解一下 JS 提供的数组排序的 sort 方法。数组排序在日常工作中经常会出现,除了上一讲介绍的排序算法外,通过 sort 方法也可以实现数组的排序,因此这一讲我会带你剖析 JS 数组 sort 方法的底层实现。

我们比较常用的是直接调用 JS 的 sort 方法,这一讲会围绕原生 JS 方法,并结合 V8 排序的代码一起来分析,以便你碰到类似的 JS 排序问题时能够轻松应对。

那么,在课程开始前请你先思考一下:

  1. sort 方法到底是用了哪种排序思路?

  2. sort 方法里面的参数对比函数是什么意思?

带着问题,我们开始说说 sort 方法的基本内容。

sort 方法的基本使用

sort 方法是对数组元素进行排序,默认排序顺序是先将元素转换为字符串,然后再进行排序,先来看一下它的语法:

arr.sort([compareFunction])

其中 compareFunction 用来指定按某种顺序进行排列的函数,如果省略不写,元素按照转换为字符串的各个字符的 Unicode 位点进行排序。我们来看一下代码。

const months = ['March', 'Jan', 'Feb', 'Dec'];months.sort();console.log(months);const array1 = [1, 30, 4, 21, 100000];array1.sort();console.log(array1);

从上面的执行结果可以看出,如果不加参数,在第二段代码中,21 会排到 4 的前面。这样按照从小到大的逻辑是行不通的,如果想要按照从小到大排序或者从大到小排序,那么上面的代码就需要调整为下面这样。

const array1 = [1, 30, 4, 21, 100000];array1.sort((a,b) => b - a);console.log(array1);    const array1 = [1, 30, 4, 21, 100000];array1.sort((a,b) => a - b);console.log(array1);    

如果指明了 compareFunction 参数 ,那么数组会按照调用该函数的返回值排序,即 a 和 b 是两个将要被比较的元素:

  • 如果 compareFunction(a, b)小于 0,那么 a 会被排列到 b 之前;

  • 如果 compareFunction(a, b)等于 0,a 和 b 的相对位置不变;

  • 如果 compareFunction(a, b)大于 0,b 会被排列到 a 之前。

说完 sort 方法的对比函数,下面我们来看一下 sort 的底层实现。

sort 方法的底层实现

相信你对 JS 数组的 sort 方法已经不陌生了,上面我也对它的用法进行了详细的介绍。那么它的内部是如何实现的呢?如果你能够进入它的内部看一看源码,理解背后的设计,这对编程思维的提升是一个很好的帮助。

sort 方法在 V8 内部相较于其他方法而言是一个比较难的算法,对于很多边界情况结合排序算法做了反复的优化,但是这里我不会直接拿源码来讲,而是会根据源码的思路,循序善诱地带你实现一个跟引擎性能类似的排序算法,并且一步步拆解其中的奥秘。

这里你需要回顾上一讲说的插入排序和快速排序,接下来我们就对 sort 源码进行分析。

底层 sort 源码分析

先大概来梳理一下源码中排序的思路(下面的源码均来自 V8 源码中关于 sort 排序的摘要,地址:V8 源码 sort 排序部分)。

通过研究源码我们先直接看一下结论,如果要排序的元素个数是 n 的时候,那么就会有以下几种情况:

  1. 当 n<=10 时,采用插入排序;

  2. 当 n>10 时,采用三路快速排序;

  3. 10<n <=1000,采用中位数作为哨兵元素;

  4. n>1000,每隔 200~215 个元素挑出一个元素,放到一个新数组中,然后对它排序,找到中间位置的数,以此作为中位数。

在得出这个结论之前,我觉得有必要让你了解为什么这么做。下面就一起来思考两个问题。

1. 为什么元素个数少的时候要采用插入排序?

虽然插入排序理论上是平均时间复杂度为 O(n^2) 的算法,快速排序是一个平均 O(nlogn) 级别的算法。但是别忘了,这只是理论上平均的时间复杂度估算,但是它们也有最好的时间复杂度情况,而插入排序在最好的情况下时间复杂度是 O(n)。

在实际情况中两者的算法复杂度前面都会有一个系数,当 n 足够小的时候,快速排序 nlogn 的优势会越来越小。倘若插入排序的 n 足够小,那么就会超过快排。而事实上正是如此,插入排序经过优化以后,对于小数据集的排序会有非常优越的性能,很多时候甚至会超过快排。因此,对于很小的数据量,应用插入排序是一个非常不错的选择。

2. 为什么要花这么大的力气选择哨兵元素?

因为快速排序的性能瓶颈在于递归的深度,最坏的情况是每次的哨兵都是最小元素或者最大元素,那么进行 partition(一边是小于哨兵的元素,另一边是大于哨兵的元素)时,就会有一边是空的。如果这么排下去,递归的层数就达到了 n , 而每一层的复杂度是 O(n),因此快排这时候会退化成 O(n^2) 级别。

这种情况是要尽力避免的,那么如何来避免?就是让哨兵元素尽可能地处于数组的中间位置,让最大或者最小的情况尽可能少。这时候,你就能理解 V8 里面所做的各种优化了。

接下来,我们看一下官方实现的 sort 排序算法的代码基本结构。

function ArraySort(comparefn) {CHECK_OBJECT_COERCIBLE(this,"Array.prototype.sort");var array = TO_OBJECT(this);var length = TO_LENGTH(array.length);return InnerArraySort(array, length, comparefn);}function InnerArraySort(array, length, comparefn) {if (!IS_CALLABLE(comparefn)) {comparefn = function (x, y) {if (x === y) return 0;if (%_IsSmi(x) && %_IsSmi(y)) {return %SmiLexicographicCompare(x, y);}x = TO_STRING(x);y = TO_STRING(y);if (x == y) return 0;else return x < y ? -1 : 1;};}function InsertionSort(a, from, to) {for (var i = from + 1; i < to; i++) {var element = a[i];for (var j = i - 1; j >= from; j--) {var tmp = a[j];var order = comparefn(tmp, element);if (order > 0) {a[j + 1] = tmp;} else {break;}}a[j + 1] = element;}}function GetThirdIndex(a, from, to) {   var t_array = new InternalArray();var increment = 200 + ((to - from) & 15);var j = 0;from += 1;to -= 1;for (var i = from; i < to; i += increment) {t_array[j] = [i, a[i]];j++;}t_array.sort(function(a, b) {return comparefn(a[1], b[1]);});var third_index = t_array[t_array.length >> 1][0];return third_index;}function QuickSort(a, from, to) {  var third_index = 0;while (true) {if (to - from <= 10) {InsertionSort(a, from, to); return;}if (to - from > 1000) {third_index = GetThirdIndex(a, from, to);} else {third_index = from + ((to - from) >> 1);}var v0 = a[from];var v1 = a[to - 1];var v2 = a[third_index];var c01 = comparefn(v0, v1);if (c01 > 0) {var tmp = v0;v0 = v1;v1 = tmp;}var c02 = comparefn(v0, v2);if (c02 >= 0) {var tmp = v0;v0 = v2;v2 = v1;v1 = tmp;} else {var c12 = comparefn(v1, v2);if (c12 > 0) {var tmp = v1;v1 = v2;v2 = tmp;}}a[from] = v0;a[to - 1] = v2;var pivot = v1;var low_end = from + 1; var high_start = to - 1;a[third_index] = a[low_end];a[low_end] = pivot;partition: for (var i = low_end + 1; i < high_start; i++) {var element = a[i];var order = comparefn(element, pivot);if (order < 0) {a[i] = a[low_end];a[low_end] = element;low_end++;} else if (order > 0) {do {high_start--;if (high_start == i) break partition;var top_elem = a[high_start];order = comparefn(top_elem, pivot);} while (order > 0);a[i] = a[high_start];a[high_start] = element;if (order < 0) {element = a[i];a[i] = a[low_end];a[low_end] = element;low_end++;}}}if (to - high_start < low_end - from) {QuickSort(a, high_start, to);to = low_end;} else {QuickSort(a, from, low_end);from = high_start;}}}

从上面的源码分析来看,当数据量小于 10 的时候用插入排序;当数据量大于 10 之后采用三路快排;当数据量为 10~1000 时候直接采用中位数为哨兵元素;当数据量大于 1000 的时候就开始寻找哨兵元素。

我们直接从上面的源码中就可以看到整个 sort 源码的编写逻辑,也就是上面总结分析的逻辑对应实现。如果你还是没有理解得很好,我建议你再重新看一下插入排序和快速排序的核心逻辑。其实关键点在于根据数据量的大小,从而确定用什么排序来解决;时间复杂度是根据数据量的大小,从而进行变化的,这一点需要深入理解。

总结

那么关于 sort 排序方法我就介绍到这里了。这一讲,我把 JS 中的 sort 方法详细讲解了一遍,同时又带你剖析了 sort 方法中内部的原理实现,你需要好好学习排序相关的算法,才能真正理解 V8 源码中实现的排序逻辑。

此外,关于排序时间复杂度也不用死记硬背,在不同的数据量情况下,不代表某种排序一定就要比另外一种排序速度快,这点你要牢记,然后根据不同的场景进行不同的分析。

那么讲到这里,我们再来整理一下快速排序和插入排序的最好以及最快情况下的时间复杂度的对比,请看下面的表格。

|

将这两个排序的时间复杂度对比来看,如果当 n 足够小的时候,最好的情况下,插入排序的时间复杂度为 O(n) 要优于快速排序的 O(nlogn),因此就解释了这里当 V8 实现 JS 数组排序算法时,数据量较小的时候会采用插入排序的原因了。

在日常的前端开发工作中,对此的应用会比较多,研究源码的机会也是相对较少的。通过本讲的学习,我还是希望你能够多想想日常工作中经常用到的 JS 方法,及其底层源代码的实现逻辑,从而整体提升 JS 的编程能力和理解能力。

这一讲我只是带你剖析了 sort 的源码实现,下一讲我会带你手写 JS 数组多个方法的底层实现。

另外在后面的课程中,类似这样的源码剖析还会有很多,在看每一个部分的同时,希望你能多练习、多研究,也欢迎你在下方留言发表自己在学习过程中遇到的困惑以及学习感悟等,让我们共同进步。

12-进阶练习:带你手写 JS 数组多个方法的底层实现

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

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

  1. reduce 方法里面的参数都是什么作用?

  2. push 和 pop 的底层逻辑是什么样的呢?

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

push 方法的底层实现

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

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

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

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

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

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

pop 方法的底层实现

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

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

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

Array.prototype.pop = function() {let O = Object(this);let len = this.length >>> 0;if (len === 0) {O.length = 0;return undefined;}len --;let value = O[len];delete O[len];O.length = len;return value;}

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

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

map 方法的底层实现

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

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

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

Array.prototype.map = function(callbackFn, thisArg) {if (this === null || this === undefined) {throw new TypeError("Cannot read property 'map' of null");}if (Object.prototype.toString.call(callbackfn) != "[object Function]") {throw new TypeError(callbackfn + ' is not a function')}let O = Object(this);let T = thisArg;let len = O.length >>> 0;let A = new Array(len);for(let k = 0; k < len; k++) {if (k in O) {let kValue = O[k];let mappedValue = callbackfn.call(T, KValue, k, O);A[k] = mappedValue;}}return A;}

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

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

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

reduce 方法的底层实现

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

When the reduce method is called with one or two arguments, the following steps are taken:1. Let O be ? ToObject(this value).2. Let len be ? LengthOfArrayLike(O).3. If IsCallable(callbackfn) is false, throw a TypeError exception.4. If len = 0 and initialValue is not present, throw a TypeError exception.5. Let k be 0.6. Let accumulator be undefined.7. If initialValue is present, thenSet accumulator to initialValue.8. Else,Let kPresent be false.Repeat, while kPresent is false and k < len,Let Pk be ! ToString(F(k)).Set kPresent to ? HasProperty(O, Pk).If kPresent is true, thenSet accumulator to ? Get(O, Pk).Set k to k + 1.If kPresent is false, throw a TypeError exception.9. Repeat, while k < len,Let Pk be ! ToString(F(k)).Let kPresent be ? HasProperty(O, Pk).If kPresent is true, thenLet kValue be ? Get(O, Pk).Set accumulator to ? Call(callbackfn, undefined, « accumulator, kValue, F(k), O »).Set k to k + 1.10. Return accumulator.

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

Array.prototype.reduce  = function(callbackfn, initialValue) {if (this === null || this === undefined) {throw new TypeError("Cannot read property 'reduce' of null");}if (Object.prototype.toString.call(callbackfn) != "[object Function]") {throw new TypeError(callbackfn + ' is not a function')}let O = Object(this);let len = O.length >>> 0;let k = 0;let accumulator = initialValue;  if (accumulator === undefined) {  for(; k < len ; k++) {if (k in O) {accumulator = O[k];k++;break;}}throw new Error('Each element of the array is empty');}for(;k < len; k++) {if (k in O) {accumulator = callbackfn.call(undefined, accumulator, O[k], O);}}return accumulator;}

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

  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 的编程能力和对底层的理解能力。

if (Object.prototype.toString.call(callbackfn) != “[object Function]”) {

throw new TypeError(callbackfn + ’ is not a function’)

}

let O = Object(this);

let len = O.length >>> 0;

let k = 0;

let accumulator = initialValue;

if (accumulator === undefined) {

for(; k < len ; k++) {

if (k in O) {

    accumulator = O[k];k++;

break;

  }}

throw new Error(‘Each element of the array is empty’);

}

for(;k < len; k++) {

if (k in O) {

  accumulator = callbackfn.call(undefined, accumulator, O[k], O);}

}

return accumulator;

}


根据上面的代码及注释,有几个关键点你需要重点关注:1.  初始值默认值不传的特殊处理;2.  累加器以及 callbackfn 的处理逻辑。这两个关键问题处理好,其他的地方和上面几个方法实现的思路是基本类似的,你要学会举一反三。### 总结到这里,本讲的内容就先告一段落了。这一讲内容虽少,但却是你必须要掌握的内容。这一讲中,我把 JS 的 push 、pop、map、reduce 的底层方法挨个带你实现了一遍,希望你能对此形成一套自己的思路。我所提供的实现代码,虽然不能完全和 V8 源码中实现的代码媲美,但是在正常的使用中,你如果自己能实现到这个程度,基本也可以满足要求了。讲到这里,我再贴一下 V8 数组关于各种方法的实现源码地址,如下表所示。<table data-nodeid="2914"><thead data-nodeid="2915"><tr data-nodeid="2916"><th data-nodeid="2918">数组方法</th><th data-nodeid="2919">V8 源码地址</th></tr></thead><tbody data-nodeid="2922"><tr data-nodeid="2923"><td data-nodeid="2924">pop</td><td data-nodeid="2925"><a href="https://github.com/v8/v8/blob/98d735069d0937f367852ed968a33210ceb527c2/src/js/array.js#L394" data-nodeid="2946">V8 源码 pop 的实现</a></td></tr><tr data-nodeid="2926"><td data-nodeid="2927">push</td><td data-nodeid="2928"><a href="https://github.com/v8/v8/blob/98d735069d0937f367852ed968a33210ceb527c2/src/js/array.js#L414" data-nodeid="2950">V8 源码 push 的实现</a></td></tr><tr data-nodeid="2929"><td data-nodeid="2930">map</td><td data-nodeid="2931"><a href="https://github.com/v8/v8/blob/98d735069d0937f367852ed968a33210ceb527c2/src/js/array.js#L1036" data-nodeid="2954">V8 源码 map 的实现</a></td></tr><tr data-nodeid="2932"><td data-nodeid="2933">slice</td><td data-nodeid="2934"><a href="https://github.com/v8/v8/blob/98d735069d0937f367852ed968a33210ceb527c2/src/js/array.js#L586" data-nodeid="2958">V8 源码 slice 的实现</a></td></tr><tr data-nodeid="2935"><td data-nodeid="2936">filter</td><td data-nodeid="2937"><a href="https://github.com/v8/v8/blob/98d735069d0937f367852ed968a33210ceb527c2/src/js/array.js#L1024" data-nodeid="2962">V8 源码 filter 的实现</a></td></tr><tr data-nodeid="2938"><td data-nodeid="2939">...</td><td data-nodeid="2940">...</td></tr></tbody></table>关于本讲内容没有提到的代码及方法,你可以根据自己的兴趣,尝试着实现其中的某个方法。同时也希望你能够多思考日常工作中都有哪些经常用到的 JS 方法,并且去研究其底层源代码的实现逻辑,找机会自己实现一遍,来整体提升你的 JavaScript 的编程能力和对底层的理解能力。下一讲我们将会进入一个全新的模块——JS 的异步编程篇,期待你能从中学习到更多的东西。每天进步一点点,加油!

JavaScript核心原理精讲第三章 数组原理和排序相关推荐

  1. 《学习JavaScript数据结构与算法》第三章 数组

    文章目录 前言 一.创建 && 初始化数组 二.操作数组 push-添加元素于末尾 unshift-添加元素于开头 pop-从数组末尾开始删除元素 shift-从数组开头开始删除元素 ...

  2. 深入Java核心 Java内存分配原理精讲

    深入Java核心 Java内存分配原理精讲 Java内存分配与管理是Java的核心技术之一,之前我们曾介绍过Java的内存管理与内存泄露以及Java垃圾回收方面的知识,今天我们再次深入Java核心,详 ...

  3. 视频教程-区块链技术原理精讲-区块链

    区块链技术原理精讲 5年JAVA/Go研发工程师经验.目前作为公司技术负责人从事区块链架构设计.研发工作. 熟悉分布式系统/区块链.云计算/虚拟化技术.敏捷开发等领域技术,开源软件爱好者. 荆帅帅 ¥ ...

  4. 编译原理练习题(第三章)

    编译原理练习题(第三章) 一. 二. 三. 四.

  5. 自动配置原理精讲||@Conditional ||怎么知道哪些自动配置类生效? 启用debug=true属性(在配置文件配置);

    自动配置原理精讲 派生注解 配置文件怎么配置 server.port spring.config.location="D://application.properties" htt ...

  6. 视觉SLAM十四讲-第三章笔记

    视觉SLAM14讲-第三章笔记 3.1 旋转矩阵 欧式变换 欧式变换:改变位资,不改变形状.大小. 旋转矩阵:R(3x3).是两个坐标系基的内积. 正交阵 行列式为1 逆表示相反的旋转 平移向量:t( ...

  7. Java学习 第三章 数组(三)排序算法

    ** Java学习 第三章 数组(三)排序算法 ** 主要内容:排序算法.排序算法横向比较.Arrays工具类的使用.数组常见异常 1.数组中涉及到的常见算法:排序算法 1.1 排序算法分类:内部排序 ...

  8. Java学习 第三章 数组(一)一维数组

    第三章 数组 3-1 数组的概述 数组:多个相同类型数据按一定的顺序排列的集合,并使用一个名字命名,并通过编号的方式对这些数据进行统一管理. 数组相关概念: 数组名.下标(索引.角标).元素.数组的长 ...

  9. 分布式服务架构精讲pdf文档:原理+设计+实战,(收藏再看)

    前言 如果你期待对分布式系统有一个更全面的认识,想要了解各个技术在分布式系统中如何应用.分别解决哪些问题.有怎样优秀的实现,推荐阅读.收藏本篇. 分布式.微服务几乎是现在的技术人员必须要了解的架构方向 ...

最新文章

  1. 站立潮头、无问西东 | 第二届“大数据在清华”高峰论坛成功举办
  2. 页面布局 - flex弹性布局
  3. Databricks基本操作
  4. Java数组与List 相互转换方法详解
  5. wxWidgets:wxSplashScreen 示例
  6. Android开发工程师面试指南
  7. WPF 模仿QQ音乐首页歌单效果
  8. 【华为云 ModelArts-Lab AI实战营】第三期:图像分类 (III) 模型参数网络调优
  9. Xcode 开发 控件如何和代码配合使用
  10. Servlet规范总结
  11. .[转] 读十年书,不如读懂这百句话
  12. 「mac操作指南」WidsMob HEIC将HEIC/HEIF 转换为 JPEG/PNG/TIFF格式
  13. DevExpress 小结
  14. android设置高度比例,Android View设置宽高比
  15. 第一个FPGA项目:led_flash简介项目流程和项目中遇到的问题
  16. 如何修改BOOT.INI启动项,添加一个D盘的启动系统上去?
  17. 11_4 PTB数据预处理
  18. 生产管理系统是什么?它有哪些功能模块?
  19. 路由器实现不同VLAN间通信
  20. 项目实战:PB/ORACLE:C/S架构 学生成绩教务管理系统

热门文章

  1. Dijkstra算法实现求有向图中一顶点到其余各个顶点的最短路径
  2. 三星资产管理 (香港)推出元宇宙ETF 专注投资未来的风口赛道
  3. python os爬取一文件夹下所有文件,获取大小并排序
  4. 智能电网技术:奥克尼群岛电力解决方案
  5. ubuntu 获取root权限超简单方法
  6. 问题1:解决Pycharm中的terminal无法打字问题
  7. android 自定义漂浮,Android漂浮背景效果的制作方法
  8. 印度软件服务业发布2013年展望 (zz)
  9. 联想一体计算机排行,联想一体机电脑哪款比较好?十大联想一体机电脑详细排名...
  10. 智课雅思短语---四、Exploit to the full one’s favorableconditions and avoid unfavorable ones...