前端学习之路

前端资源

gulp 是一个 js 自动化工具。

github 上的一些资料

谷歌字体

Open Graph protocol

MDN 学习 Web 开发

掘金 是真的挺不错的一个社区。好多干货文章。

现代 JavaScript 教程

相关文章

一文完全吃透 JavaScript 继承(面试必备良药)

这篇文章详细讲解了原型相关的知识,包括原型链、ES5 ES6中的继承实现以及原理。帮助理解 JavaScript 面向对象方向的继承原理和构造器相关概念。

[译] 在 async/await 中更好的处理错误

这篇文章详细介绍了在 JavaScript 中的异步问题,介绍了回调地狱、Promise、async/await,以及他们的错误处理机制。

中高级前端面试题(万字长文)

面试题以及知识框架整理。

理解Javascript的正则表达式

正则表达式是在应用中相当重要的一部分,需要去理解记忆并多加练习。

轻松理解JS中的面向对象,顺便搞懂prototype和proto

理解 JavaScript 中的原型、原型链、构造器等相关概念。

2020年你不能不知道的webpack基本配置

了解 webpack 工具,官方文档 https://www.webpackjs.com/concepts/

前端下载文件的5种方法的对比

关于前端的一些典型下载文件实现案例。

2020年大厂面试指南 - Vue篇

看大厂面试题对于理解相关知识也是很有用的。重要的是不要死记硬背,因为很多答案并不是从原理讲起,或者讲了看不懂,需要结合官方文档和实际上手去理解。

非常感谢这位作者,关于 Vue 的实现原理可以参考上面的连载专栏。不需要去深入算法,但是需要去了解一些基本的原理。同时自己可以尝试做一个类似的例如双重绑定、响应式等等。

这个源码系列好多都没看懂, 只能看个大概。感觉文档越看越浮躁了。

作为计算机相关行业人员对于数据结构和算法是必须要掌握一些的。

剖析一些经典的CSS布局问题,为前端开发+面试保驾护航

跟着某些作者看可以找到一些没有推荐过的文章。有的还没看过,先留着。

前端面试常考的手写代码不是背出来的!

这篇文章是对于代码规范和一些功能函数的实例研究,很基础但也很有用。

深入理解JS的原型和原型链

深入理解原型和原型链之间的关系,然后引申出执行上下文和 this 的相关概念。

vue 248个知识点(面试题)为你保驾护航

了解 Vue 的相关知识点和原理。

🔥(已更新3. 1w字)《大前端吊打面试官系列》 之 ES6 精华篇(2020年)

深入理解 ES6 。

在前端开发环境下,对于 Webpack、gulp 等工具的理解使用对于提升开发效率、专注前端页面是有好处的。

面试题:说说事件循环机制(满分答案来了)

关于事件循环机制。

前端工程师的自我修养-关于 Babel 那些事儿

Babel 是可以对代码进行向后兼容性编译的编译器,使得代码在不同环境下依然可以稳定运行。

你再不知道怎么解决跨域, 我就真的生气了

关于跨域相关的知识点。跨域指的是由于浏览器出于安全考虑所设置的同源策略,如果协议、域名、端口有一项不同就会产生跨域问题,而跨域问题所带来的是安全问题,解决跨域不仅仅是让前端可以成功访问到跨域资源,更重要的是理解背后的安全隐患。

记好这 24 个 ES6 方法,用来解决实际开发的 JS 问题

ES6 相关的对于实际问题的解决办法。

深入vue响应式原理(包含vue3. 0)

看这个是想了解关于 Vue 2. 0 和 3. 0 之间的变化。

2020 前端面试 | 第一波面试题总结

实际面试题。

🔥 动画:《大前端吊打面试官系列》 之原生 JavaScript 精华篇

关于 JavaScript 相关的基础知识理解。

前端也能学算法:由浅入深讲解贪心算法

关于贪心算法的讲解。

「 如何优雅的使用VUE? 」不可不知的VUE实战技巧

Vue 的实战技巧。

2020年了, 再不会webpack敲得代码就不香了(近万字实战)

webpack 相关知识。

【建议星星】要就来45道Promise面试题一次爽到底(1. 1w字用心整理)

关于 Promise 的各种面试题型。加深对于异步的理解和处理。

从手写Promise到async/await(接近6千字, 建议看一下)

关于异步和生成器、构造器的原理讲解和一些组合方法。

【建议👍】再来40道this面试题酸爽继续(1. 2w字用手整理)

关于 this 的相关题型。

vue 组件通信看这篇就够了(12种通信方式)

详细介绍了不同的父子组件间的通信方式。

学习 BFC (Block Formatting Context)

了解在 CSS 中的 BFC 的相关概念。

【第1250期】彻底理解浏览器的缓存机制

了解浏览器的缓存机制

【译】Async/await和Promise的不为人知的秘密

关于 Async 和 Promise 的性能上的区别。

learnVue

github 上的 Vue 学习文章

Vue3响应式系统源码解析-单测篇

关于 Vue3 的响应式实现。

【面试篇】寒冬求职季之你必须要懂的原生JS(上)

经典面试题和解析

【面试篇】寒冬求职季之你必须要懂的原生JS(中)

经典面试题和解析

【面试篇】寒冬求职之你必须要懂的Web安全

关于 Web 安全的相关面试题和解析

「进击的前端工程师」浏览器中JavaScript的事件循环

深入了解 JavaScript 引擎的事件机制,后面的例题也很值得思考。

详解JS函数柯里化

介绍了柯里化的详细原理和实现过程

柯里化与反柯里化

详细解释柯里化的用法

2万字 | 前端基础拾遗90问

深入理解基础,大多都是需要去看代码思考代码的问题。

一位前端小姐姐的五万字面试宝典

经典例题以及代码实现

图解浏览器的基本工作原理

了解浏览器的工作原理

浅谈js防抖和节流

了解防抖和节流的思想以及实现方法

函数防抖和节流

可以参考上面的文章一起看,我感觉这个实例代码写的更好一些。

前端基础篇之CSS世界

总算找到CSS的教程了。

知识框架

  • HTML
    • HTML 不同版本的差异
    • HTML5
  • CSS
    • CSS 不同版本的差异
    • CSS3
    • Sass
  • JavaScript 廖雪峰
  • Node. js
  • jQuery(老掉牙了)
  • Ajax(也老掉牙了,了解XMR就行)
  • Webpack
  • Gulp
  • Vue
  • React
  • Angular
  • ES5
  • ES6

HTML 整理

HTML 只是一个标记性的语言,本身没有什么学习难度,HTML5 是新一代的 HTML 标准,实现了一些新的元素和属性, 改进了本地存储并且添加了很多语义元素,使得代码或者页面更加语义化。语义化的好处在于对代码的理解更加简单,同时也为视力障碍人士改善了网页的可阅读性。

关于更多的细节参考 https://www.runoob.com/html/html-tutorial.html

HTML DOM

关于 DOM 的理解可以参考 https://developers.google.com/web/fundamentals/performance/critical-rendering-path/render-tree-construction

DOM 是文档对象模型的缩写,按照对对象的理解,DOM 就是浏览器生成的这个页面的对象,每一个 HTML 元素就是一个节点,通过 HTML 的嵌套关系生成子节点和父节点关系的树,元素的属性也是元素对应节点的子节点。

DOM 的树并不是普通的二叉树或者特殊的树

DOM 节点关系

关系其实还是挺复杂的。不过 DOM 提供了各种方法实现了对于节点或者元素的选择、修改。

获取目标元素节点是对于 JavaScript 脚本比较重要的一环,获取到节点后即可对节点所产生的事件进行脚本控制。

当然在 Vue 或者 React 等框架中有对应的方法可以更加方便的获取节点添加事件。

CSS DOM

css 也是有一个 DOM 树的通过将两棵树合并生成一个渲染树供浏览器对页面进行渲染。

CSS 整理

CSS 的作用是给 HTML 元素加上样式,同时又可以多个样式层叠,所以叫层叠样式表。

CSS 的内容也是比较复杂的。细节参考 https://www.runoob.com/css/css-tutorial.html

需要理解不同元素类型能做的事和不能做的事,例如行内元素的宽度和高度是不能设置的。根据内容自动。

不过对于网页 CSS 设计来说,例如 Bootstrap 或者 Element 等前端组件库已经设定好了很多实用的样式。可以在此基础上进行自定义。关于页面的布局也是有对应的解决方案,需要理解的还是元素的类型,

参考 https://www.runoob.com/cssref/pr-class-display.html

CSS 相关的教程好少啊,但是感觉各种属性对应的布局却很麻烦。

关于各个布局

https://zh.learnlayout.com/ 可以用来了解布局相关的知识。

布局其实就是把网页根据内容进行一个规划。

网页的内容永远都是不确定的,那么布局当然是要怎么好看怎么来。

了解布局首先需要了解 display 这个属性。这个属性决定了 HTML 元素在 CSS 层面上的性质。每个 tag 都有默认的 display 属性,比如常见的 h1 div p form 就是块级元素,而 a b i span select img input 都是行内元素。

通过不同的元素特性,页面元素也会产生不同的显示效果。比如在弹性盒子中有很多新的特性和 CSS 样式,可以更加方便快捷的设定垂直居中效果,而在块级元素中就很难做到。大致上来说网页的布局也就分为一列、二列、三列,通过样式的叠加形成不同的网页显示效果。至于说的什么圣飞布局、双飞翼布局感觉没啥意思,究其原理还是浮动、弹性盒子、网格、定位这类基础属性,只能说是别人设计出来的网页布局就是了。重要的还是内容的排列。通过 display 属性获得不同的文字、图片、内容块的排列效果。

关于盒模型

盒模型是用来计算元素大小的一个标准模型,完整适用于块元素,部分适用于行内元素。盒模型还有一个替代模型IE盒模型,在IE盒模型中,所有的宽度和高度都是可见的,内容的宽度包括了Padding的宽度。

Diagram of the box model

W3C 模型中的块级元素大小 = Content+Padding*2+Margin*2+Border*2

IE 盒模型大小不包括 Margin。

一个被定义成块级的(block)盒子会表现出以下行为:

  • 盒子会在内联的方向上扩展并占据父容器在该方向上的所有可用空间,在绝大数情况下意味着盒子会和父容器一样宽
  • 每个盒子都会换行
  • width height 属性可以发挥作用
  • 内边距(padding), 外边距(margin) 和 边框(border) 会将其他元素从当前盒子周围“推开”

如果一个盒子对外显示为 inline ,那么他的行为如下:

  • 盒子不会产生换行。
  • width height 属性将不起作用。
  • 内边距、外边距以及边框会被应用但是不会把其他处于 inline 状态的盒子推开。

一个元素使用 display: inline-block ,实现我们需要的块级的部分效果:

  • 设置 widthheight 属性会生效。
  • padding , margin , 以及 border 会推开其他元素。

但是,它不会跳转到新行,如果显式添加 widthheight 属性,它只会变得比其内容更大。

https://developer.mozilla.org/zh-CN/docs/Learn/CSS/CSS_layout/Flexbox 详细介绍了 display:flexible 弹性盒子的 flex 模型

flex_terms. png

  • 主轴(main axis)是沿着 flex 元素放置的方向延伸的轴(比如页面上的横向的行、纵向的列)。该轴的开始和结束被称为 main startmain end
  • 交叉轴(cross axis)是垂直于 flex 元素放置方向的轴。该轴的开始和结束被称为 cross startcross end
  • 设置了 display: flex 的父元素(在本例中是 <section> )被称之为 flex 容器(flex container)。
  • 在 flex 容器中表现为柔性的盒子的元素被称之为 flex 项flex item)(本例中是 article 元素。

CSS 语句的权重

css选择器权重列表如下:

权重值 选择器
1,0,0,0 内联样式:style=””
0,1,0,0 ID选择器:#idName{...}
0,0,1,0 类、伪类、属性选择器:.className{...} / :hover{...} / [type="text"] ={...}
0,0,0,1 标签、伪元素选择器:div{...} / :after{...}
0,0,0,0 通用选择器(*)、子选择器(>)、相邻选择器(+)、同胞选择器(~)

需要注意的是这个权重并不是累加的,比如 ID 选择器会始终比属性选择器的优先级更高,即使在同一个元素上加10个属性选择器,优先级也不会比 ID 选择器高。系统在比较两个元素的优先级时会先比较高级的,如果相同才会向下比较,不相同就直接得出结果。

垂直对齐

行内元素在块级元素中的垂直对齐主要使用 line-heightvertical-align 两个属性。

line-height 属性通过设置行高确定行距,行距是系统会自动设置上下对等的,所以将行高设置成块级元素的高即可使行内元素在块级元素中垂直居中。

line-height 属性可以在块级元素中设置也可以在子元素中设置,并且按照最大值确定。

vertical-align 属性只能作用于内联元素。考虑到内联元素就需要考虑到 基线 ,基线是字体对齐的标准。

一个设置了display: inline-block的元素:

  1. 如果元素内部没有内联元素,则该元素基线就是该元素下边缘;
  2. 如果元素设置了overflowhidden auto scroll,则其基线就是该元素下边缘;
  3. 如果元素内部还有内联元素,则其基线就是内部最后一行内联元素的基线。

需要注意的是 vertical-align 属性是基于行按照基线去对齐的,所以如果他需要对齐的父元素的高度并不是普通行高,是需要设置 line-height 的。也就是说大部分时候 line-height 属性就可以居中了,但是在视觉上可能没有那么居中,所以需要 vertical-align 来微调。

关于 BFC

CSS3 更新了什么

CSS3被拆分为”模块”。旧规范已拆分成小块,还增加了新的。

一些最重要CSS3模块如下:

  • 选择器
  • 盒模型
  • 背景和边框
  • 文字特效
  • 2D/3D转换
  • 动画
  • 多列布局
  • 用户界面

新的边框属性

border-radius 可以为任何元素提供一个圆角,值一般是这个圆角的半径。可以单独为每个角进行设置。

box-shadow 属性用于给一个元素添加阴影效果。这个元素可以是块元素也可以是内联元素。

border-image 用于给边框提供一个自定义的图片。

新的背景属性

  • background-image
  • background-size
  • background-origin
  • background-clip

background-image 属性可以同时使用多张图片,通过逗号隔开,其他的关于图片背景的设置会按照 background-image 中的引入顺序进行配置。

background-origin 属性用于配置背景图片的位置是以 border、padding 还是 content 为起始位置。

background-clip 属性和 origin 差不太多,只是作用不太一样,origin 是指定背景图片的位置,clip 是指定整个背景的范围。这里只用的裁剪,更适合用在纯色背景上。

渐变效果(好看

  • 线性渐变(Linear Gradients)- 向下/向上/向左/向右/对角方向
  • 径向渐变(Radial Gradients)- 由它们的中心定义
1
2
background-image: linear-gradient(direction, color-stop1, color-stop2, ...);
background-image: radial-gradient(shape size at position, start-color, ..., last-color);

默认情况下是从上到下的渐变。

角度图

也可以通过角度来设置方向。

(可以设置多个节点但是彩虹色好看吗?)

变化更多的话可以使用透明度,也就是使用 rgba() 函数来定义颜色节点。

径向渐变就是一个渐变圆,默认是一个椭圆,可以设置为圆形的渐变。

甚至可以通过 repeating-radial-gradient() 设置重复效果。或许可以用来做水滴波纹样式。

新的文本属性

  • text-shadow
  • box-shadow
  • text-overflow
  • word-wrap
  • word-break

JavaScript 整理

关于语法就和其他语言是一样的,变量、运算、数据类型、函数、对象、数组、循环、作用域等等。

不一样的是,由于 JavaScript 是运行在浏览器上的。所以很多功能代码都是已经实现了的。不需要像其他语言一样需要自己写类。由于本身是面向对象的,JavaScript 的所有变量都是一个对象。对于 JavaScript 的学习需要去了解背后的原理。

由于浏览器的版本大家可能都不一样,所以在使用 JavaScript 特性的时候需要考虑版本问题。了解 ES5 和 ES6 的区别。可以使用 https://caniuse.com/ 来了解特性的支持情况。

细节参考 https://www.w3school.com.cn/js/index.asp

关于原型链

JavaScript 是解释型脚本语言,同时也是面向对象的语言,JavaScript 的对象是动态的属性“包”,会有一个 __proto__ 的私有属性,该属性会指向构造函数的原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 让我们从一个自身拥有属性a和b的函数里创建一个对象o:
let f = function() {
this.a = 1;
this.b = 2;
}
/* 这么写也一样
function f() {
this.a = 1;
this.b = 2;
}
*/
let o = new f(); // {a: 1, b: 2}

// 在f函数的原型上定义属性
f.prototype.b = 3;
f.prototype.c = 4;

// 不要在 f 函数的原型上直接定义 f.prototype = {b:3,c:4};这样会直接打破原型链
// o.[[Prototype]] 有属性 b 和 c
// (其实就是 o.__proto__ 或者 o.constructor.prototype)
// o.[[Prototype]].[[Prototype]] 是 Object.prototype.
// 最后o.[[Prototype]].[[Prototype]].[[Prototype]]是null
// 这就是原型链的末尾,即 null,
// 根据定义,null 就是没有 [[Prototype]]。

// 综上,整个原型链如下:

// {a:1, b:2} ---> {b:3, c:4} ---> Object.prototype---> null

console.log(o.a); // 1
// a是o的自身属性吗?是的,该属性的值为 1

console.log(o.b); // 2
// b是o的自身属性吗?是的,该属性的值为 2
// 原型上也有一个'b'属性,但是它不会被访问到。
// 这种情况被称为"属性遮蔽 (property shadowing)"

console.log(o.c); // 4
// c是o的自身属性吗?不是,那看看它的原型上有没有
// c是o.[[Prototype]]的属性吗?是的,该属性的值为 4

console.log(o.d); // undefined
// d 是 o 的自身属性吗?不是,那看看它的原型上有没有
// d 是 o.[[Prototype]] 的属性吗?不是,那看看它的原型上有没有
// o.[[Prototype]].[[Prototype]] 为 null,停止搜索
// 找不到 d 属性,返回 undefined

来自 MDN https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Inheritance_and_the_prototype_chain

所以当一个对象通过 new 关键字从其他的对象“实例化”,会将先生成一个空对象,通过 Object. setPrototypeOf()原生函数将实例对象添加到原型链上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function polyNew(source, ...arg) {
// 创建一个空的简单JavaScript对象(即{})
let newObj = {};
// 链接该对象(即设置该对象的构造函数)到另一个对象
Object.setPrototypeOf(newObj, source.prototype);
// 将步骤1新创建的对象作为this的上下文 ;
const resp = source.apply(newObj, arg);
// 判断该函数返回值是否是对象
if (Object.prototype.toString.call(resp) === "[object Object]") {
// 如果该函数没有返回对象,则返回this。
return resp
} else {
// 如果该函数返回对象,那用返回的这个对象作为返回值。
return newObj
}
}

作者: dellyoung
链接: https: //juejin.im/post/5e54d9e86fb9a07c944c932a
来源: 掘金
著作权归作者所有。 商业转载请联系作者获得授权, 非商业转载请注明出处。

上面这个地址的文章写的非常详细。可以深入理解关于原型 Null 、原型链、This 的相关原理。

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

实现起来也是比较简单的。顺藤摸瓜就行了。

this 是什么?

理解 this 就需要理解 JavaScript 的执行上下文和调用栈,可以看上面的文章进行详细的步进的了解。

了解了执行上下文后,就知道了执行上下文包括了变量环境、词法环境、outer、this。

而这个 this 就是指向当下执行环境的一个指针。在浏览器环境下指的是 window。

1
2
3
4
5
6
7
8
9
10
11
12
执行下面代码:

function foo() {
console.log(this) // window
}
foo()
// 可以看到输出了window,说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。
// 可以认为 JavaScript 引擎在执行foo()时,将其转化为了:
function foo() {
console.log(this) // window
}
window.foo.call(window)

关于这一段我理解的是由于 foo() 函数是一个函数而不是一个对象, this 指向这个函数没有什么意义,于是默认情况下会将它指向上一层的执行上下文对象window window 在浏览器中表示浏览器的窗口。

1
2
3
4
5
6
7
8
9
10
function foo() {
console.log(this) // window
}
foo()
//Window {parent: Window, opener: Window, top: Window, length: 0, frames: Window, …}
a = new foo()
//foo {}
// __proto__:
// constructor: ƒ foo()
// __proto__: Object

可见,当我们把它实例化的时候,会通过 new 中的构造器,将 foo() 链接到 a 的原型上。这个时候的 this 指的就是 foo() 了,当然这个 foo() 并不是说 foo() 函数,而是指将 foo() 实例化的 a 对象。如果再实例化一个 b ,两者并不会相等。

或许我可以理解为 this 指代的永远是一个对象,而非一个函数,如果在函数中输出一个 this 而这个函数并非是某个对象的方法,那么 this 会重定向到 window 对象

关于执行上下文还有配套的 callapplybind 三个方法。详情参考https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/apply#

主要用处就是设置 this ,在调用一个存在的函数时,你可以为其指定一个 this 对象。 this 指当前对象,也就是正在调用这个函数的对象。 使用 apply , 你可以只写一次这个方法然后在另一个对象中继承它,而不用在新对象中重复写该方法。

1
2
3
4
5
function foo() {
console.log(this) // window
}
foo()
复制代码

可以看到输出了window,说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function f(fn, x) {
console.log('into')
if (x < 1) {
f(g, 1);
} else {
fn();
}

function g() {
console.log('x' + x);
}
}

function h() {}

f(h, 0)
// into
// into
// x0

作者: 小黎也
链接: https: //juejin.im/post/5e523e726fb9a07c9a195a95
来源: 掘金
著作权归作者所有。 商业转载请联系作者获得授权, 非商业转载请注明出处。

这个面试题的逻辑看上去很简单,但是却有效的解释了执行上下文是如何影响程序的,同时也引出了作用域链的一个概念,上下文上下文,则说明是有上文也是有下文的。比如全局执行上下文就肯定是其他执行上下文的上文,那么参数在不同的执行上下文之间调用,每个参数不同的作用域,就形成了作用域链,和原型链是异曲同工的。如果一个函数在执行中找不到某个参数,就会顺着作用域链往上找。

g 函数中的 x 变量是引用父级的,而 f 函数执行了两次,x 变量依次为 0 1,在 f(h, 0) 这个函数执行的时候,这个函数的作用域中的 x=0,这个时候 g 函数中引用的 x 就是当前执行上下文中的 x=0 这个变量,但这个函数还没被执行,接着到了 f(g, 1) 执行,这一层执行上下文中的 x=1 ,但注意两次 f 执行的作用域不是同一个对象,是作用域链上两个独立的对象,最后到了 fn() ,这个 fn 是一个参数,也就是在 f(h, 0) 执行的时候 g 函数,那么 g 函数在这里被执行,g 打印出来的 x 就是 0 。

如果最后一段理解起来困难的话可以这样想,最后执行 f(g, 1)的时候,执行的 fn() 并不是当下的 g(),而是传入的 g(),而这个传入的 g() 是来自 f(h, 0) 这个环境下的。所以 x=0.

或许可以对这个题进行一个改变,如果 g() 函数的定义在是 f() 函数开头会怎么样?结果还是一样的,因为不管 g() 函数的位置如何变,始终是运行的 f(h, 0) 环境下的 g()。

关于变量提升

https://developer.mozilla.org/zh-CN/docs/Glossary/Hoisting

变量提升(Hoisting)被认为是, Javascript中执行上下文 (特别是创建和执行阶段)工作方式的一种认识。

JavaScript 仅提升声明,而不提升初始化

变量提升可能是个好东西,因为它能让你的程序跑起来。但也可能是个坏东西,因为会养成到处声明的坏习惯。

1
2
3
4
5
6
7
8
console.log(num); // Returns undefined 
var num;
num = 6;
// If you declare the variable after it is used, but initialize it beforehand, it will return the value:

num = 6;
console.log(num); // returns 6
var num;

正确的编写原则是先声明再初始化,最后才是运用变量。

在 ES6 中的 let 和const不存在变量提升

var: 解析器在对 js 解析时,会将脚本扫描一遍,将变量的声明提前到代码块的顶部,赋值还是在原先的位置,若在赋值前调用,就会出现暂时性死区,值为 undefined

let const:不存在在变量提升,且作用域是存在于块级作用域下,所以这两个的出现解决了变量提升的问题,同时引用块级作用域。 注:变量提升的原因是为了解决函数互相调用的问题。

关于 Promise 和 Async/Await

在 MDN 中关于 Promise 是这样介绍的 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises

Promise 是一个对象,它代表了一个异步操作的最终完成或者失败。本质上,Promise 是一个被某些函数传出的对象,我们附加回调函数(callback)使用它,而不是将回调函数传入那些函数内部。

由于 JavaScript 是单线程的,所以我们需要考虑到网络的影响,如果在一个图片还没加载好的时候就去使用这个资源,是会造成图片缺失的,所以提出了异步,所谓异步就是使有关联性的程序按照顺序运行,防止资源加载未完成等问题。

我对于 Promise 的理解是,首先它的提出是为了解决回调函数过于复杂和冗余的问题。

1
2
3
4
5
6
7
doSomething(function(result) {
doSomethingElse(result, function(newResult) {
doThirdThing(newResult, function(finalResult) {
console.log('Got the final result: ' + finalResult);
}, failureCallback);
}, failureCallback);
}, failureCallback);

回调函数本身是把一个函数作为另一个函数的参数,然后在函数中调用这个函数达到异步的效果。

1
2
3
4
5
6
7
8
9
10
doSomething().then(function(result) {
return doSomethingElse(result);
})
.then(function(newResult) {
return doThirdThing(newResult);
})
.then(function(finalResult) {
console.log('Got the final result: ' + finalResult);
})
.catch(failureCallback);

这个示例通过返回 Promise 对象,实现了 Promise 对象的 then 链式写法,then里的参数是可选的, catch(failureCallback)then(null, failureCallback) 的缩略形式。如下所示,我们也可以用箭头函数来表示:

1
2
3
4
5
6
7
doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
console.log( `Got the final result: ${finalResult}` );
})
.catch(failureCallback);

上面的示例来自 MDN 的 Promise 文档

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises

需要注意的是,这个示例并不能在 console 中跑起来,因为没有具体的定义 doSomething(),在实际使用中,这几个函数都是需要有返回值的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const doSomething = () => {
return new Promise((resolve, reject) => {
console.log("doSomething")
resolve("doSomething end")
})
}
const doSomethingElse = (argu) => {
return new Promise((resolve, reject) => {
console.log(argu)
resolve("doSomethingElse end")
})
}
const doThirdThing = (argu) => {
return new Promise((resolve, reject) => {
console.log(argu)
resolve("doThirdThing end")
})
}
// doSomething()
// .then(result => doSomethingElse(result))
// .then(newResult => doThirdThing(newResult))
// .then(finalResult => {
// console.log( `Got the final result: ${finalResult}` );
// })
// .catch(()=>{console.log("fail")});

// doSomething
// doSomething end
// doSomethingElse end
// Got the final result: doThirdThing end
// Promise {<resolved>: undefined}

当一个函数返回 Promise 对象的时候,可以把它当做一个异步函数,后面的 .then 方法是 Promise 对象的方法之一,用来接收上一个 Promise 在 resolve 的时候的消息,作为自定义的变量如 result 参与 .then 方法中的程序。由于几个函数都是返回的 Promise 对象,形成了一条 Promise 链。这条链是按照顺序异步运行的。 .catch 方法是用来获取在前面 Promise 链中的错误,进行合适的错误抛出和处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new Promise((resolve, reject) => {
console.log('初始化');

resolve();
})
.then(() => {
throw new Error('有哪里不对了');

console.log('执行「这个」”');
})
.catch(() => {
console.log('执行「那个」');
})
.then(() => {
console.log('执行「这个」,无论前面发生了什么');
});
// 初始化
// 执行“那个”
// 执行“这个”,无论前面发生了什么

.catch 也是返回的 Promise 对象,可以继续连接 .then 方法,使得程序在接收到错误后可以继续正常运行。

1
2
3
4
5
6
7
8
9
10
async function foo() {
try {
let result = await doSomething();
let newResult = await doSomethingElse(result);
let finalResult = await doThirdThing(newResult);
console.log( `Got the final result: ${finalResult}` );
} catch (error) {
failureCallback(error);
}
}

使用 Async/Await 同样可以达成异步操作,并且代码看上去更加的简洁。Async/Await 其实是 Promise 的一个语法糖,其原理还是 Promise,

async / await 的目的是简化使用多个 promise 时的同步行为,并对一组 Promises 执行某些操作。正如 Promises 类似于结构化回调, async / await 更像结合了generators和 promises。

关于 Async/Await 的错误处理机制可以参考 https://juejin.im/post/5e535624518825496e784ccb

继续深入一下 Promise

Promise.all(iterable)方法返回一个 Promise 实例,此实例在 iterable 参数内所有的 promise 都“完成(resolved)”或参数中不包含 promise 时回调完成(resolve);如果参数中 promise 有一个失败(rejected),此实例回调失败(reject),失败原因的是第一个失败 promise 的结果。

1
2
3
4
5
6
7
8
9
10
11
var p1 = Promise.resolve(3);
var p2 = 1337;
var p3 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("foo");
}, 100);
});

Promise.all([p1, p2, p3]).then(values => {
console.log(values); // [3, 1337, "foo"]
});

Promise.allSettled()方法返回一个在所有给定的promise已被决议或被拒绝后决议的promise,并带有一个对象数组,每个对象表示对应的promise结果。

1
2
3
4
5
6
7
8
9
10
const promise1 = Promise.resolve(3);
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'foo'));
const promises = [promise1, promise2];

Promise.allSettled(promises).
then((results) => results.forEach((result) => console.log(result.status)));

// expected output:
// "fulfilled"
// "rejected"

Promise.any() 接收一个 Promise 可迭代对象,只要其中的一个 promise 完成,就返回那个已经有完成值的 promise 。如果可迭代对象中没有一个 promise 完成(即所有的 promises 都失败/拒绝),就返回一个拒绝的 promise ,返回值还有待商榷:无非是拒绝原因数组或 AggregateError 类型的实例,它是 Error 的一个子类,用于把单一的错误集合在一起。本质上,这个方法和 Promise.all() 是相反的。

Promise.race(iterable) 方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。

1
2
3
4
5
6
7
8
9
10
11
12
13
const promise1 = new Promise(function(resolve, reject) {
setTimeout(resolve, 500, 'one');
});

const promise2 = new Promise(function(resolve, reject) {
setTimeout(resolve, 100, 'two');
});

Promise.race([promise1, promise2]).then(function(value) {
console.log(value);
// Both resolve, but promise2 is faster
});
// expected output: "two"

理解 Async/Await 除了理解 Promise 之外,我们知道它是 Promise 的一个语法糖。结合了 Promise 和 constructor。

AsyncFunction 就是 Async/Await 的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function resolveAfter2Seconds(x) {
return new Promise(resolve => {
setTimeout(() => {
resolve(x);
}, 2000);
});
}

var AsyncFunction = Object.getPrototypeOf(async function() {}).constructor;
var a = new AsyncFunction('a',
'b',
'return await resolveAfter2Seconds(a) + await resolveAfter2Seconds(b);');
a(10, 20).then(v => {
console.log(v); // 4 秒后打印 30
});

这个就是使用 AsyncFunction 实现的一个异步函数。不过看上去好像跟普通的 Promise 没什么不同,我记得看过一个实现 Async 构造器的文章,找不到了。回头再写这里吧。

关于包装对象

在 JavaScript 中变量一般分为基本数据类型和对象类型。基本数据类型包括 Number、String、Boolean,在我们定义一个数字或者字符串的时候,它的类型 typeof() 出来就是普通的数据类型,但是我们可以使用 .length .toString() 等对象的方法,原因是 JavaScript 在执行的时候会使用数据类型对象包装原始数据,使其成为一个对象然后使用相关方法和属性。但是在输出后就会将其销毁,并不会改变原生数据的类型。

关于 Null 和 Undefined

1
2
3
4
undefined == null
true
undefined === null
false

两个数据类型都是表示为空,但是具体的含义不太一样,Null 表示数据有值,值的内容为空,这个空并不是 0 或者一个空字符串,所以可以使用 Null 来初始化变量,Undefined 表示数据没有值,当变量仅被声明而没有被初始化的时候即是 Undefined。

经典一点的问题比如一个空数组,如何判定为空,由于 JavaScript 的对象类型,数组是对象的一种,他的原型是 Array 原型对象。

1
2
3
4
5
6
7
a = []
typeof(a)
"object"
a === null
false
a == null
false

需要使用原型对象的 .length 属性来判断数组中是否有值。

1
2
3
4
5
6
7
8
a = [1, 2, 3]
(3)[1, 2, 3]
a.length
3
a.length = 4
4
a
(4)[1, 2, 3, empty]

关于 JavaScript 执行机制和事件循环机制

JavaScript 引擎是 JavaScript 的解释器,类似于 Python,他们都是解释性语言。JavaScript 在最初被开发出来时,是为了用于浏览器的,所以注定了 JavaScript 是一个单线程的语言。那么当 JavaScript 需要处理两个事件的时候,如果一个事件是一个需要等待的事件,那么他会挂起等待的任务,先运行后面的任务。

这张图说明了,在主线程运行的时候,会生成堆栈,形成一个任务队列,然后主线程会循环访问这个任务队列,运行队列中的事件。

在堆中保存的是对象的数据,在栈中保存的是基本数据类型和函数执行时的内存信息。

栈中的代码会调用各种外部API,它们在任务队列中加入各种事件(onClick, onLoad, onDone),只要栈中的代码执行完毕(js引擎存在 monitoring process 进程,会持续不断的检查主线程执行栈是否为空),主线程就回去读取任务队列,在按顺序执行这些事件对应的回调函数。

也就是说主线程从任务队列中读取事件,这个过程是循环不断的,所以这种运行机制又成为 Event Loop (事件循环)。

作者:童欧巴
链接:https://juejin.im/post/5d2036106fb9a07eb15d76e9
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

通常来说,大部分的代码按照从上到下的顺序执行,并不会有什么问题,即是同步任务,但是当遇到例如加载图片这种操作的时候,可能在图片还没加载完成的情况下就使用了该图片,就会造成显示不全等问题,所以提出了异步 的思想。

同步任务就是在主线程上排队执行的任务,严格按照代码顺序执行(变量提升后),异步任务则不进入主线程,会在 event table 中注册函数,当达到执行条件后才会进入任务队列,然后在任务队列等待主线程执行。

宏任务和微任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
setTimeout(function() {
console.log('a')
});

new Promise(function(resolve) {
console.log('b');

for(var i =0; i <10000; i++) {
i ==99 && resolve();
}
}).then(function() {
console.log('c')
});

console.log('d');
// b
// d
// c
// a
复制代码
  1. 首先执行 script 下的宏任务,遇到 setTimeout ,将其放入宏任务的队列里。

  2. 遇到 Promisenew Promise 直接执行,打印b。

  3. 遇到 then 方法,是微任务,将其放到微任务的队列里。

  4. 遇到 console.log('d') ,直接打印。

  5. 本轮宏任务执行完毕,查看微任务,发现 then 方法里的函数,打印c。

  6. 本轮 event loop 全部完成。

  7. 下一轮循环,先执行宏任务,发现宏任务队列中有一个 setTimeout ,打印a。

作者:童欧巴
链接:https://juejin.im/post/5d2036106fb9a07eb15d76e9
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

通过宏任务和微任务的划分, 可以更加清晰的分辨出程序的运行顺序,

上面的文章里还有一个思考题。推荐看一看。同时也看看 https://juejin.im/post/5e5c7f6c518825491b11ce93 文章的理解,这篇文章更加详细,但是不太好懂。

闭包是什么

函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。

也就是说闭包的作用是将一个构造函数的环境封闭,内部函数可以访问当前构造函数的变量,这个是很容易理解的。

1
2
3
4
5
6
7
8
9
10
11
function makeFunc() {
var name = "Mozilla";

function displayName() {
alert(name);
}
return displayName;
}

var myFunc = makeFunc();
myFunc();

运行这段代码的效果和之前 init() 函数的示例完全一样。其中不同的地方(也是有意思的地方)在于内部函数 displayName() 在执行前,从外部函数返回。

原因在于,JavaScript中的函数会形成了闭包。 闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。在本例子中, myFunc 是执行 makeFunc 时创建的 displayName 函数实例的引用。 displayName 的实例维持了一个对它的词法环境(变量 name 存在于其中)的引用。因此,当 myFunc 被调用时,变量 name 仍然可用,其值 Mozilla 就被传递到 alert 中。

1
2
3
4
5
6
7
8
9
10
11
function makeAdder(x) {
return function(y) {
return x + y;
};
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

在这个示例中,我们定义了 makeAdder(x) 函数,它接受一个参数 x ,并返回一个新的函数。返回的函数接受一个参数 y ,并返回 x+y 的值。

从本质上讲, makeAdder 是一个函数工厂 — 他创建了将指定的值和它的参数相加求和的函数。在上面的示例中,我们使用函数工厂创建了两个新函数 — 一个将其参数和 5 求和,另一个和 10 求和。

add5add10 都是闭包。它们共享相同的函数定义,但是保存了不同的词法环境。在 add5 的环境中, x 为 5。而在 add10 中, x 则为 10。

用闭包模拟私有方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
var Counter = (function() {
var privateCounter = 0;

function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */
var makeCounter = function() {
var privateCounter = 0;

function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
};

var Counter1 = makeCounter();
var Counter2 = makeCounter();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */

两个计数器具有独立性,引用的自己的词法作用域内的变量。与此同时并不能通过两个实例去访问私有属性和方法。

JavaScript的面向对象

面向对象三大特征:继承、多态、封装

当代码 new Foo(...) 执行时,会发生以下事情:

1. 一个继承自 Foo\.prototype 的新对象被创建。
2. 使用指定的参数调用构造函数 * Foo *,并将 this 绑定到新创建的对象。 new Foo 等同于 * new Foo * \(\) ,也就是没有指定参数列表,* Foo * 不带任何参数调用的情况。
3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function myNew(func, ...args) {
const obj = {}; // 新建一个空对象
const result = func.call(obj, ...args); // 执行构造函数
obj.__proto__ = func.prototype; // 设置原型链

// 注意如果原构造函数有Object类型的返回值,包括Functoin, Array, Date, RegExg, Error
// 那么应该返回这个返回值
const isObject = typeof result === 'object' && result !== null;
const isFunction = typeof result === 'function';
if (isObject || isFunction) {
return result;
}

// 原构造函数没有Object类型的返回值,返回我们的新对象
return obj;
}

function Puppy(age) {
this.puppyAge = age;
}

Puppy.prototype.say = function() {
console.log("汪汪汪");
}

const myPuppy3 = myNew(Puppy, 2);

console.log(myPuppy3.puppyAge); // 2
console.log(myPuppy3.say()); // 汪汪汪

作者: 蒋鹏飞
链接: https: //juejin.im/post/5e50e5b16fb9a07c9a1959af
来源: 掘金
著作权归作者所有。 商业转载请联系作者获得授权, 非商业转载请注明出处。

继承

ES6 中可以直接使用 class 关键字表示一个类,ES5 中则是使用函数对象和原型上的内置方法实现。

1
2
3
4
5
6
7
8
9
10
class Polygon {
constructor() {
this.name = "Polygon";
}
}

const poly1 = new Polygon();

console.log(poly1.name);
// expected output: "Polygon"

如果不指定构造方法,则使用默认构造函数。对于基类,默认构造函数是:

1
constructor() {}

对于派生类,默认构造函数是:

1
2
3
constructor(...args) {
super(...args);
}

super 不仅仅是一个标识符表示当前对象的父对象,当它作为一个函数的时候则代表父对象的构造函数。

通过 extends 命令声明该类是父类的子类。如果使用了 extends 就必须使用 super() 在构造函数中初始化父类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Square extends Polygon {
constructor(length) {
// Here, it calls the parent class' constructor with lengths
// provided for the Polygon's width and height
super(length, length);
// Note: In derived classes, super() must be called before you
// can use 'this'. Leaving this out will cause a reference error.
this.name = 'Square';
}

get area() {
return this.height * this.width;
}
}

在之前的原型链中说过,构造函数是实现继承关系的,在构造函数内部会将两个类通过原型链链接。

参考 https://juejin.im/post/5a96d78ef265da4e9311b4d8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
class Father {
constructor(a = 'a', b = 'b') {
this.a = a
this.b = b

}
methodA() {
console.log('父类方法A')
}
}

function FatherFun(a = 'a', b = 'b') {
this.a = a
this.b = b
FatherFun.prototype.methodA = function() {
console.log('父类方法A')
}
}

// 原型链继承,通过new父类生成一个实例,将这个实例作为子类的原型。
function son() {
this.c = 'c'
// 这里prototype不管用,不能继承到父类的属性和方法。
// this.prototype = new FatherFun()
// 原型方法和属性查询是通过 __proto__ 向上查询的。
this.__proto__ = new FatherFun()
}
let son1 = new son()
console.log(son1.a)
son1.methodA()
// son {c: "c"}
// c: "c"
// __proto__: FatherFun
// a: "a"
// b: "b"
// __proto__:
// methodA: ƒ ()
// constructor: ƒ FatherFun(a = 'a', b = 'b')
// __proto__: Object
// 也就是说原型链继承就是通过new方法继承。
// new 方法的实现原理如下
function polyNew(source, ...arg) {
// 创建一个空的简单JavaScript对象(即{})
let newObj = {};
// 链接该对象(即设置该对象的构造函数)到另一个对象
Object.setPrototypeOf(newObj, source.prototype);
// 将步骤1新创建的对象作为this的上下文 ;
const resp = source.apply(newObj, arg);
// 判断该函数返回值是否是对象
if (Object.prototype.toString.call(resp) === "[object Object]") {
// 如果该函数返回对象,那用返回的这个对象作为返回值。
return resp
} else {
// 如果该函数没有返回对象,则返回newObj。
return newObj
}
}

// 构造继承
// function constrSon() {
// Father.call(this, 'a', 'b')
// this.c = 'c'
// }
// ES6 环境下会报错 TypeError: Class constructor Father cannot be invoked without 'new'
// 这是因为 Father 通过 class 定义,构造函数需要通过 new 方法调用。如果使用原来的function对象方法构造即可。
// 也就是说ES6环境其实已经没有构造继承了(大概。
// 用.call()和.apply()将父类构造函数引入子类函数
function constrSon() {
FatherFun.call(this)
this.c = 'c'
}
let constrSon1 = new constrSon()
console.log(constrSon1.a)
console.log(constrSon1.c)
try {
constrSon1.methodA()
} catch (err) {
console.log(err)
}
// constrSon {a: "a", b: "b", c: "c"}
// a: "a"
// b: "b"
// c: "c"
// __proto__:
// constructor: ƒ constrSon()
// __proto__: Object
// 通过call方法可以将父类的构造方法用于子类,使得子类拥有父类的构造属性,但是不能调用父类的原型方法。同样也不能实现函数复用,每个子类都有父类实例函数的副本
// 实例属性指的是在构造函数中定义的属性和方法,每个实例对象都会单独保存;原型属性就是通过实例对象的构造函数的原型的属性和方法,实例对象共享。

// https://juejin.im/post/5e91b01651882573716a9b23 这里小姐姐说的实例继承不知道是个啥。看样子大概是寄生继承
function mistSon() {
let o = Object.create(new Father)
// Object.create() 方法中需要传入一个对象而不是一个类。
// let o = Object.create(Father.prototype)
// 使用 Father.prototype 会跳过构造器,不能访问到原型的原型属性,但是可以访问到方法。
o.__proto__.methodB = function() {
console.log('B方法')
}
return o
}
let mistSon1 = mistSon()
try {
console.log(mistSon1.a)
mistSon1.methodA()
} catch (err) {
console.log(err)
}
mistSon1.methodB()
// mistSon1
// Father {}
// __proto__: Father
// a: "a"
// b: "b"
// methodB: ƒ ()
// __proto__: Object
// constructor: class Father
// methodA: ƒ methodA()
// __proto__: Object
// 从结果看来寄生继承实际就是父类的一个实例

//类继承+构造继承
function combinSon() {
// 这里用的是函数定义的类
FatherFun.call(this)
this.c = 'c'
}
combinSon.prototype = new FatherFun()
let combinSon1 = new combinSon()
console.log(combinSon1.a)
combinSon1.methodA()
// combinSon {a: "a", b: "b", c: "c"}
// a: "a"
// b: "b"
// c: "c"
// __proto__: FatherFun
// a: "a"
// b: "b"
// __proto__: Object
// methodA: ƒ ()
// constructor: ƒ FatherFun(a = 'a', b = 'b')
// __proto__: Object
// 原型上依然是有父类的属性存在。
// 通过new父类赋值给子类的原型形成原型链
// 再实例化子类,这样的子类实例可以访问父类的原型方法。而且父类的构造函数运行了两次。

// 寄生组合式继承的核心方法
function inherit(child, parent) {
// 继承父类的原型
const p = Object.create(parent.prototype)
// 重写子类的原型
child.prototype = p
// 重写被污染的子类的constructor
// 这里我也看不太懂。看浏览器结果的意思是使用子类作为父类的构造函数
p.constructor = child
}

function mistCombinSon() {
FatherFun.call(this)
this.c = 'c'
}
inherit(mistCombinSon, FatherFun)
let mistCombinSon1 = new mistCombinSon()
console.log(mistCombinSon1.a)
mistCombinSon1.methodA()
// mistCombinSon {a: "a", b: "b", c: "c"}
// a: "a"
// b: "b"
// c: "c"
// __proto__: FatherFun
// constructor: ƒ mistCombinSon()
// __proto__: Object
// methodA: ƒ ()
// constructor: ƒ FatherFun(a = 'a', b = 'b')
// __proto__: Object
// 父类原型上没有属性和方法,构造器被设置成了子类的构造函数。父类的方法被放到了父类的对象原型中,
// 子类继承了父类的属性和方法,同时,属性没有被创建在原型链上,因此多个子类不会共享同一个属性。
// 子类可以传递动态参数给父类!
// 父类的构造函数只执行了一次!
// * 原PO说 子类想要在原型上添加方法,必须在继承之后添加,否则将覆盖掉原有原型上的方法。这样的话 若是已经存在的两个类,就不好办了。
// 这里的大概意思说的就是如果需要向子类原型上添加方法,需要挨个给子类添加,不能直接向父类添加。

ES6 的继承

参考 https://es6.ruanyifeng.com/#docs/class-extends

首先最关键的是关键字 extends ,通过这个关键字表示子类继承父类。

使用 extends 在构造器中需要配套使用 super 方法运行父类的构造器,再运行子类后面的构造代码。

super 虽然代表了父类 A 的构造函数,但是返回的是子类 B 的实例,即 super 内部的 this 指的是 B 的实例,因此 super() 在这里相当于 A.prototype.constructor.call(this)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ES6 继承
class extendsSon extends Father {
constructor(a = 'a', b = 'b') {
super(a, b)
this.c = 'c'
}
}
let extendsSon1 = new extendsSon()
console.log(extendsSon1.a)
extendsSon1.methodA()
// extendsSon {a: "a", b: "b", c: "c"}
// a: "a"
// b: "b"
// c: "c"
// __proto__: Father
// constructor: class extendsSon
// __proto__:
// constructor: class Father
// methodA: ƒ methodA()
// __proto__: Object

// 感觉ES6的继承很完美嘛,父类构造函数只运行一次,属性也都是被子类继承,但是父类原型方法由原型链查询。修改也很方便。

封装

封装是通过设定私有变量和方法,让外部通过接口去调用方法。防止直接修改内部数据和方法。

闭包可以实现私有方法,通过返回含有多个接口的方法,来隐藏真正的方法。

私有变量也可以通过闭包的词法环境实现,在闭包的词法环境中,变量是会得以保存的,但是只有通过 this 绑定在原型属性上的变量会被外部通过原型属性访问,但是通过 varletconst 方法定义的变量是不能被外界访问到的,也就实现了私有属性。

ES6 中类(class)通过 static 关键字定义静态方法。不能在类的实例上调用静态方法,而应该通过类本身调用。这些通常是实用程序方法,例如创建或克隆对象的功能。

多态

多态是指在一个接口中对同一个行为,具有不同的表现形式。最常用的实现方法就是通过对父类传入不同的参数,使得多个子类的实例在访问父类的方法时呈现不同的结果。或者说在接口中通过对当前实例的区分来完成不同的行为。

JavaScript ES6 更新了什么?

详细的教程或者文档可以参考 阮一峰的教程Babel 的文档

在前端方面有相当多的规范,比如我们所使用的 HTML、CSS、JavaScript 都是有规范的,其中 HTML、CSS 的标准是由 W3C 设定的,还有 XML、XHTML 也是 W3C 制定的标准。JavaScript 的标准则是由 ECMAScript 所制定的,目前最新的规范是 ECMA-262 Edition 6,也被称为 ECMAScript 2015。这是一个大版本的更新,对比第五代的 ECMAScript 更新了很多东西。可以帮助开发者更加方便快捷的开发网页。

Babel 是一个 JavaScript 编译器。

Babel 作为 JavaScript 的编译器可以将 ES6 的代码转换成向后兼容的代码,防止出现浏览器兼容错误。

然后就是 JavaScript 的引擎,简单说就是浏览器的核心,目前使用较多的就是 Firefox 的 SpiderMonkeyChrome 的 V8 了,关于 JavaScript 引擎了解的不需要太多。还是关注 JavaScript 的语法标准和原理比较好。

let 命令

ES6 中新增了 letconst 两个命令,用来声明变量,取代 var ,不同的是 letconst 都不会产生变量提升,也就是说他们所声明的变量只能在命令所在的代码块有效。

暂时性死区

1
2
3
4
5
6
var tmp = 123;

if (true) {
tmp = 'abc'; // ReferenceError
let tmp;
}

使用 var 声明的 tmp 变量可以看做是一个全局变量,但是由于在 if 循环语句内又声明了一个 tmp ,即使 tmplet 声明之前赋值也是会直接报错的,如果在 let 声明之后赋值的话既是对 let 声明的 tmp 赋值。

ES6 明确规定,如果区块中存在 letconst 命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

1
2
3
4
5
6
7
8
9
10
11
if (true) {
// TDZ开始
tmp = 'abc'; // ReferenceError
console.log(tmp); // ReferenceError

let tmp; // TDZ结束
console.log(tmp); // undefined

tmp = 123;
console.log(tmp); // 123
}

由于暂时性死区的特性,在变量声明之前都是属于变量的死区,不管做什么操作都会报错。包括 typeof

let 也不支持重复声明,相比于 var 来说更加的严格。

新的作用域

详见 ES6-的块级作用域

作用域是用来确定变量的有效范围和调用顺序,在 ES5 中只有全局作用域和函数作用域,在 ES6 中通过 let 新增了块级作用域,尽可能的避免变量提升。

1
2
3
4
5
6
7
8
9
10
var tmp = new Date();

function f() {
console.log(tmp);
if (false) {
var tmp = 'hello world';
}
}

f(); // undefined

在没有块级作用域的时候,上面代码的原意是, if 代码块的外部使用外层的 tmp 变量,内部使用内层的 tmp 变量。但是,函数 f 执行后,输出结果为 undefined ,原因在于变量提升,导致内层的 tmp 变量覆盖了外层的 tmp 变量。

1
2
3
4
5
6
7
var s = 'hello';

for (var i = 0; i < s.length; i++) {
console.log(s[i]);
}

console.log(i); // 5

还有就是在使用 for 循环的时候使用 var 声明的变量会泄露成为全局变量。

1
2
3
4
5
6
7
function f1() {
let n = 5;
if (true) {
let n = 10;
}
console.log(n); // 5
}

在使用了 let 之后会产生块级作用域,使得变量仅在该区域内生效,

ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。

1
2
3
4
5
6
7
8
9
10
11
// 情况一
if (true) {
function f() {}
}

// 情况二
try {
function f() {}
} catch (e) {
// ...
}

上面两种函数声明,根据 ES5 的规定都是非法的。

但是,浏览器没有遵守这个规定,为了兼容以前的旧代码,还是支持在块级作用域之中声明函数,因此上面两种情况实际都能运行,不会报错。

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于 let ,在块级作用域之外不可引用。

函数表达式和函数声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 块级作用域内部的函数声明语句,建议不要使用
{
let a = 'secret';

function f() {
return a;
}
}

// 块级作用域内部,优先使用函数表达式
{
let a = 'secret';
let f = function() {
return a;
};
}

ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。

1
2
3
4
5
6
7
8
9
10
11
// 情况一
if (true) {
function f() {}
}

// 情况二
try {
function f() {}
} catch (e) {
// ...
}

但是由于浏览器为了兼容以前的代码,是会支持的,ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于 let ,在块级作用域之外不可引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function f() {
console.log('I am outside!');
}

(function() {
if (false) {
// 重复声明一次函数f
function f() {
console.log('I am inside!');
}
}

f();
}());

在 ES5 中,这个函数声明会被提升到块级作用域头部,并不会受 if 影响,但是在 ES6 中,会因为浏览器的行为而报错。

  • 允许在块级作用域内声明函数。
  • 函数声明类似于 var ,即会提升到全局作用域或函数作用域的头部。
  • 同时,函数声明还会提升到所在的块级作用域的头部。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 浏览器的 ES6 环境
function f() {
console.log('I am outside!');
}
(function() {
var f = undefined;
if (false) {
function f() {
console.log('I am inside!');
}
}

f();
}());
// Uncaught TypeError: f is not a function

所以在块级作用域内声明函数,最好是使用函数表达式而非函数声明。

const 命令

const 声明一个只读的常量。一旦声明,常量的值就不能改变。

1
2
3
4
5
const PI = 3.1415;
PI // 3.1415

PI = 3;
// TypeError: Assignment to constant variable.

const 命令需要在声明变量同时对变量进行初始化。只声明而不赋值的话也是会报错的。

constlet 一样,只会在声明的块级作用域内有效,存在暂时性死区,不会被变量提升。

const 实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针, const 只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。

1
2
3
4
5
6
7
8
const foo = {};

// 为 foo 添加一个属性,可以成功
foo.prop = 123;
foo.prop // 123

// 将 foo 指向另一个对象,就会报错
foo = {}; // TypeError: "foo" is read-only

如果需要一个彻底不能被修改的变量,不论是对象还是其他类型,可以使用 Object.freeze()

1
2
3
4
5
6
7
8
var constantize = (obj) => {
Object.freeze(obj);
Object.keys(obj).forEach((key, i) => {
if (typeof obj[key] === 'object') {
constantize(obj[key]);
}
});
};

通过循环遍历对象的属性可以保证对象内的属性也得到冻结。

顶层对象和全局对象

顶层对象和全局对象 详情就不写了,主要是为了解决前面对于两个对象的区分不严格的问题,达到浏览器和 Node 环境中的统一,

变量解构赋值

ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

以前,为变量赋值,只能直接指定值。

1
2
3
let a = 1;
let b = 2;
let c = 3;

ES6 允许写成下面这样。

1
let [a, b, c] = [1, 2, 3];

解构赋值的前提是他们的类型需要相同,所以在右边会使用 [] 来包裹值,如果等号左边比右边的值多的话,多的值会被赋值 undefined ,如果右边比左边多的话,多的值会被丢弃不用。

解构赋值允许指定默认值。

1
2
3
4
5
let [foo = true] = [];
foo // true

let [x, y = 'b'] = ['a']; // x='a', y='b'
let [x, y = 'b'] = ['a', undefined]; // x='a', y='b'

ES6 内部使用严格相等运算符( === ),判断一个位置是否有值。所以,只有当一个数组成员严格等于 undefined ,默认值才会生效。

然后就是对对象的解构赋值,也是同理的。只是写法上不太一样,需要指定目标对象属性值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let {
bar,
foo
} = {
foo: 'aaa',
bar: 'bbb'
};
foo // "aaa"
bar // "bbb"

let {
baz
} = {
foo: 'aaa',
bar: 'bbb'
};
baz // undefined

同样也是可以给对象指定默认值。

字符串相关

ES6 加强了对 Unicode 的支持,允许采用 \uxxxx 形式表示一个字符,其中 xxxx 表示字符的 Unicode 码点。

但是,这种表示法只限于码点在 \u0000 ~ \uFFFF 之间的字符。超出这个范围的字符,必须用两个双字节的形式表示。

1
2
3
4
5
"\uD842\uDFB7"
// "𠮷"

"\u20BB7"
// " 7"

ES6 对这一点做出了改进,只要将码点放入大括号,就能正确解读该字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
"\u{20BB7}"
// "𠮷"

"\u{41}\u{42}\u{43}"
// "ABC"

let hello = 123;
hell\ u {
6 F
} // 123

'\u{1F680}' === '\uD83D\uDE80'
// true

有了这种表示法之后,JavaScript 共有 6 种方法可以表示一个字符。

1
2
3
4
5
'\z' === 'z' // true
'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true
'\u{7A}' === 'z' // true

ES6 为字符串添加了遍历器接口(详见《Iterator》一章),使得字符串可以被 for...of 循环遍历。

1
2
3
4
5
6
for (let codePoint of 'foo') {
console.log(codePoint)
}
// "f"
// "o"
// "o"

然后是关于模板字符串的改进,让字符串中的变量可以更加方便的输出。

1
2
3
4
5
6
7
8
9
10
11
$('#result').append(
'There are <b>' + basket.count + '</b> ' +
'items in your basket, ' +
'<em>' + basket.onSale +
'</em> are on sale!'
);
$('#result').append(`
There are <b>${basket.count}</b> items
in your basket, <em>${basket.onSale}</em>
are on sale!
`);

模板字符串(template string)是增强版的字符串,用反引号(\ )标识。它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入 ${}` 变量。

如果使用模板字符串表示多行字符串,所有的空格和缩进都会被保留在输出之中。

ES6 还为原生的 String 对象,提供了一个 raw() 方法。该方法返回一个斜杠都被转义(即斜杠前面再加一个斜杠)的字符串,往往用于模板字符串的处理方法。

1
2
3
4
5
String.raw `Hi\n${2+3}!` 
// 实际返回 "Hi\\n5!",显示的是转义后的结果 "Hi\n5!"

String.raw `Hi\u000A!` ;
// 实际返回 "Hi\\u000A!",显示的是转义后的结果 "Hi\u000A!"

String.raw() 本质上是一个正常的函数,只是专用于模板字符串的标签函数。如果写成正常函数的形式,它的第一个参数,应该是一个具有 raw 属性的对象,且 raw 属性的值应该是一个数组,对应模板字符串解析后的值。

1
2
3
4
5
// `foo${1 + 2}bar` 
// 等同于
String.raw({
raw: ['foo', 'bar']
}, 1 + 2) // "foo3bar"

传统上,JavaScript 只有 indexOf 方法,可以用来确定一个字符串是否包含在另一个字符串中。ES6 又提供了三种新方法。

  • includes():返回布尔值,表示是否找到了参数字符串。
  • startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
  • endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。

这三个方法都支持第二个参数,表示开始搜索的位置。

1
2
3
4
5
let s = 'Hello world!';

s.startsWith('world', 6) // true
s.endsWith('Hello', 5) // true
s.includes('Hello', 6) // false

repeat 方法返回一个新字符串,表示将原字符串重复 n 次。

1
2
3
'x'.repeat(3) // "xxx"
'hello'.repeat(2) // "hellohello"
'na'.repeat(0) // ""

参数如果是小数,会被取整。

1
'na'.repeat(2.9) // "nana"

如果 repeat 的参数是负数或者 Infinity ,会报错。

1
2
3
4
'na'.repeat(Infinity)
// RangeError
'na'.repeat(-1)
// RangeError

但是,如果参数是 0 到-1 之间的小数,则等同于 0,这是因为会先进行取整运算。0 到-1 之间的小数,取整以后等于 -0repeat 视同为 0。

1
'na'.repeat(-0.9) // ""

参数 NaN 等同于 0。

1
'na'.repeat(NaN) // ""

如果 repeat 的参数是字符串,则会先转换成数字。

1
2
'na'.repeat('na') // ""
'na'.repeat('3') // "nanana"

正则表达式的改进

RegExp 构造函数创建了一个正则表达式对象,用于将文本与一个模式匹配。

在 ES5 中的 RegExp 构造函数中

1
2
3
4
5
var regex = new RegExp('xyz', 'i')
//等价于
var regex = /xyz/i
//等价于
var regex = new RegExp(/xyz/i)

RegExp 可以接受两个字符串作为参数,或者是一个完整的正则语句作为参数。

但是不能

1
RegExp(/xyz/, 'i')

不过在 ES6 中可以这样使用,如果前面的正则中有已经设定的修饰符,会被后面的第二个参数所覆盖。

感觉都不推荐使用,因为表达的意义太模糊了。还是直接使用完整的正则表达式比较好,意义明确,格式也不会因为字符串的原因而被取消高亮。而且覆盖修饰符感觉没有什么意义。

1
var regex = new RegExp(/xyz/i) // 意义更加清楚而且美观。

以前的字符串内置四种正则表达式方法, match()replace()search()split() ,用于对字符串进行匹配、替换、查询、切割操作。

ES6 将这四种方法从新定义在 RegExp 对象上,字符串还是可以使用,间接调用而已。

u 修饰符

ES6 新增了 u 修饰符,用来正确处理 Unicode 字符。ES5 不支持四个字节的 UTF-16编码,将其识别为两个字符。

u 修饰符对于大于 0xFFFF 的字符都有效果,所以在使用中文正则时是常用的。

ES6 新增了使用大括号表示 Unicode 字符

1
2
3
4
5
6
7
8
9
10
11
/\u{61}/.test('a') // false
/
\u {
61
}
/u.test('a') / / true
/
\u {
20 BB7
}
/u.test('𠮷') / / true

使用大括号表示 Unicode 字符必须使用 u 修饰符。

包括量词、占位符、预定义模式等大于 0xFFFF 的 Unicode 字符都需要使用 u 修饰符才能正确匹配。

设置了 u 修饰符后会在生成 RegExp 对象时设置新增的 unicode 属性为 true

1
2
3
4
5
const r1 = /hello/;
const r2 = /hello/u;

r1.unicode // false
r2.unicode // true

y 修饰符

ES6 新增 y 修饰符,叫做“粘连”(sticky)修饰符。

y 修饰符的作用与 g 修饰符类似,也是全局匹配,后一次匹配都从上一次匹配成功的下一个位置开始。不同之处在于, g 修饰符只要剩余位置中存在匹配就可,而 y 修饰符确保匹配必须从剩余的第一个位置开始,这也就是“粘连”的涵义。

1
2
3
4
5
6
7
8
9
var s = 'aaa_aa_a';
var r1 = /a+/g;
var r2 = /a+/y;

r1.exec(s) // ["aaa"]
r2.exec(s) // ["aaa"]

r1.exec(s) // ["aa"]
r2.exec(s) // null

上面代码有两个正则表达式,一个使用 g 修饰符,另一个使用 y 修饰符。这两个正则表达式各执行了两次,第一次执行的时候,两者行为相同,剩余字符串都是 _aa_a 。由于 g 修饰没有位置要求,所以第二次执行会返回结果,而 y 修饰符要求匹配必须从头部开始, _aa_a 第一个字符不匹配,所以返回 null

y 修饰符的设计本意,就是让头部匹配的标志 ^ 在全局匹配中都有效。

在某些场景下对比 g 修饰符来说更加严格,更容易发现错误。

同样 RegExp 也添加了 sticky 属性用来表示是否设置了 y 修饰符。

还有 sourceflags 属性用来表示正则表达式的正文和修饰符。

s 修饰符

正则表达式中,点( . )是一个特殊字符,代表任意的单个字符,但是有两个例外。一个是四个字节的 UTF-16 字符,这个可以用 u 修饰符解决;另一个是行终止符(line terminator character)。

所谓行终止符,就是该字符表示一行的终结。以下四个字符属于“行终止符”。

  • U+000A 换行符( \n
  • U+000D 回车符( \r
  • U+2028 行分隔符(line separator)
  • U+2029 段分隔符(paragraph separator)

ES2018 引入 s 修饰符,使得 . 可以匹配任意单个字符。

1
/foo.bar/s.test('foo\nbar') // true

这被称为 dotAll 模式,即点(dot)代表一切字符。所以,正则表达式还引入了一个 dotAll 属性,返回一个布尔值,表示该正则表达式是否处在 dotAll 模式。

1
2
3
4
5
6
7
const re = /foo.bar/s;
// 另一种写法
// const re = new RegExp('foo.bar', 's');

re.test('foo\nbar') // true
re.dotAll // true
re.flags // 's'

/s 修饰符和多行修饰符 /m 不冲突,两者一起使用的情况下, . 匹配所有字符,而 ^$ 匹配每一行的行首和行尾。

后行断言

“先行断言”指的是, x 只有在 y 前面才匹配,必须写成 /x(?=y)/ 。比如,只匹配百分号之前的数字,要写成 /\d+(?=%)/ 。“先行否定断言”指的是, x 只有不在 y 前面才匹配,必须写成 /x(?!y)/ 。比如,只匹配不在百分号之前的数字,要写成 /\d+(?!%)/

1
2
3
/\d+(?=%)/.exec('100% of US presidents have been male') // ["100"]
/
\d + ( ? ! % ) / .exec('that’s all 44 of them') // ["44"]

上面两个字符串,如果互换正则表达式,就不会得到相同结果。另外,还可以看到,“先行断言”括号之中的部分( (?=%) ),是不计入返回结果的。

“后行断言”正好与“先行断言”相反, x 只有在 y 后面才匹配,必须写成 /(?<=y)x/ 。比如,只匹配美元符号之后的数字,要写成 /(?<=\$)\d+/ 。“后行否定断言”则与“先行否定断言”相反, x 只有不在 y 后面才匹配,必须写成 /(?。比如,只匹配不在美元符号后面的数字,要写成 /(?。

1
2
3
/(?<=\$)\d+/.exec('Benjamin Franklin is on the $100 bill') // ["100"]
/
( ? < !\$)\ d + /.exec('its is worth about90') / / ["90"]

具名组匹配

在正则表达式中,通常使用圆括号进行组匹配,并返回匹配到的内容

1
2
3
4
5
6
const RE_DATE = /(\d{4})-(\d{2})-(\d{2})/;

const matchObj = RE_DATE.exec('1999-12-31');
const year = matchObj[1]; // 1999
const month = matchObj[2]; // 12
const day = matchObj[3]; // 31

组匹配的一个问题是,每一组的匹配含义不容易看出来,而且只能用数字序号(比如 matchObj[1] )引用,要是组的顺序变了,引用的时候就必须修改序号。

1
2
3
4
5
6
const RE_DATE = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;

const matchObj = RE_DATE.exec('1999-12-31');
const year = matchObj.groups.year; // 1999
const month = matchObj.groups.month; // 12
const day = matchObj.groups.day; // 31

上面代码中,“具名组匹配”在圆括号内部,模式的头部添加“问号 + 尖括号 + 组名”( ? ),然后就可以在 exec 方法返回结果的 groups 属性上引用该组名。同时,数字序号( matchObj[1] )依然有效。

具名组匹配等于为每一组匹配加上了 ID,便于描述匹配的目的。如果组的顺序变了,也不用改变匹配后的处理代码。

有了具名组匹配以后,可以使用解构赋值直接从匹配结果上为变量赋值。

1
2
3
4
5
6
7
8
let {
groups: {
one,
two
}
} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar');
one // foo
two // bar

字符串替换时,使用 $<组名> 引用具名组。

1
2
3
4
let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;

'2015-01-02'.replace(re, '$<day>/$<month>/$<year>')
// '02/01/2015'

上面代码中, replace 方法的第二个参数是一个字符串,而不是正则表达式。

replace 方法的第二个参数也可以是函数,该函数的参数序列如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'2015-01-02'.replace(re, (
matched, // 整个匹配结果 2015-01-02
capture1, // 第一个组匹配 2015
capture2, // 第二个组匹配 01
capture3, // 第三个组匹配 02
position, // 匹配开始的位置 0
S, // 原字符串 2015-01-02
groups // 具名组构成的一个对象 {year, month, day}
) => {
let {
day,
month,
year
} = groups;
return `${day}/${month}/${year}` ;
});

具名组匹配在原来的基础上,新增了最后一个函数参数:具名组构成的一个对象。函数内部可以直接对这个对象进行解构赋值。

如果要在正则表达式内部引用某个“具名组匹配”,可以使用 \k<组名> 的写法。

1
2
3
const RE_TWICE = /^(?<word>[a-z]+)!\k<word>$/;
RE_TWICE.test('abc!abc') // true
RE_TWICE.test('abc!ab') // false

数字引用( \1 )依然有效。

1
2
3
const RE_TWICE = /^(?<word>[a-z]+)!\1$/;
RE_TWICE.test('abc!abc') // true
RE_TWICE.test('abc!ab') // false

正则匹配索引

正则表达式返回的结果中一般会包含 index 属性,表示匹配到的字符串的开始位置,但是如果是组匹配的话, index 会是第一个匹配结果的开始位置。

现在有一个第三阶段提案,为 exec() 方法的返回结果加上 indices 属性,在这个属性上面可以拿到匹配的开始位置和结束位置。

1
2
3
4
5
6
const text = 'zabbcdef';
const re = /ab/;
const result = re.exec(text);

result.index // 1
result.indices // [ [1, 3] ]

上面例子中, exec() 方法的返回结果 result ,它的 index 属性是整个匹配结果( ab )的开始位置,而它的 indices 属性是一个数组,成员是每个匹配的开始位置和结束位置的数组。由于该例子的正则表达式没有组匹配,所以 indices 数组只有一个成员,表示整个匹配的开始位置是 1 ,结束位置是 3

注意,开始位置包含在匹配结果之中,但是结束位置不包含在匹配结果之中。比如,匹配结果为 ab ,分别是原始字符串的第1位和第2位,那么结束位置就是第3位。

如果正则表达式包含组匹配,那么 indices 属性对应的数组就会包含多个成员,提供每个组匹配的开始位置和结束位置。

1
2
3
4
5
const text = 'zabbcdef';
const re = /ab+(cd)/;
const result = re.exec(text);

result.indices // [ [ 1, 6 ], [ 4, 6 ] ]

上面例子中,正则表达式包含一个组匹配,那么 indices 属性数组就有两个成员,第一个成员是整个匹配结果( abbcd )的开始位置和结束位置,第二个成员是组匹配( cd )的开始位置和结束位置。

如果正则表达式包含具名组匹配, indices 属性数组还会有一个 groups 属性。该属性是一个对象,可以从该对象获取具名组匹配的开始位置和结束位置。

1
2
3
4
5
const text = 'zabbcdef';
const re = /ab+(?<Z>cd)/;
const result = re.exec(text);

result.indices.groups // { Z: [ 4, 6 ] }

String. prototype. matchAll()

这是字符串的一个方法,用于返回所有匹配,不过他返回的是一个遍历器。

1
2
3
4
5
6
7
8
9
10
11
const string = 'test1test2test3';

// g 修饰符加不加都可以
const regex = /t(e)(st(\d?))/g;

for (const match of string.matchAll(regex)) {
console.log(match);
}
// ["test1", "e", "st1", "1", index: 0, input: "test1test2test3"]
// ["test2", "e", "st2", "2", index: 5, input: "test1test2test3"]
// ["test3", "e", "st3", "3", index: 10, input: "test1test2test3"]

上面代码中,由于 string.matchAll(regex) 返回的是遍历器,所以可以用 for...of 循环取出。相对于返回数组,返回遍历器的好处在于,如果匹配结果是一个很大的数组,那么遍历器比较节省资源。

数值的扩展

ES6 提供了二进制和八进制数值的新的写法,分别用前缀 0b (或 0B )和 0o (或 0O )表示。

ES6 在 Number 对象上,新提供了 Number.isFinite()Number.isNaN() 两个方法。

Number.isFinite() 用来检查一个数值是否为有限的(finite),即不是 Infinity

1
2
3
4
5
6
7
8
Number.isFinite(15); // true
Number.isFinite(0.8); // true
Number.isFinite(NaN); // false
Number.isFinite(Infinity); // false
Number.isFinite(-Infinity); // false
Number.isFinite('foo'); // false
Number.isFinite('15'); // false
Number.isFinite(true); // false

Number.isNaN() 用来检查一个值是否为 NaN

1
2
3
4
5
6
7
Number.isNaN(NaN) // true
Number.isNaN(15) // false
Number.isNaN('15') // false
Number.isNaN(true) // false
Number.isNaN(9 / NaN) // true
Number.isNaN('true' / 0) // true
Number.isNaN('true' / 'true') // true

这两个方法对于非数值一律返回 false 。在调用时需要使用 Number 类,不然会使用以前的传统方法。

ES6 将全局方法 parseInt()parseFloat() ,移植到 Number 对象上面,行为完全保持不变。

1
2
3
4
5
6
7
// ES5的写法
parseInt('12.34') // 12
parseFloat('123.45#') // 123.45

// ES6的写法
Number.parseInt('12.34') // 12
Number.parseFloat('123.45#') // 123.45

Number.isInteger() 用来判断一个数值是否为整数。

1
2
Number.isInteger(25) // true
Number.isInteger(25.1) // false

JavaScript 内部,整数和浮点数采用的是同样的储存方法,所以 25 和 25. 0 被视为同一个值。

注意,由于 JavaScript 采用 IEEE 754 标准,数值存储为64位双精度格式,数值精度最多可以达到 53 个二进制位(1 个隐藏位与 52 个有效位)。如果数值的精度超过这个限度,第54位及后面的位就会被丢弃,这种情况下, Number.isInteger 可能会误判。

类似的情况还有,如果一个数值的绝对值小于 Number.MIN_VALUE (5E-324),即小于 JavaScript 能够分辨的最小值,会被自动转为 0。这时, Number.isInteger 也会误判。

1
2
Number.isInteger(5E-324) // false
Number.isInteger(5E-325) // true

上面代码中, 5E-325 由于值太小,会被自动转为0,因此返回 true

JavaScript 能够准确表示的整数范围在 -2^532^53 之间(不含两个端点),超过这个范围,无法精确表示这个值。

1
2
3
4
5
6
7
Math.pow(2, 53) // 9007199254740992

9007199254740992 // 9007199254740992
9007199254740993 // 9007199254740992

Math.pow(2, 53) === Math.pow(2, 53) + 1
// true

上面代码中,超出 2 的 53 次方之后,一个数就不精确了。

ES6 引入了 Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER 这两个常量,用来表示这个范围的上下限。

1
2
3
4
5
6
7
8
9
Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1
// true
Number.MAX_SAFE_INTEGER === 9007199254740991
// true

Number.MIN_SAFE_INTEGER === -Number.MAX_SAFE_INTEGER
// true
Number.MIN_SAFE_INTEGER === -9007199254740991
// true

Number.isSafeInteger() 则是用来判断一个整数是否落在这个范围之内。但是如果是一个计算式的话很可能返回一个错误的结果。因为在计算的时候就已经超出精度了。

新的 Math 方法

  • Math. trunc()

    Math.trunc 方法用于去除一个数的小数部分,返回整数部分。

  • Math. sign()

    Math.sign 方法用来判断一个数到底是正数、负数、还是零。对于非数值,会先将其转换为数值。

    它会返回五种值。

    • 参数为正数,返回 +1
    • 参数为负数,返回 -1
    • 参数为 0,返回 0
    • 参数为-0,返回 -0 ;
    • 其他值,返回 NaN
  • Math. cbrt()

    Math.cbrt 方法用于计算一个数的立方根。

  • Math. clz32()

    Math.clz32() 方法将参数转为 32 位无符号整数的形式,然后返回这个 32 位值里面有多少个前导 0。

  • Math. imul()

    Math.imul 方法返回两个数以 32 位带符号整数形式相乘的结果,返回的也是一个 32 位的带符号整数。

  • Math. fround()

    Math.fround 方法返回一个数的32位单精度浮点数形式。

  • Math. hypot()

    Math.hypot 方法返回所有参数的平方和的平方根。

  • Math. expm1()

    Math.expm1(x) 返回 ex - 1,即 Math.exp(x) - 1

  • Math. log1p()

    Math.log1p(x) 方法返回 1 + x 的自然对数,即 Math.log(1 + x) 。如果 x 小于-1,返回 NaN

  • Math. log10()

    Math.log10(x) 返回以 10 为底的 x 的对数。如果 x 小于 0,则返回 NaN。

  • Math. log2()

    Math.log2(x) 返回以 2 为底的 x 的对数。如果 x 小于 0,则返回 NaN。

ES6 新增了 6 个双曲函数方法。

  • Math.sinh(x) 返回 x 的双曲正弦(hyperbolic sine)
  • Math.cosh(x) 返回 x 的双曲余弦(hyperbolic cosine)
  • Math.tanh(x) 返回 x 的双曲正切(hyperbolic tangent)
  • Math.asinh(x) 返回 x 的反双曲正弦(inverse hyperbolic sine)
  • Math.acosh(x) 返回 x 的反双曲余弦(inverse hyperbolic cosine)
  • Math.atanh(x) 返回 x 的反双曲正切(inverse hyperbolic tangent)

ES2016 新增了一个指数运算符( ** )。

1
2
2 ** 2 // 4
2 ** 3 // 8

这个运算符的一个特点是右结合,而不是常见的左结合。多个指数运算符连用时,是从最右边开始计算的。

BigInt

JavaScript 所有数字都保存成 64 位浮点数,这给数值的表示带来了两大限制。一是数值的精度只能到 53 个二进制位(相当于 16 个十进制位),大于这个范围的整数,JavaScript 是无法精确表示的,这使得 JavaScript 不适合进行科学和金融方面的精确计算。二是大于或等于2的1024次方的数值,JavaScript 无法表示,会返回 Infinity

1
2
3
4
5
// 超过 53 个二进制位的数值,无法保持精度
Math.pow(2, 53) === Math.pow(2, 53) + 1 // true

// 超过 2 的 1024 次方的数值,无法表示
Math.pow(2, 1024) // Infinity

ES2020 引入了一种新的数据类型 BigInt(大整数),来解决这个问题。BigInt 只用来表示整数,没有位数的限制,任何位数的整数都可以精确表示。

1
2
3
4
5
6
7
8
const a = 2172141653n;
const b = 15346349309n;

// BigInt 可以保持精度
a * b // 33334444555566667777n

// 普通整数无法保持精度
Number(a) * Number(b) // 33334444555566670000

为了与 Number 类型区别,BigInt 类型的数据必须添加后缀 n

1
2
3
4
5
1234 // 普通整数
1234n // BigInt

// BigInt 的运算
1n + 2n // 3n

BigInt 同样可以使用各种进制表示,都要加上后缀 n

1
2
3
0b1101 n // 二进制
0o777 n // 八进制
0xFF n // 十六进制

BigInt 与普通整数是两种值,它们之间并不相等。

1
42n === 42 // false

typeof 运算符对于 BigInt 类型的数据返回 bigint

1
typeof 123n // 'bigint'

函数的扩展

参数默认值设置

ES6 之前,不能为函数参数提供默认值,只能通过对参数进行判断是否为空来为它赋值

1
2
3
4
5
6
7
8
function log(x, y) {
y = y || 'World';
console.log(x, y);
}

log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello World

但是如果 y 的赋值布尔值为 false 也是会被修改。

1
2
3
if (typeof y === 'undefined') {
y = 'World';
}

使用类型判断更加合理。

ES6 中允许为函数参数设置默认值

1
2
3
4
5
6
7
function log(x, y = 'World') {
console.log(x, y);
}

log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 写法一
function m1({
x = 0,
y = 0
} = {}) {
return [x, y];
}

// 写法二
function m2({
x,
y
} = {
x: 0,
y: 0
}) {
return [x, y];
}

上面两种写法都对函数的参数设定了默认值,区别是写法一函数参数的默认值是空对象,但是设置了对象解构赋值的默认值;写法二函数参数的默认值是一个有具体属性的对象,但是没有设置对象解构赋值的默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 函数没有参数的情况
m1() // [0, 0]
m2() // [0, 0]

// x 和 y 都有值的情况
m1({
x: 3,
y: 8
}) // [3, 8]
m2({
x: 3,
y: 8
}) // [3, 8]

// x 有值,y 无值的情况
m1({
x: 3
}) // [3, 0]
m2({
x: 3
}) // [3, undefined]

// x 和 y 都无值的情况
m1({}) // [0, 0];
m2({}) // [undefined, undefined]

m1({
z: 3
}) // [0, 0]
m2({
z: 3
}) // [undefined, undefined]

在上面的实例中,写法一设置默认值是空对象,但是对于函数内部调用时的解构是赋予了默认值的,写法二虽然设置了默认值,但是在函数内部,没有设置解构赋值的默认值,相当于只是声明了两个变量,于是在传入无值情况的时候回返回 undefined,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 例一
function f(x = 1, y) {
return [x, y];
}

f() // [1, undefined]
f(2) // [2, undefined]
f(, 1) // 报错
f(undefined, 1) // [1, 1]

// 例二
function f(x, y = 5, z) {
return [x, y, z];
}

f() // [undefined, 5, undefined]
f(1) // [1, 5, undefined]
f(1, , 2) // 报错
f(1, undefined, 2) // [1, 5, 2]

设置默认值也是与参数的位置有关系的,如果在第一个参数设置默认值,后面的参数不设置,就需要在使用的时候传入 undefined 作为占位。

指定了默认值以后,函数的 length 属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后, length 属性将失真。

1
2
3
(function(a) {}).length // 1
(function(a = 5) {}).length // 0
(function(a, b, c = 5) {}).length // 2

在使用了默认值后,函数的声明初始化阶段会形成一个单独的作用域,用于给参数赋值。

1
2
3
4
5
6
7
var x = 1;

function f(x, y = x) {
console.log(y);
}

f(2) // 2

上面代码中,参数 y 的默认值等于变量 x 。调用函数 f 时,参数形成一个单独的作用域。在这个作用域里面,默认值变量 x 指向第一个参数 x ,而不是全局变量 x ,所以输出是 2

再看下面的例子。

1
2
3
4
5
6
7
8
let x = 1;

function f(y = x) {
let x = 2;
console.log(y);
}

f() // 1

利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。

1
2
3
4
5
6
7
8
9
10
function throwIfMissing() {
throw new Error('Missing parameter');
}

function foo(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}

foo()
// Error: Missing parameter

上面代码的 foo 函数,如果调用的时候没有参数,就会调用默认值 throwIfMissing 函数,从而抛出一个错误。

从上面代码还可以看到,参数 mustBeProvided 的默认值等于 throwIfMissing 函数的运行结果(注意函数名 throwIfMissing 之后有一对圆括号),这表明参数的默认值不是在定义时执行,而是在运行时执行。如果参数已经赋值,默认值中的函数就不会运行。

另外,可以将参数默认值设为 undefined ,表明这个参数是可以省略的。

1
2
function foo(optional = undefined) {
···}

rest 参数

ES6 引入 rest 参数(形式为 ...变量名 ),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

1
2
3
4
5
6
7
8
9
10
11
function add(...values) {
let sum = 0;

for (var val of values) {
sum += val;
}

return sum;
}

add(2, 5, 3) // 10

箭头函数

ES6 允许使用“箭头”( => )定义函数。

1
2
3
4
5
6
var f = v => v;

// 等同于
var f = function(v) {
return v;
};

如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。

1
2
3
4
5
6
7
8
9
10
11
var f = () => 5;
// 等同于
var f = function() {
return 5
};

var sum = (num1, num2) => num1 + num2;
// 等同于
var sum = function(num1, num2) {
return num1 + num2;
};

箭头函数可以与变量解构结合使用。

1
2
3
4
5
6
7
8
9
const full = ({
first,
last
}) => first + ' ' + last;

// 等同于
function full(person) {
return person.first + ' ' + person.last;
}

箭头函数有几个使用注意点。

(1)函数体内的 this 对象,就是定义时所在的对象,而不是使用时所在的对象。

(2)不可以当作构造函数,也就是说,不可以使用 new 命令,否则会抛出一个错误。

(3)不可以使用 arguments 对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。

(4)不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。

上面四点中,第一点尤其值得注意。 this 对象的指向是可变的,但是在箭头函数中,它是固定的。

this 指向的固定化,并不是因为箭头函数内部有绑定 this 的机制,实际原因是箭头函数根本没有自己的 this ,导致内部的 this 就是外层代码块的 this 。正是因为它没有 this ,所以也就不能用作构造函数。

所以,箭头函数转成 ES5 的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ES6
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}

// ES5
function foo() {
var _this = this;

setTimeout(function() {
console.log('id:', _this.id);
}, 100);
}

尾调用和尾递归

尾调用指的是在函数的最后一步调用另一个函数。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。

这样的话对于内存的优化是很有帮助的。尾递归同理只是调用的是自己,递归是非常消耗内存的操作,但是如果是尾递归的话就只需要存在一个调用帧,即节省了内存也使得内存更加安全。

1
2
3
4
5
6
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}

factorial(5) // 120

上面代码是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度 O(n) 。

如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。

1
2
3
4
5
6
function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

还有一个比较著名的例子,就是计算 Fibonacci 数列,也能充分说明尾递归优化的重要性。

非尾递归的 Fibonacci 数列实现如下。

1
2
3
4
5
6
7
8
9
10
11
function Fibonacci(n) {
if (n <= 1) {
return 1
};

return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Fibonacci(10) // 89
Fibonacci(100) // 超时
Fibonacci(500) // 超时

尾递归优化过的 Fibonacci 数列实现如下。

1
2
3
4
5
6
7
8
9
10
11
function Fibonacci2(n, ac1 = 1, ac2 = 1) {
if (n <= 1) {
return ac2
};

return Fibonacci2(n - 1, ac2, ac1 + ac2);
}

Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity

由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 亦是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。这就是说,ES6 中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。

这么看的话尾递归相比较递归函数来说好处实在太多了,那么递归函数都可以改写成尾递归吗?在需要前者计算的递归函数中,比如阶乘,通过一个中间变量 total,取消了调用后的操作,将需要计算的部分交给递归后的函数,使得递归函数在返回后就可以释放空间。

柯里化

柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function currying(fn, n) {
return function(m) {
return fn.call(this, m, n);
};
}

function tailFactorial(n, total) {
if (n === 1) return total;
return tailFactorial(n - 1, n * total);
}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120

上面代码通过柯里化,将尾递归函数 tailFactorial 变为只接受一个参数的 factorial

currying 返回的是一个函数,并通过 call 方法调用传入的 fn 函数以及设定的 n 参数, m 参数是返回后由 factorial 函数传递,在这里是 5。

1
return fn.call(this, m, n);

回看 call 方法,目的是为了使 currying 继承 tailFactorial 函数,这里的 this 并不是返回的匿名函数,匿名函数没有 this 所以指的是 currying 函数,通过 call 方法调用实际需要使用的 tailFactorial 函数。

call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Function/call

通过 currying 函数返回的匿名函数由 factorial 接收,设定的 tailFactorial 中的 total 为 1,传入 n=5 ,返回 120。

上面使用柯里化设置 total 值,完全可以直接使用默认值设置。

1
2
3
4
5
6
function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}

factorial(5) // 120

还有一个柯里化的经典例题是实现 add(1)(2, 3)(4)() = 10 的效果

不管有几个括号,括号里几个参数,都只需要累加 。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function currying(fn) {
var allArgs = [];

return function next() {
var args = [].slice.call(arguments);

if (args.length > 0) {
allArgs = allArgs.concat(args);
return next;
} else {
return fn.apply(null, allArgs);
}
}
}
var add = currying(function() {
var sum = 0;
for (var i = 0; i < arguments.length; i++) {
sum += arguments[i];
}
return sum;
});

作者: 我是leon
链接: https: //juejin.im/post/5b561426518825195f499772
来源: 掘金
著作权归作者所有。 商业转载请联系作者获得授权, 非商业转载请注明出处。

在上面的代码中, currying 函数返回的是 next() 函数, currying 函数接收到的 fn 是后面给 add 赋值时传入的匿名函数,匿名函数的作用是将默认的参数 arguments 使用 for 循环相加返回累加和。 currying 的特性例如延迟计算、记忆参数,这是由 currying 函数中的 allArgsnext() 函数中通过判断参数个数,如果后面还有参数就将参数存入列表,后面是空括号 () 就调用 add 初始化时的匿名函数计算。

concat() 方法用于连接两个或多个数组。

  • 传入参数时,代码不执行输出结果,而是先记忆起来
  • 当传入空的参数时,代表可以进行真正的运算

比较多次接受的参数总数与函数定义时的入参数量,当接受参数的数量大于或等于被 Currying 函数的传入参数数量时,就返回计算结果,否则返回一个继续接受参数的函数。

而且由于 add 并没有在使用后被销毁,如果多次使用的话是算作一起的

add(1)(2, 3)(4)() 第一次运行返回 10,第二次就会返回 20.

尾递归优化的实现

再次回到尾递归上来,尾递归优化具体来说是浏览器实现,可能不同的浏览器会有不同的结果。我目前使用的 Chrome 内核的浏览器是可以支持尾递归优化的,你如果想试一下,可以使用之前的斐波那契数列函数,如果 100 不会卡死的话就说明是有的。

它的原理非常简单。尾递归之所以需要优化,原因是调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。怎么做可以减少调用栈呢?就是采用“循环”换掉“递归”。

下面是一个正常的递归函数。

1
2
3
4
5
6
7
8
9
10
function sum(x, y) {
if (y > 0) {
return sum(x + 1, y - 1);
} else {
return x;
}
}

sum(1, 100000)
// Uncaught RangeError: Maximum call stack size exceeded(…)

上面代码中, sum 是一个递归函数,参数 x 是需要累加的值,参数 y 控制递归次数。一旦指定 sum 递归 100000 次,就会报错,提示超出调用栈的最大次数。

蹦床函数(trampoline)可以将递归执行转为循环执行。

1
2
3
4
5
6
function trampoline(f) {
while (f && f instanceof Function) {
f = f();
}
return f;
}

尾递归优化是通过减少在递归的时候使用的调用栈,所有的循环都可以使用递归去实现,同样递归也是可以通过循环去实现的,在上面的 trampoline 函数中,通过将函数 f 执行后返回一个函数 f ,然后再去执行,就不是在函数里面调用函数,避免了递归。 while 里面的判断是函数 f 不为空且他是一个函数,目的就是将递归产生的下一个函数独立出来,使得上一个运算函数被当做普通函数在运行完成后销毁调用栈。

1
2
3
4
5
6
7
function sum(x, y) {
if (y > 0) {
return sum.bind(null, x + 1, y - 1);
} else {
return x;
}
}

上面代码中, sum 函数的每次执行,都会返回自身的另一个版本。

现在,使用蹦床函数执行 sum ,就不会发生调用栈溢出。

1
2
trampoline(sum(1, 100000))
// 100001

蹦床函数并不是真正的尾递归优化,下面的实现才是。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function tco(f) {
var value;
var active = false;
var accumulated = [];

return function accumulator() {
accumulated.push(arguments);
if (!active) {
active = true;
while (accumulated.length) {
value = f.apply(this, accumulated.shift());
}
active = false;
return value;
}
};
}

var sum = tco(function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
} else {
return x
}
});

sum(1, 100000)
// 100001

上面的实例代码是蹦床函数的升级版,同时也加入了柯里化的一些思想,通过 active 控制尾递归优化,通过 accumulated 来保存每一轮运行的参数。

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

回顾一下 apply() ,使用的作用和 call() 是一样的,但是 apply() 接受的是一个参数数组,这也是为什么这里 accumulated 是一个数组,然后用来保存 arguments 参数数组, pushshift 方法出来就是当前递归的两个参数,

使用 while 和其他感觉差别不大,但是由于 JavaScript 的单线程原因,我还真有点怕使用 if 等条件语句或者其他循环会导致语句并没有同步运行。

1
f.apply(this, accumulated.shift())

返回的 sum(x+1,y-1) , 都会再次通过 tco 函数,进行柯里化。之前的函数已经运行完毕就被销毁了,除了 tco 函数因为具有柯里化的特性。

这样就把一个递归函数改成了具有柯里化特性的循环函数。通过 apply() 的特性去调用实际的计算函数。返回的结果由 Value 来接收并返回,作为下一次的 tco 函数的 f 参数。

数组的扩展

扩展运算符

... 用于将一个数组转为用逗号分隔的参数序列。

1
2
3
4
5
6
7
8
console.log(...[1, 2, 3])
// 1 2 3

console.log(1, ...[2, 3, 4], 5)
// 1 2 3 4 5

[...document.querySelectorAll('div')]
// [<div>, <div>, <div>]

参数数列的意思是如果将它作为函数的参数,数组有几个就有几个参数传入。如果函数本身的参数比数组长度小的话多余的参数会被抛弃。

1
2
3
4
5
6
function push(array, ...items) {
array.push(...items);
}
a = [1, 2, 3]
push(a, 4, 5, 6, 7)
a // [1, 2, 3, 4, 5, 6, 7]

如果是在参数内容使用 ... 会将后续的参数一起接收。

1
2
3
4
5
function push(array, ...items, item) {
array.push(...items);
}

// Uncaught SyntaxError: Rest parameter must be last formal parameter

如果使用 ... 作为参数的话必须放到最后一位。

1
2
3
function f(v, w, x, y, z) {}
const args = [0, 1];
f(-1, ...args, 2, ...[3]);

在调用函数使用 ... 的时候也需要小心,注意数组的长度,最好也是作为最后一个参数组传入。如果传入的是空数组,则不会产生效果。

1
2
3
4
5
const arr = [
...(x > 0 ? ['a'] : []),
'b',
];
// ["a", "b"]

... 的优先级是比较低的,如果是表达式的话是会等表达式结束的。

可以用来替代 apply 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function currying(fn) {
var allArgs = [];

return function next() {
var args = [].slice.call(arguments);

if (args.length > 0) {
allArgs = allArgs.concat(args);
return next;
} else {
return fn.apply(null, allArgs);
}
}
}
var add = currying(function() {
var sum = 0;
for (var i = 0; i < arguments.length; i++) {
sum += arguments[i];
}
return sum;
});
作者: 我是leon
链接: https: //juejin.im/post/5b561426518825195f499772
来源: 掘金
著作权归作者所有。 商业转载请联系作者获得授权, 非商业转载请注明出处。

回顾之前的柯里化过程,通过 allArgs 数组保存每次传入参数后通过 fn 传入调用实际计算函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// ES5 的写法
function f(x, y, z) {
// ...
}
var args = [0, 1, 2];
f.apply(null, args);

// ES6的写法
function f(x, y, z) {
// ...
}
let args = [0, 1, 2];
f(...args);

不过改动的话只有 fn.apply() 这一句,具体区别的话可能不太大。对于一切工具函数需要传入数组作为参数时,使用 ... 可以更加的简洁方便。

1
2
3
4
5
6
// ES5 的写法
Math.max.apply(null, [14, 3, 77])
// ES6 的写法
Math.max(...[14, 3, 77])
// 等同于
Math.max(14, 3, 77);

这里的关键是 Math.max 方法接收的是多个参数,而不是数组参数,所以通过 apply() 方法调用,传入数组参数。因为 apply() 方法本来就是接收数组参数,作为函数的参数时。相当于是内部实现了 ... 方法。

阮一峰的这个入门里面还提了一个例子是数组相加,通过数组原生 push 方法的 apply()

1
2
3
4
5
6
7
8
9
// ES5的 写法
var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
Array.prototype.push.apply(arr1, arr2);

// ES6 的写法
let arr1 = [0, 1, 2];
let arr2 = [3, 4, 5];
arr1.push(...arr2);

这个方法和原生的 concat() 方法不一样,因为 concat() 方法是返回一个新的数组,原来的两个数组都不会被修改。使用 push.apply() 是为了节省内存空间避免新生成数组。

然后就是数组的深拷贝,简单的 a1=a2 只是浅拷贝,ES5 环境下会使用 concat() 方法,返回 a1 的副本。使用扩展运算符就可以直接 a2=[...a1] 或者 [...a2]=a1 ,虽然后面这种写法有点反人类,不过之前的函数调用就是这样。

1
2
3
function push(array, ...items) {
array.push(...items);
}

在函数初始化的时候将后续的参数都赋值给 items

同理,如果是多个参数赋值,需要将扩展运算符放在最后一位

1
2
const [a, b, c, d, ...e] = [1, 2, 3, 4, 5, 6, 7]
e // [5,6,7]

使用 ... 也可以将字符串变成数组,支持四个字节的 Unicode 字符解析。

1
2
[...'hello']
// [ "h", "e", "l", "l", "o" ]

背后的原理应该是 iterator 这么说的话可迭代的变量应该也是都可以使用扩展运算符的。

1
2
3
4
5
6
7
let map = new Map([
[1, 'one'],
[2, 'two'],
[3, 'three'],
]);

let arr = [...map.keys()]; // [1, 2, 3]

Generator 函数运行后,返回一个遍历器对象,因此也可以使用扩展运算符。

1
2
3
4
5
6
7
const go = function*() {
yield 1;
yield 2;
yield 3;
};

[...go()] // [1, 2, 3]

上面代码中,变量 go 是一个 Generator 函数,执行后返回的是一个遍历器对象,对这个遍历器对象执行扩展运算符,就会将内部遍历得到的值,转为一个数组。

Array. from()

Array.from 方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。

只要是部署了 Iterator 接口的数据结构, Array.from 都能将其转为数组。

1
2
3
4
5
Array.from('hello')
// ['h', 'e', 'l', 'l', 'o']

let namesSet = new Set(['a', 'b'])
Array.from(namesSet) // ['a', 'b']

扩展运算符背后调用的是遍历器接口( Symbol.iterator ),如果一个对象没有部署这个接口,就无法转换。 Array.from 方法还支持类似数组的对象。所谓类似数组的对象,本质特征只有一点,即必须有 length 属性。因此,任何有 length 属性的对象,都可以通过 Array.from 方法转为数组,而此时扩展运算符就无法转换。

对于还没有部署该方法的浏览器,可以用 Array.prototype.slice 方法替代。

1
2
3
const toArray = (() =>
Array.from ? Array.from : obj => [].slice.call(obj)
)();

Array.from 还可以接受第二个参数,作用类似于数组的 map 方法,用来对每个元素进行处理,将处理后的值放入返回的数组。

1
2
3
4
5
Array.from(arrayLike, x => x * x);
// 等同于
Array.from(arrayLike).map(x => x * x);
Array.from([1, 2, 3], (x) => x * x)
// [1, 4, 9]

Array. of()

Array.of 基本上可以用来替代 Array()new Array() ,并且不存在由于参数不同而导致的重载。它的行为非常统一。

1
2
3
4
Array.of() // []
Array.of(undefined) // [undefined]
Array.of(1) // [1]
Array.of(1, 2) // [1, 2]

copyWithin()

数组实例的 copyWithin() 方法,在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法,会修改当前数组。

1
Array.prototype.copyWithin(target, start = 0, end = this.length)
  • target(必需):从该位置开始替换数据。如果为负值,表示倒数。
  • start(可选):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
  • end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示从末尾开始计算。

所以需要复制的数组段是[start, end),前闭后开,从 target 开始复制。

find() 和 findInedx()

数组实例的 find 方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为 true 的成员,然后返回该成员。如果没有符合条件的成员,则返回 undefined

1
2
[1, 4, -5, 10].find((n) => n < 0)
// -5
1
2
3
[1, 5, 10, 15].find(function(value, index, arr) {
return value > 9;
}) // 10

上面代码中, find 方法的回调函数可以接受三个参数,依次为当前的值、当前的位置和原数组。

数组实例的 findIndex 方法的用法与 find 方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回 -1

1
2
3
[1, 5, 10, 15].findIndex(function(value, index, arr) {
return value > 9;
}) // 2

这两个方法都可以接受第二个参数,用来绑定回调函数的 this 对象。

1
2
3
4
5
6
7
8
function f(v) {
return v > this.age;
}
let person = {
name: 'John',
age: 20
};
[10, 12, 26, 15].find(f, person); // 26

另外,这两个方法都可以发现 NaN ,弥补了数组的 indexOf 方法的不足。

NaN 属性是代表非数字值的特殊值。该属性用于指示某个值不是数字。可以把 Number 对象设置为该值,来指示其不是数字值。

提示:请使用 isNaN() 全局函数来判断一个值是否是 NaN 值。

fill()

fill 方法使用给定值,填充一个数组。

1
2
3
4
5
['a', 'b', 'c'].fill(7)
// [7, 7, 7]

new Array(3).fill(7)
// [7, 7, 7]

fill 方法还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置。

可以在声明数组时对数组进行初始化。

entries()keys()values()

ES6 提供三个新的方法—— entries()keys()values() ——用于遍历数组。它们都返回一个遍历器对象(详见《Iterator》一章),可以用 for...of 循环进行遍历,唯一的区别是 keys() 是对键名的遍历、 values() 是对键值的遍历, entries() 是对键值对的遍历。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (let index of ['a', 'b'].keys()) {
console.log(index);
}
// 0
// 1

for (let elem of ['a', 'b'].values()) {
console.log(elem);
}
// 'a'
// 'b'

for (let [index, elem] of ['a', 'b'].entries()) {
console.log(index, elem);
}
// 0 "a"
// 1 "b"

includes()

includes() 方法函数与 indexOf() 方法效果差不多,但是更加具有语义化,而且可以返回出现位置,同时内部使用了 === 可以判断 NaN

1
2
3
[1, 2, 3].includes(2) // true
[1, 2, 3].includes(4) // false
[1, 2, NaN].includes(NaN) // true

该方法的第二个参数表示搜索的起始位置,默认为 0 。如果第二个参数为负数,则表示倒数的位置,如果这时它大于数组长度(比如第二个参数为 -4 ,但数组长度为 3 ),则会重置为从 0 开始。

flat(), flatMap()

数组的成员有时还是数组, Array.prototype.flat() 用于将嵌套的数组“拉平”,变成一维的数组。该方法返回一个新数组,对原数据没有影响。

如果原数组有空位, flat() 方法会跳过空位。

1
2
[1, 2, [3, 4]].flat()
// [1, 2, 3, 4]

flatMap() 方法对原数组的每个成员执行一个函数(相当于执行 Array.prototype.map() ),然后对返回值组成的数组执行 flat() 方法。该方法返回一个新数组,不改变原数组。

1
2
3
// 相当于 [[2, 4], [3, 6], [4, 8]].flat()
[2, 3, 4].flatMap((x) => [x, x * 2])
// [2, 4, 3, 6, 4, 8]

数组空位的处理

ES6 则是明确将空位转为 undefined

Array. prototype. sort() 排序稳定性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const arr = [
'peach',
'straw',
'apple',
'spork'
];

const stableSorting = (s1, s2) => {
if (s1[0] < s2[0]) return -1;
return 1;
};

arr.sort(stableSorting)
// ["apple", "peach", "straw", "spork"]

上面代码对数组 arr 按照首字母进行排序。排序结果中, strawspork 的前面,跟原始顺序一致,所以排序算法 stableSorting 是稳定排序。

1
2
3
4
5
6
7
const unstableSorting = (s1, s2) => {
if (s1[0] <= s2[0]) return -1;
return 1;
};

arr.sort(unstableSorting)
// ["apple", "peach", "spork", "straw"]

上面代码中,排序结果是 sporkstraw 前面,跟原始顺序相反,所以排序算法 unstableSorting 是不稳定的。

对象的扩展

属性的简写方法

1
2
3
4
5
6
7
8
9
10
const foo = 'bar';
const baz = {
foo
};
baz // {foo: "bar"}

// 等同于
const baz = {
foo: foo
};

通过大括号包括的属性如果没有通过冒号赋值则会寻找同名的变量并赋值。

对象属性定义

1
2
3
4
5
// 方法一
obj.foo = true;

// 方法二
obj['a' + 'bc'] = 123;

上面代码的方法一是直接用标识符作为属性名,方法二是用表达式作为属性名,这时要将表达式放在方括号之内。

属性名表达式与简洁表示法,不能同时使用。

1
2
3
4
5
6
7
8
9
10
11
12
// 报错
const foo = 'bar';
const bar = 'abc';
const baz = {
[foo]
};

// 正确
const foo = 'bar';
const baz = {
[foo]: 'abc'
};

方法的 name 属性

函数的 name 属性,返回函数名。对象方法也是函数,因此也有 name 属性。

1
2
3
4
5
6
7
const person = {
sayName() {
console.log('hello!');
},
};

person.sayName.name // "sayName"

上面代码中,方法的 name 属性返回函数名(即方法名)。

如果对象的方法使用了取值函数( getter )和存值函数( setter ),则 name 属性不是在该方法上面,而是该方法的属性的描述对象的 getset 属性上面,返回值是方法名前加上 getset

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
get foo() {},
set foo(x) {}
};

obj.foo.name
// TypeError: Cannot read property 'name' of undefined

const descriptor = Object.getOwnPropertyDescriptor(obj, 'foo');

descriptor.get.name // "get foo"
descriptor.set.name // "set foo"

属性的可枚举性和遍历

对象的每个属性都有一个描述对象(Descriptor),用来控制该属性的行为。 Object.getOwnPropertyDescriptor 方法可以获取该属性的描述对象。

描述对象的属性中的 enumerable 属性称为“可枚举性“,如果该属性的 enumerablefalse 的话,就表示该属性不可被枚举,如果遇到 for...in Object.keys() JSON.stringify() Object.assign() 这类方法时,会跳过该属性。

这个属性在 JavaScript 内部实现时也是会经常用到的,可以用来规避一些隐性的属性被遍历。例如原生的方法或者数组的 length 属性等。另外,ES6 规定,所有 Class 的原型的方法都是不可枚举的。

对象内的方法也算是对象的属性之一。

1
2
3
4
5
a = {
method() {}
}
Object.getOwnPropertyDescriptor(a, 'method')
// {value: ƒ, writable: true, enumerable: true, configurable: true}

super 关键字

我们知道, this 关键字总是指向函数所在的当前对象,ES6 又新增了另一个类似的关键字 super ,指向当前对象的原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
const proto = {
foo: 'hello'
};

const obj = {
foo: 'world',
find() {
return super.foo;
}
};

Object.setPrototypeOf(obj, proto);
obj.find() // "hello"

注意, super 关键字表示原型对象时,只能用在对象的方法之中,用在其他地方都会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 报错
const obj = {
foo: super.foo
}

// 报错
const obj = {
foo: () => super.foo
// 是 foo: function(){} 的简写。
}

// 报错
const obj = {
foo: function() {
return super.foo
}
}

上面三种 super 的用法都会报错,因为对于 JavaScript 引擎来说,这里的 super 都没有用在对象的方法之中。第一种写法是 super 用在属性里面,第二种和第三种写法是 super 用在一个函数里面,然后赋值给 foo 属性。目前,只有对象方法的简写法可以让 JavaScript 引擎确认,定义的是对象的方法。

… 扩展运算符

对象也使用了 iterator 属性,可以用 ... 对其遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let {
x,
y,
...z
} = {
x: 1,
y: 2,
a: 3,
b: 4
};
x // 1
y // 2
z // { a: 3, b: 4 }
let o1 = {
a: 1
};
let o2 = {
b: 2
};
o2.__proto__ = o1;
let {
...o3
} = o2;
o3 // { b: 2 }
o3.a // undefined

上面代码中,对象o3复制了o2,但是只复制了o2自身的属性,没有复制它的原型对象o1的属性。

对象的扩展运算符( ... )用于取出参数对象的所有可遍历属性,拷贝到当前对象之中。

对象的扩展运算符等同于使用 Object.assign() 方法。

1
2
3
4
5
let aClone = {
...a
};
// 等同于
let aClone = Object.assign({}, a);

链判断运算符

编程实务中,如果读取对象内部的某个属性,往往需要判断一下该对象是否存在。比如,要读取 message.body.user.firstName ,安全的写法是写成下面这样。

1
2
3
4
const firstName = (message &&
message.body &&
message.body.user &&
message.body.user.firstName) || 'default';

或者使用三元运算符 ?: ,判断一个对象是否存在。

1
2
const fooInput = myForm.querySelector('input[name=foo]')
const fooValue = fooInput ? fooInput.value : undefined

这样的层层判断非常麻烦,因此 ES2020 引入了“链判断运算符”(optional chaining operator) ?. ,简化上面的写法。

1
2
const firstName = message?.body?.user?.firstName || 'default';
const fooValue = myForm.querySelector('input[name=foo]')?.value

上面代码使用了 ?. 运算符,直接在链式调用的时候判断,左侧的对象是否为 nullundefined 。如果是的,就不再往下运算,而是返回 undefined

链判断运算符有三种用法。

  • obj?.prop // 对象属性
  • obj?.[expr] // 同上
  • func?.(...args) // 函数或对象方法的调用

下面是这个运算符常见的使用形式,以及不使用该运算符时的等价形式。

1
2
3
4
5
6
7
8
9
10
11
12
a?.b
// 等同于
a == null ? undefined : a.b
a?. [x]
// 等同于
a == null ? undefined : a[x]
a?.b()
// 等同于
a == null ? undefined : a.b()
a?.()
// 等同于
a == null ? undefined : a()

还有一些注意事项参考 链判断运算符

Null 判断运算符

null 一般是指变量已声明但是没有初始化。

读取对象属性的时候,如果某个属性的值是 nullundefined ,有时候需要为它们指定默认值。常见做法是通过 || 运算符指定默认值。

1
2
3
const headerText = response.settings.headerText || 'Hello, world!';
const animationDuration = response.settings.animationDuration || 300;
const showSplashScreen = response.settings.showSplashScreen || true;

上面的三行代码都通过 || 运算符指定默认值,但是这样写是错的。开发者的原意是,只要属性的值为 nullundefined ,默认值就会生效,但是属性的值如果为空字符串或 false0 ,默认值也会生效。

为了避免这种情况,ES2020 引入了一个新的 Null 判断运算符 ?? 。它的行为类似 || ,但是只有运算符左侧的值为 nullundefined 时,才会返回右侧的值。

1
2
3
const headerText = response.settings.headerText ? ? 'Hello, world!';
const animationDuration = response.settings.animationDuration ? ? 300;
const showSplashScreen = response.settings.showSplashScreen ? ? true;

上面代码中,默认值只有在属性值为 nullundefined 时,才会生效。

1
const animationDuration = response.settings?.animationDuration ? ? 300;

上面代码中, response.settings 如果是 nullundefined ,就会返回默认值300。

可以使用该运算符判断配合 ?. 运算符是否变量为 null 或者 undefined 并为其设定默认值。

** ?? 有一个运算优先级问题,它与 &&|| 的优先级孰高孰低。现在的规则是,如果多个逻辑运算符一起使用,必须用括号表明优先级,否则会报错。**

对象的新方法

MDN 关于 JavaScript 内置对象的文档

新增的方法有

  • Object.is()

    它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。

需要注意的是这个方法比较的是值,类似于 ==

1
2
3
4
5
+0 === -0 //true
NaN === NaN // false

Object.is(+0, -0) // false
Object.is(NaN, NaN) // true
  • Object.assign()

    Object.assign 方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。

该方法之前也提到过,有同种效果的还包括 ... 扩展运算符、 concat() ,只是各自的使用场景不同,而且 assign() 的主要使用参数是对象,如果目标不是对象会将其先转为对象,这也是为什么可以用在数组上的原因(包装对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const target = {
a: 1,
b: 1
};

const source1 = {
b: 2,
c: 2
};
const source2 = {
c: 3
};

Object.assign(target, source1, source2);
target // {a:1, b:2, c:3}

而且需要注意的是 Object.assign() 方法实行的是浅拷贝,而且在遇到两个对象的属性有重名现象的会优先替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const obj1 = {
a: {
b: 1
}
};
const obj2 = Object.assign({}, obj1);

obj1.a.b = 2;
obj2.a.b // 2
const target = {
a: {
b: 'c',
d: 'e'
}
}
const source = {
a: {
b: 'hello'
}
}
Object.assign(target, source)
// { a: { b: 'hello' } }

Object.assign 只能进行值的复制,如果要复制的值是一个取值函数,那么将求值后再复制。

1
2
3
4
5
6
7
8
9
const source = {
get foo() {
return 1
}
};
const target = {};

Object.assign(target, source)
// { foo: 1 }
  • Object. getOwnPropertyDescriptors()

    ES5 的 Object.getOwnPropertyDescriptor() 方法会返回某个对象属性的描述对象(descriptor)。ES2017 引入了 Object.getOwnPropertyDescriptors() 方法,返回指定对象所有自身属性(非继承属性)的描述对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const obj = {
foo: 123,
get bar() {
return 'abc'
}
};

Object.getOwnPropertyDescriptors(obj)
// { foo:
// { value: 123,
// writable: true,
// enumerable: true,
// configurable: true },
// bar:
// { get: [Function: get bar],
// set: undefined,
// enumerable: true,
// configurable: true } }

该方法的引入目的,主要是为了解决 Object.assign() 无法正确拷贝 get 属性和 set 属性的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const source = {
set foo(value) {
console.log(value);
}
};

const target1 = {};
Object.assign(target1, source);

Object.getOwnPropertyDescriptor(target1, 'foo')
// { value: undefined,
// writable: true,
// enumerable: true,
// configurable: true }

const target2 = {};
Object.defineProperties(target2, Object.getOwnPropertyDescriptors(source));
Object.getOwnPropertyDescriptor(target2, 'foo')
// { get: undefined,
// set: [Function: set foo],
// enumerable: true,
// configurable: true }

另外, Object.getOwnPropertyDescriptors() 方法可以实现一个对象继承另一个对象。以前,继承另一个对象,常常写成下面这样。

1
2
3
4
const obj = {
__proto__: prot,
foo: 123,
};

ES6 规定 __proto__ 只有浏览器要部署,其他环境不用部署。如果去除 __proto__ ,上面代码就要改成下面这样。

1
2
3
4
5
6
7
8
9
10
const obj = Object.create(prot);
obj.foo = 123;

// 或者

const obj = Object.assign(
Object.create(prot), {
foo: 123,
}
);

有了 Object.getOwnPropertyDescriptors() ,我们就有了另一种写法。

1
2
3
4
5
6
const obj = Object.create(
prot,
Object.getOwnPropertyDescriptors({
foo: 123,
})
);

Object.getOwnPropertyDescriptors() 也可以用来实现 Mixin(混入)模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let mix = (object) => ({
with: (...mixins) => mixins.reduce(
(c, mixin) => Object.create(
c, Object.getOwnPropertyDescriptors(mixin)
), object)
});

// multiple mixins example
let a = {
a: 'a'
};
let b = {
b: 'b'
};
let c = {
c: 'c'
};
let d = mix(c).with(a, b);

d.c // "c"
d.b // "b"
d.a // "a"

Mixin 模式就是一些提供能够被一个或者一组子类简单继承功能的类, 意在重用其功能。

简单说混入模式就是 JavaScript 独特的继承,通过原型链继承父对象的功能。可以参考 https://zh.javascript.info/mixins

  • __proto__属性, Object. setPrototypeOf(), Object. getPrototypeOf()

    该属性没有写入 ES6 的正文,而是写入了附录,原因是 __proto__ 前后的双下划线,说明它本质上是一个内部属性,而不是一个正式的对外的 API,只是由于浏览器广泛支持,才被加入了 ES6。标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,而且新的代码最好认为这个属性是不存在的。因此,无论从语义的角度,还是从兼容性的角度,都不要使用这个属性,而是使用下面的 Object.setPrototypeOf() (写操作)、 Object.getPrototypeOf() (读操作)、 Object.create() (生成操作)代替。

  • Object. setPrototypeOf()

    Object.setPrototypeOf 方法的作用与 __proto__ 相同,用来设置一个对象的原型对象(prototype),返回参数对象本身。它是 ES6 正式推荐的设置原型对象的方法。

  • Object. getPrototypeOf()

    该方法与 Object.setPrototypeOf 方法配套,用于读取一个对象的原型对象。

  • Object. entries()

    Object.entries() 方法返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值对数组。

  • Object. fromEntries()

    Object.fromEntries() 方法是 Object.entries() 的逆操作,用于将一个键值对数组转为对象。

Symbol

Symbol 是 ES6 新 引入的原始数据类型,用来表示独一无二的值。

感觉并没有什么大的用处,只能说是更加的语义化,通过 Symbol 生成的变量可以保证是唯一的,可能在大型项目中很有用。

Symbol 函数可以接受一个字符串作为参数,表示对 Symbol 实例的描述,主要是为了在控制台显示,或者转为字符串时,比较容易区分。

1
2
3
4
5
6
7
8
let s1 = Symbol('foo');
let s2 = Symbol('bar');

s1 // Symbol(foo)
s2 // Symbol(bar)

s1.toString() // "Symbol(foo)"
s2.toString() // "Symbol(bar)"

Symbol 函数的参数只是表示对当前 Symbol 值的描述,因此相同参数的 Symbol 函数的返回值是不相等的。

通常情况,会将 Symbol 值作为对象的属性名,保证不会出现同名的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let mySymbol = Symbol();

// 第一种写法
let a = {};
a[mySymbol] = 'Hello!';

// 第二种写法
let a = {
[mySymbol]: 'Hello!'
};

// 第三种写法
let a = {};
Object.defineProperty(a, mySymbol, {
value: 'Hello!'
});

// 以上写法都得到同样结果
a[mySymbol] // "Hello!"

上面代码通过方括号结构和 Object.defineProperty ,将对象的属性名指定为一个 Symbol 值。

注意,Symbol 值作为对象属性名时,不能用点运算符。

1
2
3
4
5
6
const mySymbol = Symbol();
const a = {};

a.mySymbol = 'Hello!';
a[mySymbol] // undefined
a['mySymbol'] // "Hello!"

在对象的内部,使用 Symbol 值定义属性时,Symbol 值必须放在方括号之中。

Symbol 作为属性名,遍历对象的时候,该属性不会出现在 for...infor...of 循环中,也不会被 Object.keys()Object.getOwnPropertyNames()JSON.stringify() 返回。

但是,它也不是私有属性,有一个 Object.getOwnPropertySymbols() 方法,可以获取指定对象的所有 Symbol 属性名。该方法返回一个数组,成员是当前对象的所有用作属性名的 Symbol 值。

1
2
3
4
5
6
7
8
9
10
11
const obj = {};
let a = Symbol('a');
let b = Symbol('b');

obj[a] = 'Hello';
obj[b] = 'World';

const objectSymbols = Object.getOwnPropertySymbols(obj);

objectSymbols
// [Symbol(a), Symbol(b)]

另一个新的 API, Reflect.ownKeys() 方法可以返回所有类型的键名,包括常规键名和 Symbol 键名。

1
2
3
4
5
6
7
8
let obj = {
[Symbol('my_key')]: 1,
enum: 2,
nonEnum: 3
};

Reflect.ownKeys(obj)
// ["enum", "nonEnum", Symbol(my_key)]

Symbol 的方法

Symbol.for()

symbol.for() 方法可以重新使用同一个 Symbol 值,他会在已生成该 Symbol 值中搜索有没有参数一致的 Symbol 值,有就返回,没有就新建一个并注册到全局

普通的 Symbol() 方法生成的 Symbol 是不会注册到全局的

1
2
3
4
5
6
7
let s1 = Symbol('foo')
let s2 = Symbol.for('foo')
s1 === s2 //false
let s3 = Symbol('foo')
s1 === s3 //false
let s4 = Symbol.for('foo')
s2 === s4 //true

所以如果确认某一个 Symbol 值会被多次使用,需要用到 for() 方法才能使得该值被全局注册。不然是不会相等的。

Symbol.for()Symbol() 这两种写法,都会生成新的 Symbol。它们的区别是,前者会被登记在全局环境中供搜索,后者不会。 Symbol.for() 不会每次调用就返回一个新的 Symbol 类型的值,而是会先检查给定的 key 是否已经存在,如果不存在才会新建一个值。比如,如果你调用 Symbol.for("cat") 30 次,每次都会返回同一个 Symbol 值,但是调用 Symbol("cat") 30 次,会返回 30 个不同的 Symbol 值。

由于 Symbol() 写法没有登记机制,所以每次调用都会返回一个不同的值。

Symbol.keyFor() 方法返回一个已登记的 Symbol 类型值的 key

1
2
3
4
5
let s1 = Symbol.for("foo");
Symbol.keyFor(s1) // "foo"

let s2 = Symbol("foo");
Symbol.keyFor(s2) // undefined

通过 Symbol 来实现 Singleton 模式

Singleton 模式指的是调用一个类,任何时候返回的都是同一个实例。也叫单例模式

这个案例是首先设计的一个单例模式,如果 global.foo 不存在就 new 一个,然后通过 module.exports 绑定,在其他文件中加载目标模块时就会返回这个 global.foo ,但是在加载了该模块后, global._foo 是全局性的变量,加载后可以被修改。

为了保护模块内的全局变量不会被外部文件所修改,可以通过 Symbol 作为全局变量的变量标志符。

需要注意的是由于 symbol.for() 方法注册的变量标志符可以通过 symbol.for() 方法进行全局的搜索进而访问,所以还是可以修改。

Symbol 的属性

除了定义自己使用的 Symbol 值以外,ES6 还提供了 11 个内置的 Symbol 值,指向语言内部使用的方法。

这些方法相当于是使用 Symbol 重新设计了一下,比如 instanceof 方法实际调用的就是 Symbol.hasInstance 方法。

  • Symbol. hasInstance === instanceof

  • Symbol. isConcatSpreadable

    对象的 Symbol.isConcatSpreadable 属性等于一个布尔值,表示该对象用于 Array.prototype.concat() 时,是否可以展开。

  • Symbol. species

    对象的 Symbol.species 属性,指向一个构造函数。创建衍生对象时,会使用该属性。

  • Symbol. match

    对象的 Symbol.match 属性,指向一个函数。当执行 str.match(myObject) 时,如果该属性存在,会调用它,返回该方法的返回值。

  • Symbol. replace

    对象的 Symbol.replace 属性,指向一个方法,当该对象被 String.prototype.replace 方法调用时,会返回该方法的返回值。

  • Symbol. search

    对象的 Symbol.search 属性,指向一个方法,当该对象被 String.prototype.search 方法调用时,会返回该方法的返回值。

  • Symbol. split

    对象的 Symbol.split 属性,指向一个方法,当该对象被 String.prototype.split 方法调用时,会返回该方法的返回值。

  • Symbol. iterator

    对象的 Symbol.iterator 属性,指向该对象的默认遍历器方法。

  • Symbol. toPrimitive

    对象的 Symbol.toPrimitive 属性,指向一个方法。该对象被转为原始类型的值时,会调用这个方法,返回该对象对应的原始类型值。

  • Symbol. toStringTag

    对象的 Symbol.toStringTag 属性,指向一个方法。在该对象上面调用 Object.prototype.toString 方法时,如果这个属性存在,它的返回值会出现在 toString 方法返回的字符串之中,表示对象的类型。也就是说,这个属性可以用来定制 [object Object][object Array]object 后面的那个字符串。

  • Symbol. unscopables

    对象的 Symbol.unscopables 属性,指向一个对象。该对象指定了使用 with 关键字时,哪些属性会被 with 环境排除。

Set 和 Map

Set 和 Map 是数据结构

ES6 提供了新的数据结构 Set。它类似于数组,但是成员的值都是唯一的,没有重复的值。

Set 本身是一个构造函数,用来生成 Set 数据结构。

1
2
3
4
5
6
7
8
const s = new Set();

[2, 3, 5, 4, 5, 2, 2].forEach(x => s.add(x));

for (let i of s) {
console.log(i);
}
// 2 3 5 4
1
2
// 去除数组的重复成员
[...new Set(array)]

上面的方法也可以用于,去除字符串里面的重复字符。

1
2
[...new Set('ababbc')].join('')
// "abc"

向 Set 加入值的时候,不会发生类型转换,所以 5"5" 是两个不同的值。Set 内部判断两个值是否不同,使用的算法叫做“Same-value-zero equality”,它类似于精确相等运算符( === ),主要的区别是向 Set 加入值时认为 NaN 等于自身,而精确相等运算符认为 NaN 不等于自身。

1
2
3
4
5
6
let set = new Set();
let a = NaN;
let b = NaN;
set.add(a);
set.add(b);
set // Set {NaN}

上面代码向 Set 实例添加了两次 NaN ,但是只会加入一个。这表明,在 Set 内部,两个 NaN 是相等的。

对于对象来说,两个对象是不相等的。

Set 实例的属性和方法

Set 结构的实例有以下属性。

  • Set.prototype.constructor :构造函数,默认就是 Set 函数。
  • Set.prototype.size :返回 Set 实例的成员总数。

Set 实例的方法分为两大类:操作方法(用于操作数据)和遍历方法(用于遍历成员)。下面先介绍四个操作方法。

  • Set.prototype.add(value) :添加某个值,返回 Set 结构本身。
  • Set.prototype.delete(value) :删除某个值,返回一个布尔值,表示删除是否成功。
  • Set.prototype.has(value) :返回一个布尔值,表示该值是否为 Set 的成员。
  • Set.prototype.clear() :清除所有成员,没有返回值。

上面这些属性和方法的实例如下。

1
2
3
4
5
6
7
8
9
10
11
s.add(1).add(2).add(2);
// 注意2被加入了两次

s.size // 2

s.has(1) // true
s.has(2) // true
s.has(3) // false

s.delete(2);
s.has(2) // false

遍历操作

Set 结构的实例有四个遍历方法,可以用于遍历成员。

  • Set.prototype.keys() :返回键名的遍历器
  • Set.prototype.values() :返回键值的遍历器
  • Set.prototype.entries() :返回键值对的遍历器
  • Set.prototype.forEach() :使用回调函数遍历每个成员

需要特别指出的是, Set 的遍历顺序就是插入顺序。这个特性有时非常有用,比如使用 Set 保存一个回调函数列表,调用时就能保证按照添加顺序调用。

数组的 mapfilter 方法也可以间接用于 Set 了。

1
2
3
4
5
6
7
let set = new Set([1, 2, 3]);
set = new Set([...set].map(x => x * 2));
// 返回Set结构:{2, 4, 6}

let set = new Set([1, 2, 3, 4, 5]);
set = new Set([...set].filter(x => (x % 2) == 0));
// 返回Set结构:{2, 4}

因此使用 Set 可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let a = new Set([1, 2, 3]);
let b = new Set([4, 3, 2]);

// 并集
let union = new Set([...a, ...b]);
// Set {1, 2, 3, 4}

// 交集
let intersect = new Set([...a].filter(x => b.has(x)));
// set {2, 3}

// 差集
let difference = new Set([...a].filter(x => !b.has(x)));
// Set {1}

WeakSet

WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别。

首先,WeakSet 的成员只能是对象,而不能是其他类型的值。

1
2
3
4
5
const ws = new WeakSet();
ws.add(1)
// TypeError: Invalid value used in weak set
ws.add(Symbol())
// TypeError: invalid value used in weak set

其次,WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。

这是因为垃圾回收机制依赖引用计数,如果一个值的引用次数不为 0 ,垃圾回收机制就不会释放这块内存。结束使用该值之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。WeakSet 里面的引用,都不计入垃圾回收机制,所以就不存在这个问题。因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些对象在外部消失,它在 WeakSet 里面的引用就会自动消失。

由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。另外,由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历。

WeakSet 结构有以下三个方法。

  • WeakSet. prototype. add(value):向 WeakSet 实例添加一个新成员。
  • WeakSet. prototype. delete(value):清除 WeakSet 实例的指定成员。
  • WeakSet. prototype. has(value):返回一个布尔值,表示某个值是否在 WeakSet 实例之中。

Map

JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。

1
2
3
4
5
const data = {};
const element = document.getElementById('myDiv');

data[element] = 'metadata';
data['[object HTMLDivElement]'] // "metadata"

上面代码原意是将一个 DOM 节点作为对象 data 的键,但是由于对象只接受字符串作为键名,所以 element 被自动转为字符串 [object HTMLDivElement]

Map 数据结构可以使用其他类型的值作为键。

1
2
3
4
5
6
7
8
9
10
11
const m = new Map();
const o = {
p: 'Hello World'
};

m.set(o, 'content')
m.get(o) // "content"

m.has(o) // true
m.delete(o) // true
m.has(o) // false

上面代码使用 Map 结构的 set 方法,将对象 o 当作 m 的一个键,然后又使用 get 方法读取这个键,接着使用 delete 方法删除了这个键。

Map 构造函数接受数组作为参数,实际上执行的是下面的算法。

1
2
3
4
5
6
7
8
9
10
const items = [
['name', '张三'],
['title', 'Author']
];

const map = new Map();

items.forEach(
([key, value]) => map.set(key, value)
);

本质就是通过 iterator 接口循环数组内容并使用 map.set() 方法添加进 map 中。

不仅仅是数组,任何具有 Iterator 接口、且每个成员都是一个双元素的数组的数据结构(详见《Iterator》一章)都可以当作 Map 构造函数的参数。这就是说, SetMap 都可以用来生成新的 Map。

1
2
3
4
const map = new Map();

map.set(['a'], 555);
map.get(['a']) // undefined

上面代码的 setget 方法,表面是针对同一个键,但实际上这是两个不同的数组实例,内存地址是不一样的,因此 get 方法无法读取该键,返回 undefined

所以如果要取一个对象需要使用该对象的引用才行。

Map 的属性和方法

  • Map.size

    size 属性返回 Map 结构的成员总数。

  • Map.prototype.set(key, value)

    set 方法设置键名 key 对应的键值为 value ,然后返回整个 Map 结构。如果 key 已经有值,则键值会被更新,否则就新生成该键。

  • Map.prototype.get(key)

    get 方法读取 key 对应的键值,如果找不到 key ,返回 undefined

  • Map.prototype.has(key)

    has 方法返回一个布尔值,表示某个键是否在当前 Map 对象之中。

  • Map.prototype.delete(key)

    delete 方法删除某个键,返回 true 。如果删除失败,返回 false

  • Map.prototype.clear()

    clear 方法清除所有成员,没有返回值。

  • Map.prototype.keys() :返回键名的遍历器。

  • Map.prototype.values() :返回键值的遍历器。

  • Map.prototype.entries() :返回所有成员的遍历器。

  • Map.prototype.forEach() :遍历 Map 的所有成员。

与其他数据结构的互相转换

(1)Map 转为数组

前面已经提过,Map 转为数组最方便的方法,就是使用扩展运算符( ... )。

1
2
3
4
5
6
7
const myMap = new Map()
.set(true, 7)
.set({
foo: 3
}, ['abc']);
[...myMap]
// [ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]

(2)数组 转为 Map

将数组传入 Map 构造函数,就可以转为 Map。

1
2
3
4
5
6
7
8
9
10
11
12
new Map([
[true, 7],
[{
foo: 3
},
['abc']
]
])
// Map {
// true => 7,
// Object {foo: 3} => ['abc']
// }

(3)Map 转为对象

如果所有 Map 的键都是字符串,它可以无损地转为对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
function strMapToObj(strMap) {
let obj = Object.create(null);
for (let [k, v] of strMap) {
obj[k] = v;
}
return obj;
}

const myMap = new Map()
.set('yes', true)
.set('no', false);
strMapToObj(myMap)
// { yes: true, no: false }

如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名。

(4)对象转为 Map

对象转为 Map 可以通过 Object.entries()

1
2
3
4
5
let obj = {
"a": 1,
"b": 2
};
let map = new Map(Object.entries(obj));

此外,也可以自己实现一个转换函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
function objToStrMap(obj) {
let strMap = new Map();
for (let k of Object.keys(obj)) {
strMap.set(k, obj[k]);
}
return strMap;
}

objToStrMap({
yes: true,
no: false
})
// Map {"yes" => true, "no" => false}

(5)Map 转为 JSON

Map 转为 JSON 要区分两种情况。一种情况是,Map 的键名都是字符串,这时可以选择转为对象 JSON。

1
2
3
4
5
6
7
function strMapToJson(strMap) {
return JSON.stringify(strMapToObj(strMap));
}

let myMap = new Map().set('yes', true).set('no', false);
strMapToJson(myMap)
// '{"yes":true,"no":false}'

另一种情况是,Map 的键名有非字符串,这时可以选择转为数组 JSON。

1
2
3
4
5
6
7
8
9
function mapToArrayJson(map) {
return JSON.stringify([...map]);
}

let myMap = new Map().set(true, 7).set({
foo: 3
}, ['abc']);
mapToArrayJson(myMap)
// '[[true,7],[{"foo":3},["abc"]]]'

(6)JSON 转为 Map

JSON 转为 Map,正常情况下,所有键名都是字符串。

1
2
3
4
5
6
function jsonToStrMap(jsonStr) {
return objToStrMap(JSON.parse(jsonStr));
}

jsonToStrMap('{"yes": true, "no": false}')
// Map {'yes' => true, 'no' => false}

但是,有一种特殊情况,整个 JSON 就是一个数组,且每个数组成员本身,又是一个有两个成员的数组。这时,它可以一一对应地转为 Map。这往往是 Map 转为数组 JSON 的逆操作。

1
2
3
4
5
6
function jsonToMap(jsonStr) {
return new Map(JSON.parse(jsonStr));
}

jsonToMap('[[true,7],[{"foo":3},["abc"]]]')
// Map {true => 7, Object {foo: 3} => ['abc']}

Proxy

Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。

1
2
3
4
5
6
7
8
9
10
var obj = new Proxy({}, {
get: function(target, propKey, receiver) {
console.log( `getting ${propKey}!` );
return Reflect.get(target, propKey, receiver);
},
set: function(target, propKey, value, receiver) {
console.log( `setting ${propKey}!` );
return Reflect.set(target, propKey, value, receiver);
}
});

上面代码对一个空对象架设了一层拦截,重定义了属性的读取( get )和设置( set )行为。这里暂时先不解释具体的语法,只看运行结果。对设置了拦截行为的对象 obj ,去读写它的属性,就会得到下面的结果。

1
2
3
4
5
6
obj.count = 1
// setting count!
++obj.count
// getting count!
// setting count!
// 2

上面代码说明,Proxy 实际上重载(overload)了点运算符,即用自己的定义覆盖了语言的原始定义。

ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例。

1
var proxy = new Proxy(target, handler);

Proxy 对象的所有用法,都是上面这种形式,不同的只是 handler 参数的写法。其中, new Proxy() 表示生成一个 Proxy 实例, target 参数表示所要拦截的目标对象, handler 参数也是一个对象,用来定制拦截行为。

下面是另一个拦截读取属性行为的例子。

1
2
3
4
5
6
7
8
9
var proxy = new Proxy({}, {
get: function(target, propKey) {
return 35;
}
});

proxy.time // 35
proxy.name // 35
proxy.title // 35

下面是 Proxy 支持的拦截操作一览,一共 13 种。

  • get(target, propKey, receiver):拦截对象属性的读取,比如 proxy.fooproxy['foo']
  • set(target, propKey, value, receiver):拦截对象属性的设置,比如 proxy.foo = vproxy['foo'] = v ,返回一个布尔值。
  • has(target, propKey):拦截 propKey in proxy 的操作,返回一个布尔值。
  • deleteProperty(target, propKey):拦截 delete proxy[propKey] 的操作,返回一个布尔值。
  • ownKeys(target):拦截 Object.getOwnPropertyNames(proxy)Object.getOwnPropertySymbols(proxy)Object.keys(proxy)for...in 循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性。
  • getOwnPropertyDescriptor(target, propKey):拦截 Object.getOwnPropertyDescriptor(proxy, propKey) ,返回属性的描述对象。
  • defineProperty(target, propKey, propDesc):拦截 Object.defineProperty(proxy, propKey, propDesc)Object.defineProperties(proxy, propDescs) ,返回一个布尔值。
  • preventExtensions(target):拦截 Object.preventExtensions(proxy) ,返回一个布尔值。
  • getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy) ,返回一个对象。
  • isExtensible(target):拦截 Object.isExtensible(proxy) ,返回一个布尔值。
  • setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto) ,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
  • apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如 proxy(...args)proxy.call(object, ...args)proxy.apply(...)
  • construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如 new proxy(...args)

这也是为什么 Vue3. 0 要用 Proxy 重写数据绑定和数据劫持了,通过 Proxy 可以实现更多的拦截方式和更大的拦截范围。

Reflect

Reflect 对象与 Proxy 对象一样,也是 ES6 为了操作对象而提供的新 API。

其目的是将一些属于语言内部的方法逐渐从 Object 对象中抽离出来放入 Reflect 中,并且对于 Object 的方法进行优化,包括返回优化,使得方法在出错时返回 false 而不是直接抛出错误。

修改某些操作的方式,将其变成函数行为,而不是命令式。与 Proxy 对象配合,只要是 Proxy 对象的方法,就能在 Reflect 对象上找到对应的方法。这就让 Proxy 对象可以方便地调用对应的 Reflect 方法,完成默认行为,作为修改行为的基础。

Reflect 对象一共有 13 个静态方法。

  • Reflect. apply(target, thisArg, args)
  • Reflect. construct(target, args)
  • Reflect. get(target, name, receiver)
  • Reflect. set(target, name, value, receiver)
  • Reflect. defineProperty(target, name, desc)
  • Reflect. deleteProperty(target, name)
  • Reflect. has(target, name)
  • Reflect. ownKeys(target)
  • Reflect. isExtensible(target)
  • Reflect. preventExtensions(target)
  • Reflect. getOwnPropertyDescriptor(target, name)
  • Reflect. getPrototypeOf(target)
  • Reflect. setPrototypeOf(target, prototype)

Promise

Promise 是 ES6 所提出的异步编程的一种解决方案,所谓 Promise ,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。

Promise 对象有以下两个特点。

(1)对象的状态不受外界影响。 Promise 对象代表一个异步操作,有三种状态: pending (进行中)、 fulfilled (已成功)和 rejected (已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是 Promise 这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。

(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。 Promise 对象的状态改变,只有两种可能:从 pending 变为 fulfilled 和从 pending 变为 rejected 。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对 Promise 对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。

看之前的记录应该就行了。

Iterator

JavaScript 原有的表示“集合”的数据结构,主要是数组( Array )和对象( Object ),ES6 又添加了 MapSet 。这样就有了四种数据集合,用户还可以组合使用它们,定义自己的数据结构,比如数组的成员是 MapMap 的成员是对象。这样就需要一种统一的接口机制,来处理所有不同的数据结构。

遍历器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

Iterator 的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是 ES6 创造了一种新的遍历命令 for...of 循环,Iterator 接口主要供 for...of 消费。

Iterator 的遍历过程是这样的。

(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。

(2)第一次调用指针对象的 next 方法,可以将指针指向数据结构的第一个成员。

(3)第二次调用指针对象的 next 方法,指针就指向数据结构的第二个成员。

(4)不断调用指针对象的 next 方法,直到它指向数据结构的结束位置。

每一次调用 next 方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含 valuedone 两个属性的对象。其中, value 属性是当前成员的值, done 属性是一个布尔值,表示遍历是否结束。

下面是一个模拟 next 方法返回值的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var it = makeIterator(['a', 'b']);

it.next() // { value: "a", done: false }
it.next() // { value: "b", done: false }
it.next() // { value: undefined, done: true }

function makeIterator(array) {
var nextIndex = 0;
return {
next: function() {
return nextIndex < array.length ? {
value: array[nextIndex++],
done: false
} : {
value: undefined,
done: true
};
}
};
}

上面代码定义了一个 makeIterator 函数,它是一个遍历器生成函数,作用就是返回一个遍历器对象。对数组 ['a', 'b'] 执行这个函数,就会返回该数组的遍历器对象(即指针对象) it

指针对象的 next 方法,用来移动指针。开始时,指针指向数组的开始位置。然后,每次调用 next 方法,指针就会指向数组的下一个成员。第一次调用,指向 a ;第二次调用,指向 b

next 方法返回一个对象,表示当前数据成员的信息。这个对象具有 valuedone 两个属性, value 属性返回当前位置的成员, done 属性是一个布尔值,表示遍历是否结束,即是否还有必要再一次调用 next 方法。

通过 Iterator 接口就可以遍历大部分的数据结构。

ES6 规定,默认的 Iterator 接口部署在数据结构的 Symbol.iterator 属性,或者说,一个数据结构只要具有 Symbol.iterator 属性,就可以认为是“可遍历的”(iterable)。 Symbol.iterator 属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名 Symbol.iterator ,它是一个表达式,返回 Symbol 对象的 iterator 属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内(参见《Symbol》一章)。

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
[Symbol.iterator]: function() {
return {
next: function() {
return {
value: 1,
done: true
};
}
};
}
};

上面代码中,对象 obj 是可遍历的(iterable),因为具有 Symbol.iterator 属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有 next 方法。每次调用 next 方法,都会返回一个代表当前成员的信息对象,具有 valuedone 两个属性。

ES6 的有些数据结构原生具备 Iterator 接口(比如数组),即不用任何处理,就可以被 for...of 循环遍历。原因在于,这些数据结构原生部署了 Symbol.iterator 属性(详见下文),另外一些数据结构没有(比如对象)。凡是部署了 Symbol.iterator 属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象。

原生具备 Iterator 接口的数据结构如下。

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象

下面的例子是数组的 Symbol.iterator 属性。

1
2
3
4
5
6
7
let arr = ['a', 'b', 'c'];
let iter = arr[Symbol.iterator]();

iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }

默认情况下对象是没有默认部署 Iterator ,因为对象的属性遍历顺序是很难去默认设置的,需要开发者手动设置。

一个对象如果要具备可被 for...of 循环调用的 Iterator 接口,就必须在 Symbol.iterator 的属性上部署遍历器生成方法(原型链上的对象具有该方法也可)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class RangeIterator {
constructor(start, stop) {
this.value = start;
this.stop = stop;
}

[Symbol.iterator]() {
return this;
}

next() {
var value = this.value;
if (value < this.stop) {
this.value++;
return {
done: false,
value: value
};
}
return {
done: true,
value: undefined
};
}
}

function range(start, stop) {
return new RangeIterator(start, stop);
}

for (var value of range(0, 3)) {
console.log(value); // 0, 1, 2
}

遍历器对象除了具有 next 方法,还可以具有 return 方法和 throw 方法。如果你自己写遍历器对象生成函数,那么 next 方法是必须部署的, return 方法和 throw 方法是否部署是可选的。

return 方法的使用场合是,如果 for...of 循环提前退出(通常是因为出错,或者有 break 语句),就会调用 return 方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署 return 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function readLinesSync(file) {
return {
[Symbol.iterator]() {
return {
next() {
return {
done: false
};
},
return () {
file.close();
return {
done: true
};
}
};
},
};
}

一个数据结构只要部署了 Symbol.iterator 属性,就被视为具有 iterator 接口,就可以用 for...of 循环遍历它的成员。也就是说, for...of 循环内部调用的是数据结构的 Symbol.iterator 方法。

for...of 循环可以使用的范围包括数组、Set 和 Map 结构、某些类似数组的对象(比如 arguments 对象、DOM NodeList 对象)、后文的 Generator 对象,以及字符串。

Generator

Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。

执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

形式上,Generator 函数是一个普通函数,但是有两个特征。一是, function 关键字与函数名之间有一个星号;二是,函数体内部使用 yield 表达式,定义不同的内部状态( yield 在英语里的意思就是“产出”)。

1
2
3
4
5
6
7
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}

var hw = helloWorldGenerator();

上面代码定义了一个 Generator 函数 helloWorldGenerator ,它内部有两个 yield 表达式( helloworld ),即该函数有三个状态:hello,world 和 return 语句(结束执行)。

然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是上一章介绍的遍历器对象(Iterator Object)。

下一步,必须调用遍历器对象的 next 方法,使得指针移向下一个状态。也就是说,每次调用 next 方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个 yield 表达式(或 return 语句)为止。换言之,Generator 函数是分段执行的, yield 表达式是暂停执行的标记,而 next 方法可以恢复执行。

1
2
3
4
5
6
7
8
9
10
11
hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'world', done: false }

hw.next()
// { value: 'ending', done: true }

hw.next()
// { value: undefined, done: true }

yield 表达式

由于 Generator 函数返回的遍历器对象,只有调用 next 方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。 yield 表达式就是暂停标志。

遍历器对象的 next 方法的运行逻辑如下。

(1)遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值。

(2)下一次调用 next 方法时,再继续往下执行,直到遇到下一个 yield 表达式。

(3)如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值。

(4)如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined

需要注意的是, yield 表达式后面的表达式,只有当调用 next 方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

1
2
3
function* gen() {
yield 123 + 456;
}

上面代码中, yield 后面的表达式 123 + 456 ,不会立即求值,只会在 next 方法将指针移到这一句时,才会求值。

任意一个对象的 Symbol.iterator 方法,等于该对象的遍历器生成函数,调用该函数会返回该对象的一个遍历器对象。

由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的 Symbol.iterator 属性,从而使得该对象具有 Iterator 接口。

1
2
3
4
5
6
7
8
var myIterable = {};
myIterable[Symbol.iterator] = function*() {
yield 1;
yield 2;
yield 3;
};

[...myIterable] // [1, 2, 3]

上面代码中,Generator 函数赋值给 Symbol.iterator 属性,从而使得 myIterable 对象具有了 Iterator 接口,可以被 ... 运算符遍历了。

Generator 函数执行后,返回一个遍历器对象。该对象本身也具有 Symbol.iterator 属性,执行后返回自身。

1
2
3
4
5
6
7
8
function* gen() {
// some code
}

var g = gen();

g[Symbol.iterator]() === g
// true

yield 表达式本身没有返回值,或者说总是返回 undefinednext 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* f() {
for (var i = 0; true; i++) {
var reset = yield i;
if (reset) {
i = -1;
}
}
}

var g = f();

g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }

再看一个通过 next 方法的参数,向 Generator 函数内部输入值的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* dataConsumer() {
console.log('Started');
console.log( `1.${yield}` );
console.log( `2.${yield}` );
return 'result';
}

let genObj = dataConsumer();
genObj.next();
// Started
genObj.next('a')
// 1. a
genObj.next('b')
// 2. b

for...of 循环可以自动遍历 Generator 函数运行时生成的 Iterator 对象,且此时不再需要调用 next 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}

for (let v of foo()) {
console.log(v);
}
// 1 2 3 4 5

通过 Generator 完成异步

异步

所谓”异步”,简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。

相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。

如果你需要几个异步程序块同步运行,可以使用 Promise 链进行操作,如果不能使用 Promise 链的话就只能将后面的程序使用 setTimeOut 延迟运行,当做是等待前面的异步程序完成。

协程

协程有点像函数,又有点像线程。它的运行流程大致如下。

  • 第一步,协程 A 开始执行。
  • 第二步,协程 A 执行到一半,进入暂停,执行权转移到协程 B
  • 第三步,(一段时间后)协程 B 交还执行权。
  • 第四步,协程 A 恢复执行。

上面流程的协程 A ,就是异步任务,因为它分成两段(或多段)执行。

1
2
3
4
5
function* asyncJob() {
// ...其他代码
var f = yield readFile(fileA);
// ...其他代码
}

上面代码的函数 asyncJob 是一个协程,它的奥妙就在其中的 yield 命令。它表示执行到此处,执行权将交给其他协程。也就是说, yield 命令是异步两个阶段的分界线。

协程遇到 yield 命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除 yield 命令,简直一模一样。

协程的 Generator 实现

通过 yield 命令的特性,结合 Generator ,使得函数每次遇到 yield 会返回后面的表达式并暂停,等待 next 操作。通过这样的方式实现异步。

整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。Generator 函数的执行方法如下。

1
2
3
4
5
6
7
8
function* gen(x) {
var y = yield x + 2;
return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器) g 。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针 gnext 方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的 yield 语句,上例是执行到 x + 2 为止。

换言之, next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象,表示当前阶段的信息( value 属性和 done 属性)。 value 属性是 yield 语句后面表达式的值,表示当前阶段的值; done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。

而且 Generator 函数可以通过 next() 向外和向内传输数据,并且可以手动抛出错误让 Generator 函数接收。

1
2
3
4
5
6
7
var fetch = require('node-fetch');

function* gen() {
var url = 'https://api.github.com/users/github';
var result = yield fetch(url);
console.log(result.bio);
}

上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了 yield 命令。

执行这段代码的方法如下。

1
2
3
4
5
6
7
8
var g = gen();
var result = g.next();

result.value.then(function(data) {
return data.json();
}).then(function(data) {
g.next(data);
});

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用 next 方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个 next 方法。

Thunk 函数

Thunk 函数是自动执行 Generator 函数的一种方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function f(m) {
return m * 2;
}

f(x + 5);

// 等同于

var thunk = function() {
return x + 5;
};

function f(thunk) {
return thunk() * 2;
}

上面代码中,函数 f 的参数 x + 5 被一个函数替换了。凡是用到原参数的地方,对 Thunk 函数求值即可。

这就是 Thunk 函数的定义,它是“传名调用”的一种实现策略,用来替换某个表达式。

JavaScript 语言的 Thunk 函数

JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。

1
2
3
4
5
6
7
8
9
10
11
12
// 正常版本的readFile(多参数版本)
fs.readFile(fileName, callback);

// Thunk版本的readFile(单参数版本)
var Thunk = function(fileName) {
return function(callback) {
return fs.readFile(fileName, callback);
};
};

var readFileThunk = Thunk(fileName);
readFileThunk(callback);

上面代码中, fs 模块的 readFile 方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。

有点柯里化的意思

Thunkify 模块

使用方式如下。

1
2
3
4
5
6
7
var thunkify = require('thunkify');
var fs = require('fs');

var read = thunkify(fs.readFile);
read('package.json')(function(err, str) {
// ...
});

Thunkify 的源码与上一节那个简单的转换器非常像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function thunkify(fn) {
return function() {
var args = new Array(arguments.length);
var ctx = this;

for (var i = 0; i < args.length; ++i) {
args[i] = arguments[i];
}

return function(done) {
var called;

args.push(function() {
if (called) return;
called = true;
done.apply(null, arguments);
});

try {
fn.apply(ctx, args);
} catch (err) {
done(err);
}
}
}
};

它的源码主要多了一个检查机制,变量 called 确保回调函数只运行一次。这样的设计与下文的 Generator 函数相关。请看下面的例子。

1
2
3
4
5
6
7
8
9
10
function f(a, b, callback) {
var sum = a + b;
callback(sum);
callback(sum);
}

var ft = thunkify(f);
var print = console.log.bind(console);
ft(1, 2)(print);
// 3

上面代码中,由于 thunkify 只允许回调函数执行一次,所以只输出一行结果。

以读取文件为例。下面的 Generator 函数封装了两个异步操作。

1
2
3
4
5
6
7
8
9
10
var fs = require('fs');
var thunkify = require('thunkify');
var readFileThunk = thunkify(fs.readFile);

var gen = function*() {
var r1 = yield readFileThunk('/etc/fstab');
console.log(r1.toString());
var r2 = yield readFileThunk('/etc/shells');
console.log(r2.toString());
};

上面代码中, yield 命令用于将程序的执行权移出 Generator 函数,那么就需要一种方法,将执行权再交还给 Generator 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function run(fn) {
var gen = fn();

function next(err, data) {
var result = gen.next(data);
if (result.done) return;
result.value(next);
}

next();
}

function* g() {
// ...
}

run(g);

上面代码的 run 函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。 next 函数先将指针移到 Generator 函数的下一步( gen.next 方法),然后判断 Generator 函数是否结束( result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数( result.value 属性),否则就直接退出。

不管内部有多少个异步操作,直接把 Generator 函数传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。

1
2
3
4
5
6
7
8
9
10
11
12
var fs = require('fs');
var thunkify = require('thunkify');
var readFileThunk = thunkify(fs.readFile);

var g = function*() {
var f1 = yield readFileThunk('fileA');
var f2 = yield readFileThunk('fileB');
// ...
var fn = yield readFileThunk('fileN');
};

run(g);

上面代码中,函数 g 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。

co 模块

下面是一个 Generator 函数,用于依次读取两个文件。

1
2
3
4
5
6
var gen = function*() {
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

co 模块可以让你不用编写 Generator 函数的执行器。

1
2
var co = require('co');
co(gen);

上面代码中,Generator 函数只要传入 co 函数,就会自动执行。

co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。

1
2
3
co(gen).then(function() {
console.log('Generator 函数执行完成');
});

前面说过,Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。

两种方法可以做到这一点。

(1)回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。

(2)Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交回执行权。

co 模块其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个模块。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或 Promise 对象。如果数组或对象的成员,全部都是 Promise 对象,也可以使用 co,详见后文的例子。

首先,把 fs 模块的 readFile 方法包装成一个 Promise 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var fs = require('fs');

var readFile = function(fileName) {
return new Promise(function(resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) return reject(error);
resolve(data);
});
});
};

var gen = function*() {
var f1 = yield readFile('/etc/fstab');
var f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

然后,手动执行上面的 Generator 函数。

1
2
3
4
5
6
7
var g = gen();

g.next().value.then(function(data) {
g.next(data).value.then(function(data) {
g.next(data);
});
});

手动执行其实就是用 then 方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function run(gen) {
var g = gen();

function next(data) {
var result = g.next(data);
if (result.done) return result.value;
result.value.then(function(data) {
next(data);
});
}

next();
}

run(gen);

上面代码中,只要 Generator 函数还没执行到最后一步, next 函数就调用自身,以此实现自动执行。

co 就是上面那个自动执行器的扩展,它的源码只有几十行,非常简单。

首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。

1
2
3
4
5
function co(gen) {
var ctx = this;

return new Promise(function(resolve, reject) {});
}

在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为 resolved

1
2
3
4
5
6
7
8
function co(gen) {
var ctx = this;

return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
});
}

接着,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulfilled 函数。这主要是为了能够捕捉抛出的错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function co(gen) {
var ctx = this;

return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.call(ctx);
if (!gen || typeof gen.next !== 'function') return resolve(gen);

onFulfilled();

function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
}
});
}

最后,就是关键的 next 函数,它会反复调用自身。

1
2
3
4
5
6
7
8
9
10
11
12
13
function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
return onRejected(
new TypeError(
'You may only yield a function, promise, generator, array, or object, ' +
'but the following object was passed: "' +
String(ret.value) +
'"'
)
);
}

上面代码中, next 函数的内部代码,一共只有四行命令。

第一行,检查当前是否为 Generator 函数的最后一步,如果是就返回。

第二行,确保每一步的返回值,是 Promise 对象。

第三行,使用 then 方法,为返回值加上回调函数,然后通过 onFulfilled 函数再次调用 next 函数。

第四行,在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为 rejected ,从而终止执行。

Async 函数

Async/await 本质上是 Generator 的语法糖,可以更加直观的设计异步程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fs = require('fs');

const readFile = function(fileName) {
return new Promise(function(resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) return reject(error);
resolve(data);
});
});
};

const gen = function*() {
const f1 = yield readFile('/etc/fstab');
const f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

上面代码的函数 gen 可以写成 async 函数,就是下面这样。

1
2
3
4
5
6
const asyncReadFile = async function() {
const f1 = await readFile('/etc/fstab');
const f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

一比较就会发现, async 函数就是将 Generator 函数的星号( * )替换成 async ,将 yield 替换成 await ,仅此而已。

转换后的 async 函数也会自动进行流程管理。

async 函数对 Generator 函数的改进,体现在以下四点。

(1)内置执行器。

Generator 函数的执行必须靠执行器,所以才有了 co 模块,而 async 函数自带执行器。也就是说, async 函数的执行,与普通函数一模一样,只要一行。

1
asyncReadFile();

上面的代码调用了 asyncReadFile 函数,然后它就会自动执行,输出最后结果。这完全不像 Generator 函数,需要调用 next 方法,或者用 co 模块,才能真正执行,得到最后结果。

(2)更好的语义。

asyncawait ,比起星号和 yield ,语义更清楚了。 async 表示函数里有异步操作, await 表示紧跟在后面的表达式需要等待结果。

(3)更广的适用性。

co 模块约定, yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。

(4)返回值是 Promise。

async 函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用 then 方法指定下一步的操作。

进一步说, async 函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而 await 命令就是内部 then 命令的语法糖。

await 命令后面的 Promise 对象如果变为 reject 状态,则 reject 的参数会被 catch 方法的回调函数接收到。

1
2
3
4
5
6
7
8
async function f() {
await Promise.reject('出错了');
}

f()
.then(v => console.log(v))
.catch(e => console.log(e))
// 出错了

注意,上面代码中, await 语句前面没有 return ,但是 reject 方法的参数依然传入了 catch 方法的回调函数。这里如果在 await 前面加上 return ,效果是一样的。

任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行。

1
2
3
4
async function f() {
await Promise.reject('出错了');
await Promise.resolve('hello world'); // 不会执行
}

按顺序完成异步操作

Class 的基本语法

JavaScript 确实是一门面向对象的编程语言,那么他也应该会有面向对象的三个特性,继承、多态、封装。

生成一个实例对象的传统方法是通过构造函数。

1
2
3
4
5
6
7
8
9
10
function Point(x, y) {
this.x = x;
this.y = y;
}

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

var p = new Point(1, 2);

在这个例子中通过 new 标识符新建了 Point 函数的对象。

class 作为 ES6 的一个语法糖,用来更加语义化的声明一个类。

1
2
3
4
5
6
7
8
9
10
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}

toString() {
return '(' + this.x + ', ' + this.y + ')';
}
}

constructor 是默认的构造方法,用于在对象被 new 实例化时的初始化。

定义“类”的方法的时候,前面不需要加上 function 这个关键字,直接把函数定义放进去了就可以了。

类的内部所有定义的方法,都是不可枚举的(non-enumerable)。

他们的 enumerable 属性为 false。

与 ES5 一样,实例的属性除非显式定义在其本身(即定义在 this 对象上),否则都是定义在原型上(即定义在 class 上)。

也就是说大部分的对象方法是定义在对象的原型属性上的,即 __proto__

与函数一样,类也可以使用表达式的形式定义。

1
2
3
4
5
const MyClass = class Me {
getClassName() {
return Me.name;
}
};

上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是 Me ,但是 Me 只在 Class 的内部可用,指代当前类。在 Class 外部,这个类只能用 MyClass 引用。

类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上 static 关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。

1
2
3
4
5
6
7
8
9
10
11
class Foo {
static classMethod() {
return 'hello';
}
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

父类的静态方法,可以被子类继承。

1
2
3
4
5
6
7
8
9
class Foo {
static classMethod() {
return 'hello';
}
}

class Bar extends Foo {}

Bar.classMethod() // 'hello'

静态属性指的是 Class 本身的属性,即 Class.propName ,而不是定义在实例对象( this )上的属性。

1
2
3
4
class Foo {}

Foo.prop = 1;
Foo.prop // 1

上面的写法为 Foo 类定义了一个静态属性 prop

目前,只有这种写法可行,因为 ES6 明确规定,Class 内部只有静态方法,没有静态属性。现在有一个提案提供了类的静态属性,写法是在实例属性的前面,加上 static 关键字。

1
2
3
4
5
6
7
class MyClass {
static myStaticProp = 42;

constructor() {
console.log(MyClass.myStaticProp); // 42
}
}

私有方法和私有属性,是只能在类的内部访问的方法和属性,外部不能访问。这是常见需求,有利于代码的封装,但 ES6 不提供,只能通过变通方法模拟实现。

一种做法是在命名上加以区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget {

// 公有方法
foo(baz) {
this._bar(baz);
}

// 私有方法
_bar(baz) {
return this.snaf = baz;
}

// ...
}

上面代码中, _bar 方法前面的下划线,表示这是一个只限于内部使用的私有方法。但是,这种命名是不保险的,在类的外部,还是可以调用到这个方法。

另一种方法就是索性将私有方法移出模块,因为模块内部的所有方法都是对外可见的。

1
2
3
4
5
6
7
8
9
10
11
class Widget {
foo(baz) {
bar.call(this, baz);
}

// ...
}

function bar(baz) {
return this.snaf = baz;
}

还有一种方法是利用 Symbol 值的唯一性,将私有方法的名字命名为一个 Symbol 值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const bar = Symbol('bar');
const snaf = Symbol('snaf');

export default class myClass {

// 公有方法
foo(baz) {
this[bar](baz);
}

// 私有方法
[bar](baz) {
return this[snaf] = baz;
}

// ...
};

通过 Symbol 的特性,阻止外部函数访问内部变量。以达成私有方法的实现。

new 是从构造函数生成实例对象的命令。ES6 为 new 命令引入了一个 new.target 属性,该属性一般用在构造函数之中,返回 new 命令作用于的那个构造函数。如果构造函数不是通过 new 命令或 Reflect.construct() 调用的, new.target 会返回 undefined ,因此这个属性可以用来确定构造函数是怎么调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person(name) {
if (new.target !== undefined) {
this.name = name;
} else {
throw new Error('必须使用 new 命令生成实例');
}
}

// 另一种写法
function Person(name) {
if (new.target === Person) {
this.name = name;
} else {
throw new Error('必须使用 new 命令生成实例');
}
}

var person = new Person('张三'); // 正确
var notAPerson = Person.call(person, '张三'); // 报错

上面代码确保构造函数只能通过 new 命令调用。

Class 的继承

Class 可以通过 extends 关键字实现继承,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多。

1
2
3
class Point {}

class ColorPoint extends Point {}

上面代码定义了一个 ColorPoint 类,该类通过 extends 关键字,继承了 Point 类的所有属性和方法。但是由于没有部署任何代码,所以这两个类完全一样,等于复制了一个 Point 类。下面,我们在 ColorPoint 内部加上代码。

1
2
3
4
5
6
7
8
9
10
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // 调用父类的constructor(x, y)
this.color = color;
}

toString() {
return this.color + ' ' + super.toString(); // 调用父类的toString()
}
}

上面代码中, constructor 方法和 toString 方法之中,都出现了 super 关键字,它在这里表示父类的构造函数,用来新建父类的 this 对象。

子类必须在 constructor 方法中调用 super 方法,否则新建实例时会报错。这是因为子类自己的 this 对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用 super 方法,子类就得不到 this 对象。

1
2
3
4
5
6
7
8
class Point {
/* ... */ }

class ColorPoint extends Point {
constructor() {}
}

let cp = new ColorPoint(); // ReferenceError

上面代码中, ColorPoint 继承了父类 Point ,但是它的构造函数没有调用 super 方法,导致新建实例时报错。

Object.getPrototypeOf 方法可以用来从子类上获取父类。

1
2
Object.getPrototypeOf(ColorPoint) === Point
// true

因此,可以使用这个方法判断,一个类是否继承了另一个类。

super 这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。

第一种情况, super 作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次 super 函数。

1
2
3
4
5
6
7
class A {}

class B extends A {
constructor() {
super();
}
}

上面代码中,子类 B 的构造函数之中的 super() ,代表调用父类的构造函数。这是必须的,否则 JavaScript 引擎会报错。

注意, super 虽然代表了父类 A 的构造函数,但是返回的是子类 B 的实例,即 super 内部的 this 指的是 B 的实例,因此 super() 在这里相当于 A.prototype.constructor.call(this)

大多数浏览器的 ES5 实现之中,每一个对象都有 __proto__ 属性,指向对应的构造函数的 prototype 属性。Class 作为构造函数的语法糖,同时有 prototype 属性和 __proto__ 属性,因此同时存在两条继承链。

(1)子类的 __proto__ 属性,表示构造函数的继承,总是指向父类。

(2)子类 prototype 属性的 __proto__ 属性,表示方法的继承,总是指向父类的 prototype 属性。

1
2
3
4
5
6
class A {}

class B extends A {}

B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true

上面代码中,子类 B__proto__ 属性指向父类 A ,子类 Bprototype 属性的 __proto__ 属性指向父类 Aprototype 属性。

Mixin 指的是多个对象合成一个新的对象,新对象具有各个组成成员的接口。它的最简单实现如下。

1
2
3
4
5
6
7
8
9
10
const a = {
a: 'a'
};
const b = {
b: 'b'
};
const c = {
...a,
...b
}; // {a: 'a', b: 'b'}

上面代码中, c 对象是 a 对象和 b 对象的合成,具有两者的接口。

下面是一个更完备的实现,将多个类的接口“混入”(mix in)另一个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function mix(...mixins) {
class Mix {
constructor() {
for (let mixin of mixins) {
copyProperties(this, new mixin()); // 拷贝实例属性
}
}
}

for (let mixin of mixins) {
copyProperties(Mix, mixin); // 拷贝静态属性
copyProperties(Mix.prototype, mixin.prototype); // 拷贝原型属性
}

return Mix;
}

function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if (key !== 'constructor' &&
key !== 'prototype' &&
key !== 'name'
) {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}

上面代码的 mix 函数,可以将多个对象合成为一个类。使用的时候,只要继承这个类即可。

Module(模块)的语法

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

1
2
3
4
5
6
7
8
9
10
11
12
// CommonJS模块
let {
stat,
exists,
readFile
} = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

上面代码的实质是整体加载 fs 模块(即加载 fs 的所有方法),生成一个对象( _fs ),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。

ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

1
2
3
4
5
6
// ES6模块
import {
stat,
exists,
readFile
} from 'fs';

上面代码的实质是从 fs 模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。

模块功能主要由两个命令构成: exportimportexport 命令用于规定模块的对外接口, import 命令用于输入其他模块提供的功能。

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。下面是一个 JS 文件,里面使用 export 命令输出变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
// or
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;

export {
firstName,
lastName,
year
};

上面代码是 profile.js 文件,保存了用户信息。ES6 将其视为一个模块,里面用 export 命令对外部输出了三个变量。

也就是说如果你需要这三个变量,就可以使用 import 命令导入该模块

1
2
3
4
5
import {
firstName,
lastName,
year
} from 'profile.js';

import 命令加载的变量是只读的,具有提升效果,会提升到整个模块的头部,首先执行。

由于 import 是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

需要特别注意的是, export 命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 报错
export 1;

// 报错
var m = 1;
export m;

// 写法一
export var m = 1;

// 写法二
var m = 1;
export {
m
};

// 写法三
var n = 1;
export {
n as m
};

同样的, functionclass 的输出,也必须遵守这样的写法。

1
2
3
4
5
6
7
8
9
10
11
12
// 报错
function f() {}
export f;

// 正确
export function f() {};

// 正确
function f() {}
export {
f
};

除了指定加载某个输出值,还可以使用整体加载,即用星号( * )指定一个对象,所有输出值都加载在这个对象上面。

1
2
3
4
import * as circle from './circle';

console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));

使用 export default 命令,为模块指定默认输出。

1
2
3
4
// export-default.js
export default function() {
console.log('foo');
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export {
foo,
bar
}
from 'my_module';

// 可以简单理解为
import {
foo,
bar
} from 'my_module';
export {
foo,
bar
};

上面代码中, exportimport 语句可以结合在一起,写成一行。但需要注意的是,写成一行以后, foobar 实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用 foobar

本书介绍 const 命令的时候说过, const 声明的常量只在当前代码块有效。如果想设置跨模块的常量(即跨多个文件),或者说一个值要被多个模块共享,可以采用下面的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// constants.js 模块
export const A = 1;
export const B = 3;
export const C = 4;

// test1.js 模块
import * as constants from './constants';
console.log(constants.A); // 1
console.log(constants.B); // 3

// test2.js 模块
import {
A,
B
} from './constants';
console.log(A); // 1
console.log(B); // 3

如果要使用的常量非常多,可以建一个专门的 constants 目录,将各种常量写在不同的文件里面,保存在该目录下。

1
2
3
4
5
6
7
8
9
// constants/db.js
export const db = {
url: 'http://my.couchdbserver.local:5984',
admin_username: 'admin',
admin_password: 'admin password'
};

// constants/user.js
export const users = ['root', 'admin', 'staff', 'ceo', 'chief', 'moderator'];

然后,将这些文件输出的常量,合并在 index.js 里面。

1
2
3
4
5
6
7
8
9
// constants/index.js
export {
db
}
from './db';
export {
users
}
from './users';

使用的时候,直接加载 index.js 就可以了。

1
2
3
4
5
// script.js
import {
db,
users
} from './constants/index';

防抖和节流

防抖指的是事件在发生后 n 秒内只能执行一次,如果事件在 n 秒内重复触发,会重置时间。也就是发生后如果 n 秒内再次触发,n 会重新计时,不会再次触发函数,如果 n 秒后没有触发事件,就自动触发事件(这个可触发可以不触发),关键在于不让目标事件在一段时间内重复多次触发。

具体的实现方法可以考虑两种,一种是类似点击这种事件,可以通过设置不可点击或者隐藏按钮的方法不让用户触发事件,另一种是在事件触发函数中设置 timeout 延迟触发,如果用户多次触发就重置时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function debounce(fn, delay) {
let timer = null //借助闭包
return function() {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(fn, delay) // 简化写法
// 不管timer是否有效,都会在最后执行一次,如果有效的话就对应了在时间内重复触发,就会直接被clear掉,然后在最后一次触发的时候settimeout执行函数。无效的话也会在最后一次触发的时候settimeout执行函数。
}
}
// 然后是旧代码
function showTop() {
var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
console.log('滚动条位置:' + scrollTop);
}
window.onscroll = debounce(showTop, 1000) // 为了方便观察效果我们取个大点的间断值,实际使用根据需要来配置

https://segmentfault.com/a/1190000018428170 的实例代码

1
2
3
4
5
6
7
8
9
10
11
12
function debounce(func, wait) {
let timeout;
return function() {
let context = this;
let args = arguments;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args)
}, wait);
// 使用fn.apply() 和将方法传入settimeout是一样的,但是这样的形式可以动态传入参数。
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function debounce(func,wait) {
let timeout;
return function () {
let context = this;
let args = arguments;
if (timeout) clearTimeout(timeout);
let callNow = !timeout;
// 是否是第一次执行
timeout = setTimeout(() => {
timeout = null;
}, wait)
if (callNow) func.apply(context, args)
// 第一次执行的时候是直接执行,后面多次执行都会被延迟防抖。
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* @desc 函数防抖
* @param func 函数
* @param wait 延迟执行毫秒数
* @param immediate true 表立即执行,false 表非立即执行
*/
function debounce(func,wait,immediate) {
let timeout;

return function () {
let context = this;
let args = arguments;

if (timeout) clearTimeout(timeout);
// 通过 immediate 参数控制是否采用第一次立即执行
if (immediate) {
var callNow = !timeout;
timeout = setTimeout(() => {
timeout = null;
}, wait)
if (callNow) func.apply(context, args)
}
else {
timeout = setTimeout(function(){
func.apply(context, args)
}, wait);
}
}
}

https://www.jianshu.com/p/c8b86b09daf0 的实例代码

相对来讲的话,简书的这个文章的代码更加完整而且可控。

节流的意思是,短时间内大量触发的事件,在函数执行一次后,函数在一段时间内不再执行,但是时间过后可以再次执行,并不会重置计时。

相对于防抖来说,节流的主要目的是在防止函数的多次运行后保证了函数的有效连续运行。比如思否的这个文章中的滚动条就更加适合使用节流,而不是防抖。因为用户如果一直在滚动的话,是不会在特定的时间加载或者执行动画的。而如果是节流的话,相当于是 setInterval() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function throttle(fn, delay) {
let valid = true
return function() {
if (!valid) {
//休息时间 暂不接客
return false
}
// 工作时间,执行函数并且在间隔期内把状态位设为无效
valid = false
setTimeout(() => {
fn()
valid = true;
}, delay)
}
}
/* 请注意,节流函数并不止上面这种实现方案,
例如可以完全不借助setTimeout,可以把状态位换成时间戳,然后利用时间戳差值是否大于指定间隔时间来做判定。
也可以直接将setTimeout的返回的标记当做判断条件-判断当前定时器是否存在,如果存在表示还在冷却,并且在执行fn之后消除定时器表示激活,原理都一样
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 时间戳版本
function throttle(func, wait) {
let previous = 0;
return function() {
let now = Date.now();
// 通过设定时间戳来判断是否已经超出设定的间隔时间。
let context = this;
let args = arguments;
if (now - previous > wait) {
func.apply(context, args);
previous = now;
}
}
}
content.onmousemove = throttle(count, 1000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 计时器版本
function throttle(func, wait) {
let timeout;
return function() {
let context = this;
let args = arguments;
// 通过timeout变量检测是否超出时间
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
// 这里已经重置null了
func.apply(context, args)
}, wait)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* @desc 函数节流
* @param func 函数
* @param wait 延迟执行毫秒数
* @param type 1 表时间戳版,2 表定时器版
*/
function throttle(func, wait, type) {
if (type === 1) {
let previous = 0;
} else if (type === 2) {
let timeout;
}
return function() {
let context = this;
let args = arguments;
if (type === 1) {
let now = Date.now();

if (now - previous > wait) {
func.apply(context, args);
previous = now;
}
} else if (type === 2) {
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(context, args)
}, wait)
}
}
}
}

TypeScript 整理

设计模式

关于浏览器需要知道些什么

浏览器到底做了什么?

浏览器会管理与每个网站服务器的链接

  • HTTP
  • HTTPS

对应不同的链接方式,虽然都是基于 TCP 的,但是 HTTPS 使用了 SSL 或 TLS 协议为数据进行加密。

浏览器通过 URL 获取网页,URL 包含

  • 域名
  • 资源路径

域名通过 DNS 服务器获取网页服务器 IP,当然为了节省资源一般浏览器是有缓存的,或者操作系统会有缓存。

然后在通过资源路径获取到网页。

获取到网页后开始对网页进行解析和渲染

解析包括生成 HTML DOM 树和 CSS DOM 树,DOM 树是通过 HTML 语言的特性,将 HTML 代码变成一个个节点并连接成树,CSS 也同样如此,整合到一起后生成渲染树,然后通过浏览器内核对渲染树进行渲染展示出页面。

浏览器集成了各种解析器,用于解析 HTML、CSS、JavaScript、PHP等语言,由于各种语言版本和浏览器版本的不同,可能会出现某些浏览器不能使用某些特性,比如 ECMAScript 是 JavaScript 的标准,但是具体的实现是交给浏览器来做的,如果当前版本的浏览器没有做某个特性,可能就会导致网页显示错误或排版混乱。所以在前端页面的编写和优化过程中,对于兼容性有特殊要求的功能需要非常小心。

Node. js 整理

Vue 整理

Vue 是什么?

Vue 是一个开源的响应式前端框架,使用了 MVVM 框架,方便开发者关注数据图层。

MVVM 框架是什么?

Model-View-ViewModel 是 MVC 的一个改进版本。那么 MVC 又是什么?

Model-View-Controller,Model 是指数据模型,View 指视图,Controller 是控制器,这个框架使得视图和数据模型分开来,通过控制器来进行操作,在数据发生改变的时候通知控制器,然后控制器去更新视图。

https://www.runoob.com/design-pattern/mvc-pattern.html 菜鸟的这个示例实现了一个 MVC 架构的程序。

MVVM 就是将 MVC 中的 Controller 改进成 ViewModel,把 Model 和 View 关联起来的就是 ViewModel。ViewModel 负责把 Model 的数据同步到 View 显示出来,还负责把 View 的修改同步回 Model。

让MVVM框架去自动更新DOM的状态,从而把开发者从操作DOM的繁琐步骤中解脱出来!

https://www.liaoxuefeng.com/wiki/1022910821149312/1108898947791072

Vue 是如何实现响应式的?

Object.defineProperty()

一开始我觉得通过这个方法,就可以自定义 get() 和 set() 两个方法,这样当这个数据对象发生改变或者获取数据对象的时候,就可以先对数据对象进行处理,那么进行的是什么样的处理?

Virtual DOM

Vue源码系列一:Vue中的data是如何驱动视图渲染的? 中介绍了 Vue 的源码,分析了 Vue 的初始化顺序,主要就是为了生成虚拟 DOM 树。

浏览器不是已经有了 DOM 树了嘛?Vue 这个DOM 树有什么不同的嘛?

其实没什么不同,因为这个树就是为了给浏览器进行渲染的,在这个树的 [官方文档](https://cn.vuejs.org/v2/guide/render-function. html#虚拟-DOM) 中,Vue 解释说使用这个虚拟 DOM 是

Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。

这就联系起来了。通过 Object.defineProperty() 方法可以劫持数据对象,通过虚拟 DOM 可以实现 MVVM 中的ViewModel 功能,更加详细的东西就是在 set() 和 get() 里面了。

Vue 实现了一个订阅-发布模式。在这个模式中,Vue 实现了依赖收集和派发更新。

VUE源码系列二:Vue响应式原理解析(附超详细源码注释和原理解析)

在上面的文章中,作者通过源码解析详细介绍了这个订阅-发布模式,可以将其分成三个模块,

  • Observer(劫持者)
    • 给对象的属性添加 getter 和 setter,用于依赖收集和派发更新
    • 被用在 defineReactive 中,使得对象的每个属性都添加上 getter 和 setter 成为响应式。
  • Dep(依赖收集)
    • 收集订阅者 subs : Array<Watcher> ,用在 defineReactive 的重写 get 中为 dep.depend 方法,为数据提供收集功能。
    • 同时提供了发布更新的作用,在 defineReactive 的重写 set 中为 dep.notify() 方法。
  • Watcher(观察者)
    • 在 Dep 发布更新后使用 dep.notify() 方法,循环执行 Dep 中的 subs[i].update() 使得数据相关的订阅者更新视图。
    • watcher.update() 中,使用了 queuewatcher() 方法,通过一个队列检查是否含有目标观察者,使得视图不会重复刷新。在队列中异步执行 flushSchedulerQueue() 方法,在此方法中排序 queue 确保父子组件的顺序更新,然后执行 run() 函数,使用新旧 value 值执行 Watcher 的回调 cb.call()

响应系统源码版

clipboard. png

这两张图片可以用来参考,来自 从发布-订阅模式到Vue响应系统

总结一下

Vue 通过 Object.defineProperty() 实现对数据对象的劫持,通过 Observer 类定义 getter 和 setter,使得对象的所有属性都具备响应性,在 getter() 中通过依赖收集 Dep 的 dep.depends() 管理订阅者(当数据被 get 即视为被订阅),在 setter() 中通过 dep.notify() 函数向订阅了该数据对象的 Watcher 发送 update() ,Watcher 接收到更新信息后确定组件更新顺序然后映射到 Virtual DOM 中去,Virtual DOM 通过 patch 新旧节点,通过 diff 算法实现对新旧节点的对比,对同层的树节点进行比较而非对树进行逐层搜索遍历。然后生成真实 DOM。

这是 Vue2. x 的响应式原理,在 Vue3. 0 中,官方重写了响应式的实现,改用 Proxy Reflect 代替 Object.defineProperty() 。可以实现对更多数据的操作比如数组元素的劫持和更多的劫持方式。

Vue 的生命周期

![Vue 实例生命周期](https://cn.vuejs.org/images/lifecycle. png)

如果是 keep-alive 的话还有两个:activated 和 deactivated

keep-alive的生命周期

1. activated: 页面第一次进入的时候,钩子触发的顺序是created->mounted->activated
2. deactivated: 页面退出的时候会触发deactivated,当再次前进或者后退的时候只触发activated

那么 keep-alive 是什么?

keep-alive 是 Vue 提供的一个抽象组件, <keep-alive> 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。和 <transition> 相似, 是一个抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在组件的父组件链中。

这是 Vue 对象的生命周期,那么在 Vue 的父子组件中的生命周期是什么样的?

渲染过程:
父组件挂载完成一定是等子组件都挂载完成后,才算是父组件挂载完,所以父组件的mounted在子组件mouted之后
父beforeCreate -> 父created -> 父beforeMount -> 子beforeCreate -> 子created -> 子beforeMount -> 子mounted -> 父mounted

子组件更新过程:

1. 影响到父组件: 父beforeUpdate -> 子beforeUpdate->子updated -> 父updted
2. 不影响父组件: 子beforeUpdate -> 子updated

父组件更新过程:

1. 影响到子组件: 父beforeUpdate -> 子beforeUpdate->子updated -> 父updted
2. 不影响子组件: 父beforeUpdate -> 父updated

销毁过程:
父beforeDestroy -> 子beforeDestroy -> 子destroyed -> 父destroyed

所以不管是加载更新还是销毁都是父组件会等待子组件完后操作后才执行操作。

v-model 是什么?

v-model 是经常用来双向绑定数据的一个语法,实际是一个 v-bind 和 v-on 结合的一个语法糖,等同于

1
2
v - bind: value = "msg"
v - on: input = "msg=$event.target.value"

v-bind 动态地绑定一个或多个特性,或一个组件 prop 到表达式。

v-on 绑定事件监听器。事件类型由参数指定。表达式可以是一个方法的名字或一个内联语句,如果没有修饰符也可以省略。

也就是说 v-model 通过绑定数据和绑定数据并监听数据的变化,形成双向绑定。

VUE源码系列七:v-model实现原理

这篇文章则是从源码角度去解释 v-model,并不是简单的转换为 v-bind 和 v-on。只是在最终结果上是一样的。

Vue 组件是什么?

简单说 Vue 组件是一个可以复用的 Vue 实例,通过将网页组件化,可以更加快捷的搭建更多的网页,所以 Vue 组件最主要的目的是复用,类似 Bootstrap 的组件库、Element-UI,他们其实都是组件库,通过自己设计的组件,形成组件库。

https://cn.vuejs.org/v2/guide/components. html 是 Vue 关于组件的文档,以及后续的深入组件内容。

1
Vue. component('my-component-name', { /* ... */ })

通常来说,我们可以使用这样的格式来注册一个组件,这种格式下注册的组件是可以全局使用的,但是很明显当你的页面比较多的时候,全局使用组件会造成一定程度的冗余,所以你可以通过变量赋值,然后通过 Vue 实例的 components 属性来添加当前实例的组件。

1
2
3
4
5
6
7
8
9
var ComponentA = { /* ... */ }
var ComponentB = { /* ... */ }
new Vue({
el: '#app',
components: {
'component-a': ComponentA,
'component-b': ComponentB
}
})

在设计组件的时候,我们需要考虑这个组件需要什么,以及他最后会呈现什么样子。

1
2
3
4
5
Vue.component('blog-post', {
// 在 JavaScript 中是 camelCase 的
props: ['postTitle'],
template: '<h3>{{ postTitle }}</h3>'
})

通过 props 可以为组件添加属性,这个属性是可以由父组件传递给子组件使用的。可以理解为这是一种函数模式,通过传入参数,使得组件呈现不同的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Vue.component('base-checkbox', {
model: {
prop: 'checked',
event: 'change'
},
props: {
checked: Boolean
},
template: `
<input
type="checkbox"
v-bind:checked="checked"
v-on:change="$emit('change', $event.target.checked)"
>
`
})

除了 props 以外,我们还可以给组件添加自定义的事件属性,

注意这里在 template 中使用的是 v-bindv-on 而不是 v-model ,虽然他们所实现的目的是一样的,但是由于方便以及参数传输更加清楚,我们在使用这个组件的时候,通常使用 v-model

1
<base-checkbox v-model="lovingVue"></base-checkbox>

这样对应过来, lovingVue 属性绑定在了组件的 checked 属性上,并且在发生 change 事件时也会通过 v-on 方法返回到 lovingVue 属性上,实现子组件参数与父组件属性间的双重绑定。

更多的细节可以参考官方的详细文档,你就会发现组件其实和实例拥有几乎一模一样的属性和方法,包括 computed watch 以及生命周期方法。

但是我们可以看到,在 template 的编写过程中,我们完全得不到语法提示,因为他是一个字符串属性。

所以更好的办法是我们把组件写成一个实例。通过局部引入来进行调用。也就是单文件组件。

在单文件组件中,组件编写更加的方便,同时数据上的传递思路也更加明确。

关于插槽

插槽是 Vue 组件中一个很重要的方法,简单来说,插槽的目的是提供另外一种参数传递的方式,

1
2
3
<navigation-link url="/profile">
Your Profile
</navigation-link>

当你使用了一个 navigation-link 的组件时,正常情况下,组件间的数据都会被忽略,因为对于组件来说,中间的数据没有任何意义,如果不能正确的传递参数,中间的数据很容易打乱组件的结构。

1
2
3
<a v-bind:href="url" class="nav-link">
<slot></slot>
</a>

但是当组件中设定了一个 slot 标签后,父组件在使用子组件时,标签内部的信息都会被转移到 slot 标签中,而不是被直接抛弃。所以最后会渲染成:

1
2
3
<a v-bind:href="url" class="nav-link">
Your Profile
</a>

插槽中不仅可以填写这种纯文本,使用 HTML 代码甚至是使用 {{}} 格式包裹起来的数据属性也是可以的。

但是在使用 {{}} 格式传递数据时我们需要思考一个问题,我们是在使用子组件的时候,在子组件标签中间使用的 {{}} ,那么里面的属性我们自然是填入父组件的数据属性。

1
2
3
<navigation-link url="/profile">
{{ user.name }}
</navigation-link>

我们可以用作用域来解释,即子组件的作用域,仅仅是在子组件中,甚至说在父组件中这个子组件的标签属性,也是数据父组件的作用域范围内,所以我们可以通过标签属性来传递父组件的数据到子组件中。只有数据传递过去后,才能算是子组件的数据属性,属于子组件的作用域范围。

更多详细的关于插槽的信息参考文档 https://cn.vuejs.org/v2/guide/components-slots. html

需要了解的包括有插槽的内容、作用域、默认内容、具名插槽、插槽prop、动态插槽名等相关知识。

关于动态组件

1
2
3
<keep-alive>
<component v-bind:is="currentTabComponent"></component>
</keep-alive>

在经典 Vue 示例中,我们经常会看到 <router-view> 这个标签,这个标签常常使用在 Vue 项目的入口 App.vue 中,用来对视图进行切换,在实际项目中,当父组件动态调用子组件的时候,通常来说,在切换子组件后子组件会被销毁。但是当我们添加 <keep-alive> 标签后,被切换隐藏掉的子组件并不会被销毁,这样可以在特定场景下, 提升网页反应速度。

在测试的时候发现在 <router-view> 外也是可以使用 <keep-alive> 来实现同样的效果。

Vue Router

Vue Router 是 Vue. js 官方的路由管理器。它和 Vue. js 的核心深度集成,让构建单页面应用变得易如反掌。包含的功能有:

  • 嵌套的路由/视图表
  • 模块化的、基于组件的路由配置
  • 路由参数、查询、通配符
  • 基于 Vue. js 过渡系统的视图过渡效果
  • 细粒度的导航控制
  • 带有自动激活的 CSS class 的链接
  • HTML5 历史模式或 hash 模式,在 IE9 中自动降级
  • 自定义的滚动条行为

和大部分的路由管理器一样,Vue Router 通过对 URL 进行正则匹配指导用户转向不同的页面,和一些服务端的路由不同的是,Vue Router 通常用于建立单页面应用,这里的单页面并不是指只有一个页面,而是通过在同一个页面,通过路由的不同显示不同的预先写好的模板页面。

Vue Router 默认使用 hash 模式 URL

http://localhost:8080/#/login

通过 # 区分浏览器 URL 和 Vue Router 路由所使用的 URL。在 hash 模式下,可以更加方便的理解整个页面程序的路由,但是我们会发现他不太美观,如果你想使用正常的浏览器形式的 URL,需要修改 Vue Router 的模式。

1
mode: 'history'

http://localhost:8080/login

需要注意的时,这是在 Vue 提供的临时服务器中,所以可能感受不到 Vue 的单页面应用和其他的多页面应用的区别,因为 Vue 的临时服务器会通过 Vue 的 Router 解析端口传递的 URL 请求并指向到正确的显示页面。

回到浏览器上,当我们输入了一个 URL 后,如果是普通的多页面应用,会通过 URL 解析并指向目标 HTML 文件,返回文件进行渲染,对单页面应用来说,一个是前端页面的不同路由,一个是服务器接口的路由。如果不使用 hash 模式通过 # 进行区分的话,是不能通过 http://localhost:8080/login 直接访问到 login 页面的

当前使用 Vue 生成的静态文件作为 Python Django 服务器的模板,通过 / 路由确定 index.html 的位置并显示 Vue 页面,然后开始使用 Vue 的路由。如果直接在 URL 栏上输入 Vue 的路由是不能转到目标页面的。

关于路由方面需要了解的大概就是嵌套路由,正则表达,路由参数,有的部分是和组件有些类似的地方,比如路由参数和组件间的参数传递,通常来说路由参数是用于同级组件间的参数传递,组件间参数是父子组件间的数据传递。

导航守卫

“导航”表示路由正在发生改变。

完整的导航解析流程

  1. 导航被触发。
  2. 在失活的组件里调用离开守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫 (2. 2+)。
  5. 在路由配置里调用 beforeEnter
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter
  8. 调用全局的 beforeResolve 守卫 (2. 5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。

所以守卫实际上是设定好的函数,通过路由变化触发。在完整的 beforeEach=>beforeRouteUpdate=>beforeEnter=>beforeRouteEnter=>afterEach 状态路径上对网页进行控制。

相当于是路由的生命周期,类似于组件的生命周期一样。

Vuex

Vuex 是一个专为 Vue. js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。Vuex 也集成到 Vue 的官方调试工具 devtools extension,提供了诸如零配置的 time-travel 调试、状态快照导入导出等高级调试功能。

状态管理模式是什么?

简单说就是一个全局的数据状态,多个组件可以共享这个状态。避免多个组件间的复杂的数据传递和状态修改的实现。

store

每一个 Vuex 应用的核心就是 store(仓库)。“store”基本上就是一个容器,它包含着你的应用中大部分的状态 (state)。Vuex 和单纯的全局对象有以下两点不同:

  1. Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。
  2. 你不能直接改变 store 中的状态。改变 store 中的状态的唯一途径就是显式地**提交 (commit) mutation**。这样使得我们可以方便地跟踪每一个状态的变化,从而让我们能够实现一些工具帮助我们更好地了解我们的应用。
1
2
3
4
5
6
7
8
9
10
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment(state) {
state.count++
}
}
})

现在,你可以通过 store.state 来获取状态对象,以及通过 store.commit 方法触发状态变更:

1
2
3
store.commit('increment')

console.log(store.state.count) // -> 1

再次强调,我们通过提交 mutation 的方式,而非直接改变 store.state.count ,是因为我们想要更明确地追踪到状态的变化。这个简单的约定能够让你的意图更加明显,这样你在阅读代码的时候能更容易地解读应用内部的状态改变。此外,这样也让我们有机会去实现一些能记录每次状态改变,保存状态快照的调试工具。有了它,我们甚至可以实现如时间穿梭般的调试体验。

React 整理

Webpack 整理

Gulp 整理