最近在阅读《你不知道的JS》一书,以下是我的读书笔记,包括但不限于书中内容。本文内容仅供分享交流,转载请注明出处。若存在理解有误之处,还请各位不吝赐教!
第一部分:作用域和闭包
Javascript 通常被视作解释型语言,但事实上它是一门编译语言。但与传统编译语言的区别在于:它不是提前编译的,编译结果也不能在分布式系统中进行移植。
- 对于传统编译语言来说,其代码在执行之前一般会经历 3 个步骤,统称为“编译”:
- 词法分析:将字符串形式的代码分解成有意义的代码块,这些代码块被称为词法单元(Token),最终会形成一个词法单元流(Tokens数组,每个 Token 中会包含语法片段、位置、类型等信息)
- 语法分析:将词法单元流(Tokens数组)转换成对应的“抽象语法树”(AST)
- 代码生成:将 AST 转换为可执行代码
- 对于 Javascript 来说,“编译”一般发生在代码执行的前一刻,并且JS引擎会采取各种办法对编译性能进行优化(比如JIT,可以延迟编译或重编译)。
作用域
MDN定义:作用域,即当前的执行上下文。
在JS中,每个函数的本质都是一个对象,该对象有一个[[Scopes]]
属性,且其仅供JS引擎存取。[[Scopes]]
属性的值则是我们所说的作用域链,其以数组的形式存储了相关的执行上下文的集合。该作用域链的最顶端(数组的第 0 项)为当前函数的执行上下文,即作用域。
执行上下文:当函数执行时,会创建一个名叫“执行上下文”的内部对象。一个执行上下文定义了一个在函数执行时的环境(即作用域),函数每次执行时对应的执行上下文都是独一无二的,所以多次调用一个函数会导致创建多个执行上下文,当函数执行完毕,它所产生的执行上下文被销毁。
JS中变量的查找,总是会从作用域链的最顶端依次向下查找(即从[[Scopes]]
数组的第 0 项依次往后查找)。
话到此处,需要知道JS中的三个重要“演员”:
- 引擎:负责整个JS程序的编译及执行过程
- 编译器:负责语法分析及代码生成等
- 作用域:定义了一套规则,用于确定在何处以及如何查找变量。具体的查找规则分为 LHS查询 和 RHS查询
- LHS查询(left-hand-side):查找的目的是 对变量进行赋值
- RHS查询(right-hand-side):查找的目的是 获取变量的值
- 注意:赋值操作符会导致LHS查询,
=
操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。
错误类型:ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
词法作用域
作用域一般有两种工作模型:
- 词法(静态)作用域:在写代码或者说定义时确定的,关注函数在何处被声明(被大多数编程语言采用)
- 动态作用域:在运行时确定的,关注函数在何处被调用(被Bash脚本、Perl中的一些模式等采用)
JS采用的是词法作用域。因此,JS中函数的作用域只由函数被声明时所处的位置决定,无论它在哪里被调用以及如何被调用。但是,JS中this
机制在某种程度上很像动态作用域。
JS中有两个机制可以“欺骗”词法作用域:eval(...)
和 with(obj) { 代码体 }
。前者可以将传入的字符串当做 JS 代码进行执行,并且会在运行时修改已经存在的词法作用域。后者会在运行时将obj
对象作为AO(即执行上下文)放在代码体的作用域链的最顶端。
上述两个机制的副作用为:JS引擎无法在编译时对作用域查找进行优化,因为JS引擎只能谨慎地认为这样的优化是无效的,从而导致代码运行变慢。因此,尽量不要使用它们!
函数作用域和块作用域
函数是JS中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来, 这是有意为之的良好软件的设计原则。
最小特权原则:又称“最小暴露原则”,指在软件设计中,应该最小限度地暴露必要内容,而将其他内容“隐藏”起来,比如:隐藏某个模块的具体实现,对外只暴露出对应的API接口。
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指{ ... }
内部)。
从ES3开始,try/catch
结构在catch
分句中具有块作用域。( with(){...}
也可! )
在ES6中,引人了let
关键字,用来在任意代码块中声明变量。if(...) { let a= 2; }
会声明一个劫持了if
的{ ... }
块的变量,井且将变量添加到这个块中。除let
以外,ES6还引入了const
关键字,可用来声明常量,一旦被赋值不可修改,且在声明时必须赋值。
有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。
提升(预编译)
JS的预编译发生在代码执行的前一刻,根据实际场景分为两种:函数预编译、全局预编译。
函数预编译的流程为:(发生在函数执行的前一刻)
- 创建AO对象(Activation Object,即执行上下文)
- 寻找形参和变量声明,将形参合变量名作为AO的属性名,值初始化为undefined
- 将实参值和形参值相统一
- 在函数体里面寻找函数声明,并将其名称作为AO的属性名,值初始化为函数体
全局预编译的流程为:(发生在全局代码执行的前一刻,和上述流程类似)
- 创建GO对象(Global Object,即全局上下文,可以将其理解为
window
对象) - 寻找变量声明,并将其作为GO的属性名,值初始化为undefined
- 寻找函数声明,并将其名称作为GO的属性名,值初始化为函数体
PS:“函数声明”整体提升,“变量”声明提升。
作用域闭包
-
闭包的定义:
当函数可以记住并访问它所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行的。
-
闭包的特点:
- 无论通过何种手段将内部函数传递到所在词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
- 在定时器、事件监听器、Ajax请求、跨窗口同行、WebWorkers或任何其他异步(或同步)的任务中,只要使用了回调函数,实际上就是在使用闭包。
- 闭包会导致原有作用域链未被及时释放,从而造成“内存泄漏”。
-
闭包的作用:
- 实现公用变量,eg:函数累加器
- 实现数据缓存
- 实现封装,私有化变量
- 模块化开发,防止污染全局变量
-
“循环”遇上“闭包”(转角遇到BUG)
for(var i = 0; i <= 5; i++) { setTimeout(() => { console.log(i) }, i * 10); } // 上述代码的期望输出结果为:0 1 2 3 4 5,但实际输出结果却为:6 6 6 6 6 6 // 究其原因,可以将上述代码理解为 var i; for(i = 0; i <= 5; i++) { setTimeout(() => { console.log(i); }, i * 10); } // for每一次循环时,作用域链上都会有一个共享的全局作用域,该作用域中只有一个变量i,每次i++时,都是对全局作用域里的变量i进行修改,而在循环过程中定义的setTimeout的回调会等到JS引擎将同步任务执行完毕之后,再依次调用执行。当该类回调被执行时,全局作用域中变量id的值已经变为6了,所以此时会全部输出6 // 此时,可以利用IIFE(立即执行函数)来解决该问题,代码如下 for(var i = 0; i <= 5; i++) { (function (i) { setTimeout(() => { console.log(i); }, i * 10); }(i)) } // ES6之后,可以利用 let关键字 优雅地解决该问题(强烈推荐) for(let i = 0; i <= 5; i++) { setTimeout(() => { console.log(i); }, i * 10); } // 原理如下: // 1. let关键字 可以用来劫持块作用域,并且在其内部声明一个变量 // 2. for循环头部的let声明还会有一个特殊行为。该行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
-
补充:“模块化”,即一种开发模式,该模式需要具备两个必要条件。
-
必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
-
封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
PS:一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
-
第二部分:this 和 对象原型
this
this
的指向只取决于函数的调用位置及方式,和函数声明的位置没有任何关系PS:可借助浏览器的 DevTools 分析函数的“调用栈”和“调用位置”
-
普通函数中的this指向:
- 直接调用函数,this指向全局对象
- 通过对象调用函数,this指向对象
- 如果通过new调用函数,this指向新创建的对象
- 如果通过apply、call、bind调用函数,this指向指定的数据
- 如果是DOM事件函数,this指向事件源
-
箭头函数中的this指向:
- 箭头函数中,不存在this、arguments、new.target,如果使用了,则使用的是函数外层的对应的this、arguments、new.target
- PS:箭头函数没有原型,不能作为构造函数来使用
“非严格模式”下:直接调用某个普通函数时,其内部的
this
指向window
;“严格模式"下:直接调用某个函数时,其内部的this
指向undefined
。注意:对于默认绑定(直接调用某个普通函数)来说,决定this指向的并不是调用位置是否处于“严格模式”,而是函数体是否处于“严格模式”。如果函数体处于严格模式,this会指向undefined,否则this会指向全局对象。
-
new 的原理
function myNew(constructor, ...args) { const obj = {}; obj.__proto__ = constructor.prototype; const res = constructor.apply(obj, args); return typeof res === 'object' ? res : obj; }
-
bind 的原理
// bind的原理 // 1.函数A调用bind方法时,需要传递的参数:O, x, y, z, ...,最后会回新的函数B // 2.函数B在执行的时候,具体的功能实际上还是使用的A,只不过其this的指向变成了O。--- 如果bind()里不传参数,则默认指向window // 3.函数B在执行的时候,传递的参数会拼接到x,y,z的后面,一并在内部传递给A执行 // 4.new B() 时,构造函数依旧是A,而且bind()中的参数O不会起到任何作用 Function.prototype.myBind = function (obj, ...args) { const func = this; const Temp = function () {}; const resFunc = function (...subArgs) { return func.apply(this instanceof Temp ? this : (obj || window), [...args, ...subArgs]); } Temp.prototype = func.prototype; resFunc.prototype = new Temp(); return resFunc; }
对象
typeof null === 'object'
Why?原理:不同的对象在底层都表示为二进制,在JS中二进制前三位都为0的话会被判断为object类型,null的二进制表示是全0,自然前三位也是0,所以执行
typeof null
时会返回"object"
。即便一个对象的某个属性是
configurable: false
,但还是可以在之后把writable
的状态有true
改为false
,但是无法有false
改为true
。
-
对象的深拷贝
function deepClone(origin, target = {}) { for (const prop in origin) { if (origin.hasOwnProperty(prop)) { if(typeof origin[prop] === 'object' && origin[prop] !== null) { if(Array.isArray(origin[prop])) { target[prop] = []; }else { target[prop] = {}; } deepClone(origin[prop], target[prop]); }else { target[prop] = origin[prop]; } } } return target; }
-
不可变性
实现对象及其属性的不可变性,有多种方法,但这些方法创建的都是“浅不可变性”。即这些方法只会影响目标对象和它的直接属性,如果目标对象的某个属性值为其他对象,则该其他对象不会受到影响。
-
对象常量
结合
writabte: false
和configurable: false
就可以创建一个真正的常量属性(不可修改、重定义或删除) :const obj = {}; Object.prototype.(obj, 'TEST_NUM', { value: 666, writable: false, configurable: false //此时,除了无法修改配置,还会禁止删除这个属性 });
-
禁止拓展
如果你想禁止一个对象添加新属性并且保留已有属性,可以使用
Object.preventExtensions(...)
:const obj = { a: 1 } Object.preventExtensions(obj); obj.b = 3; obj.b; // undefined
在非严格模式下,创建属性
b
会静默失败。在严格模式下,将会抛出 TypeError 错误。 -
密封
Object.seal(...)
会创建一个“密封”的对象。该方法实际上会在一个现有对象上调用Object.preventExtensions(...)
并把所有现有属性标记为conflgurable: false
。所以,密封之后不仅不能添加新属性,也不能重新配置或删除任何现有属性(虽然可以修改属性的值)。
-
冻结(较为常用)
Object.freeze(...)
会创建一个冻结对象,该方法实际上会在一个现有对象上调用Object.seal(...)
并把所有“数据访问”属性标记为writable: false
,这样就无法修改它们的值。该方法是你可以应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意直接属性的修改(不过就像我们之前说过的, 这个对象引用的其他对象是不受影响的)。
“深度冻结”一个对象的具体方法为:
首先在这个对象上调用
Object.freeze(...)
然后遍历它引用的所有对象并在这些对象上调用Object.freeze(...)
。但一定要小心, 因为这样做有可能会在无意中冻结其他(共享) 对象。 -
-
存取器属性
属性描述符中,如果配置了 get 和 set 中的任何一个,则该属性,不再是一个普通属性,而变成了存取器属性。
get 和 set 配置均为函数,如果一个属性是存取器属性,则读取该属性时,会运行get方法,将get方法得到的返回值作为属性值;如果给该属性赋值,则会运行set方法。
存取器属性最大的意义,在于可以控制属性的读取和赋值。
注意:“value、writable”和“存取器属性—get、set”不能共存,否则会报错!
-
存在性
a in obj
中的in
操作符:检查属性a
是否在对象obj
及其[[Prototype]]
原型链中obj.hasOwnProperty(a)
中的方法:检查属性a
是否在对象obj
中,不会检查obj
的原型链。上述的
obj
对象若是通过Object.create(null)
创建的,则不能按照预期调用hasOwnProperty
方法,此时可采用该方案强行调用:Object.prototype.hasOwnProperty.call(obj, 'a')
类
- “类”是一种设计模式。许多语言提供了对于面向类软件设计的原生语法。JS中也有类似语法,但是和其他语言中的“类”完全不同。
- 多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。
原型
-
对象的属性设置和屏蔽
当我们执行
obj.foo = 'bar'
为 obj 对象赋值时,其底层会经过如下流程:- 如果 obj 对象中包含名为 foo 的普通属性,则该赋值语句只会修改已有的属性值。
- 如果 foo 属性不是直接存在于 obj 中,[[Prototype]] 链就会被遍历,类似 [[Get]] 操作。
- 如果在原型链上找不到 foo,foo 就会被直接添加到 obj 上。
- 如果在原型链上找到了 foo,赋值语句
obj.foo = 'bar'
的行为则会有所不同。- 如果在原型链上存在名为 foo 的普通属性,并且没有被标记为只读(
writable:false
), 那就会直接在 obj 中添加一个名为 foo 的新属性,它是屏蔽属性。 - 如果在原型链上存在名为 foo 的属性,但是它被标记为只读(
writable:false
), 那么无法修改已有属性或者在 obj 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。 - 如果在原型链上存在名为 foo 的属性,并且它是一个 setter 属性,那就一定会调用这个setter。foo 不会被添加到 obj 中,也不会重新定义 foo 这一 setter 属性。
- 如果在原型链上存在名为 foo 的普通属性,并且没有被标记为只读(
注意:大多数开发者都认为如果给原型链上已经存在的属性赋值,就一定会触发屏蔽。但是如你所见,上述三种情况中只有第一种是这样的。
PS:若你希望在第二、三种情况下也屏蔽 foo 属性,那就不能使用
=
操作符来赋值,而是使用Object.defineProperty(...)
来给 obj 添加 foo 属性。
-
构造函数调用
// JS约定:“构造函数”的名称的首字母需要大写,以此与“普通函数”进行区分 function Foo() { // ... } const a = new Foo();
上述代码中,函数 Foo 和 其他的普通函数 并没有任何区别,即 函数 Foo 本身并不是构造函数。但是,当且仅当使用 new 时,函数调用会变成“构造函数调用”。
-
.constructor
属性function Foo() { /* ... */ } Foo.prototype = { /* ... */ }; //创建一个新的原型对象 const a1 = new Foo(); a1.constructor === Foo; // 结果:false a1.constructor === Object; // 结果:true
a1
并没有.constructor
属性,所以它会委托原型链上的Foo.prototype
,但该对象也没有.constructor
属性(不过,默认的Foo.prototype
对象有这个属性 )。所以它会顺着原型链继续委托,此时会委托给原型链顶端的Object.prototype
,该对象有.constructor
属性,且该属性指向内置的Object(...)
函数。原型上,系统自带有:constructor属性 (构造器) — 返回构造该对象的构造函数。但是,此属性可以通过 prototype 手动更改为其他的构造函数。因此,该属性是不可靠的,尽量避免使用!
-
检查“类”关系
-
instanceof
obj instanceof Foo; // 判断:obj 的原型链上是否存在 Foo.prototype
-
isPrototypeOf
a.isPrototypeOf(b); // 判断:b 的原型链上是否存在 a
-
补充
function Foo() { /* ... */ } const obj = new Foo(); obj.__proto__ === Foo.prototype; // 结果:true Object.getPrototypeOf(obj) === Foo.prototype; // 结果:true
上述代码中,
.__proto__
实际上并不存在于正在使用的 obj 对象中,而是存在于JS内置的Object.prototype
中,且不可枚举。其实现原理大致入下:Object.defineProperty(Object.prototype, '__proto__', { get() { return Object.getPrototypeOf(this); // ES6 API }, set(newObj) { Object.setPrototypeof(this, newObj); // ES6 API return newObj; } });
-
-
对象关联
-
Object.create(原型)
通常用来创建一个拥有指定隐式原型(即新对象.__proto__
)的对象。 -
原型是系统自带的隐式属性,如果没有原型,自己加的不管用,只能修改已有的原型。
-
绝大多数的对象最终都会继承自
Object.prototype
。但是,通过Object.create(null);
创建的对象没有原型,且其通常被称作“字典”,因其完全不会受到原型链的干扰,所以非常适合用来存储数据。 -
Object.create(原型)
在ES5之前的 polyfill 代码如下:if(!Object.create) { Object.create = function (tarObj) { function F() {} F.prototype = tarObj; return new F(); } }
由于
Object.create(原型)
还支持传入第二个参数(即一个属性描述符对象),而ES5之前的JS版本无法模拟属性描述符,所以上述 polyfill 代码无法实现该附加功能。但通常来说,并不会使用Object.create(原型)
的第二个参数,所以对于大多数开发者来说,上述 polyfill 代码就足够了。
-
行为委托
-
场景实践:创建UI控件(按钮、下拉列表、…)— 实例包含 jQuery 代码
-
“类”的方式
// 父类 function Widget(width, height) { this.width = width || 50; this.height = height || 50; this.$elem = null; } Widget.prototype.render = function ($where) { if (this.$elem) { this.$elem.css({ width: this.width + "px", height: this.height + "px" }).appendTo($where); } }; // 子类 function Button(width, height, label) { // 调用“super”构造函数 Widget.call(this, width, height); this.label = label || "Default"; this.$elem = $("<button>").text(this.label); } // 让Button“继承”Widget Button.prototype = Object.create(Widget.prototype); // 重写render(...) Button.prototype.render = function ($where) { // “super”调用 Widget.prototype.render.call(this, $where); this.$elem.click(this.onClick.bind(this)); }; Button.prototype.onClick = function (evt) { console.log("Button '" + this.label + "' clicked!"); } $(document).ready(function () { var $body = s(docunent.body); var btn1 = new Button(125, 30, "Hello"); var btn2 = new Button(150, 40, "world"); btn1.render($body); btn2.render($body); });
-
“类”的方式(ES6 语法糖)
class Widget { constructor(width, height) { this.width = width || 50; this.height = height || 50; this.$elem = null; } render($where) { if (this.$elem) { this.$elem.css({ width: this.width + "px", height: this.height + "px" }).appendTo($where); } } } class Button extends Widget { constructor(width, height, label) { super(width, height); this.label = label || "Default"; this.$elem = $("<button>").text(this.label); } render($where) { super.render($where); this.$elem.click(this.onClick.bind(this)); } onClick(evt) { console.log("Button '" + this.label + "' clicked!"); } } $(document).ready(function () { var $body = s(docunent.body); var btn1 = new Button(125, 30, "Hello"); var btn2 = new Button(150, 40, "world"); btn1.render($body); btn2.render($body); });
-
“行为委托”的方式
var Widget = { init: function (width, height) { this.width = width || 50; this.height = height || 50; this.$elem = null; }, insert: function ($where) { if (this.$elem) { this.$elem.css({ width: this.width + "px", height: this.height + "px" }).appendTo($where); } } }; var Button = Object.create(Widget); Button.setup = function (width, height, label) { // 委托调用 this.init(width, height); this.label = label || "Default"; this.$elem = $("<button>").text(this.label); }; Button.build = function ($where) { //委托调用 this.insert($where); this.$elem.click(this.onclick.bind(this)); }; Button.onclick = function (evt) { console.log("Button '" + this.label + "' clicked!"); } $(docunent).ready(function () { var $body = $(docunent.body); var btn1 = Object.create(Button); btn1.setup(125, 30, "Hello"); var btn2 = Object.create(Button); btn2.setup(150, 40, "world"); btn1.build($body); btn2.build($body); });
-
-
“类”和“行为委托” —— 两种设计模式的区别
“类” | “行为委托” | |
---|---|---|
理论比较(编码风格) | 面向对象 | 对象关联 |
思维模型比较 | 强调实体及实体间的关系 | 强调对象之间的关联关系 |
实践比较 | 对象的创建和初始化通常会被合并在一起,使用时更加方便 | 对象的创建和初始通常会被分隔开,使用时较繁琐,但更加灵活(更好地支持“关注点分离”) |
-
归纳总结
在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是唯一(合适)的代码组织方式,但是本章中我们看到了另种更少见但是更强大的设计模式:行为委托。
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JS 的 [[Prototype]] 机制本质上就是行为委托机制。也就是说,我们可以选择在 JS 中努力实现“类”机制,也可以拥抱更自然的 [[Prototype]] 委托机制。
当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。
“对象关联”是一种编码风格,它倡导的是直接创建和关联对象,不把它们抽象成类。对象关联可以用基于 [Prototype]] 的行为委托非常自然地实现。
PS-1:在对象中使用 ES6 提供的“方法速写”(语法糖)时,如果需要在方法(或函数)内部进行自我引用,最好改用传统的具名函数表达式来定义对应的方法,不要使用该语法糖。
const Foo = { bar(x) { if(x < 5) { return Foo.bar(x * 2); } } } // 上述“自我引用”的方案并不能适用于所有情况,建议使用以下方案 const Foo = { bar: function bar() { if(x < 5) { return bar(x * 2); } } }
PS-2:在面向“类”的程序中,自省,即检查实例的类型。类实例的自省,主要目的是通过创建方式来判断对象的结构和功能。