读书笔记:《You Don't Know JS》
2022-08-12· 25min
#作用域
- 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS(Left-Hand Side)查询;如果目的是获取变量的值,就会使用 RHS(Right-Hand Side)查询。
var s = 2; // // 此时,s 是 LHS 查询
console.log(s); // 此时,s 是 RHS 查询
- 可访问变量的集合,函数或变量的可见区域
#编译原理
- 分词/词法分析:将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)
- 解析/语法分析:词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)
- 代码生成:将 AST 转换为可执行代码的过程
#按查找规则
#词法作用域
- 定义在词法阶段的作用域,在代码编写时确定,由代码嵌套结构决定。变量查找是从当前作用域向上查找,直到全局作用域
#动态作用域
- 变量查找是在函数调用时根据调用栈来决定,而不是代码结构(比如 this)
#按作用域的范围
#全局作用域
- 在函数外部定义的的变量可以在任何地方被访问
var aa = 1;
function AA() {
console.log(aa);
}
AA(); // 1
#局部作用域
- 函数作用域,函数内部声明的变量,只能在函数内部被访问
function FF() {
var ff = 2; // ReferenceError: ff is not defined
}
console.log(ff);
#块级作用域
- 在 { .. } 内使用 let/const 声明变量,只在 { .. } 内有效
if (true) {
const OK = 3;
}
console.log(OK); // ReferenceError: OK is not defined
- 暂时性死区,在块级作用域下声明变量前,变量不可用
if (true) {
console.log(KK); // ReferenceError: Cannot access 'KK' before initialization
let KK = 4;
}
#调用栈
- JS 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各个函数之间的调用关系。
- 当一个函数被调用时,调用栈会添加一个栈帧(包含函数执行的信息),当函数执行完并返回时,栈帧会从调用栈移除
#作用域链
- 当访问一个变量时,基于调用栈,从当前作用域向上查找,直到找到或到达全局作用域为止
#执行上下文
- 当前 JS 代码被解析和执行时所在的环境,也叫作执行环境
- 三种类型:全局执行上下文、函数执行上下文、eval 执行上下文
#this
- this指向函数运行时的上下文,this的值取决于函数如何被调用
- 作用:this能让对象中的函数访问对象自己的属性,有效提高代码质量
- 绑定规则
- 默认绑定:函数独立调用,无其它规则,this 指向 window
- 隐式绑定:当函数引用有上下文对象时,该规则会把函数调用中的 this 绑定到这个上下文对象
- 隐式丢失:函数被多个对象链式调用(如传参函数),此时函数的 this 指向就近对象
- 显式绑定:如使用 bind、call、apply 等方法绑定到目标对象上
- new绑定:使用构造函数创建对象,构造函数内部的 this 会绑定到新创建的对象实例上
- 例子1
function tea() {
console.log(this); // 默认绑定
const cc = () => {
console.log(this); // 箭头函数无 this,此处 this 指向 tea(即全局对象 window)
};
cc();
}
tea(); // Window、window
let test = {
name: "EEE",
e: function () {
console.log(this.name); // 隐式绑定
},
e1: function () {
console.log(this);
},
e2: function () {
const f1 = test.e1;
f1(); // 作为一个普通函数调用,this 指向全局对象
},
e3: function () {
const f2 = test.e1.bind(test); // 显式绑定
f2();
},
};
test.e(); // EEE
test.e2(); // Window
test.e3(); // test 对象
- 例子2
let n = "111";
let obj = {
n: "222",
log: function () {
console.log(this.n, this);
},
};
obj.log(); // 222, obj
// PS:在 obj.log 中使用的 this.n 是从 this 上查找,setTimeout 调用时,this 指向全局对象,n 是一个变量而不是属性,因此 this.n 是 undefined
setTimeout(obj.log, 0); // undefined, window
setTimeout(obj.log.bind(obj), 0); // 222, obj
#apply、call、bind区别
- 三者都可以改变 this 对象指向(apply、call 临时改变this指向一次,bind 返回永久改变this的函数)
- 都可以传参(apply 传数组、call 传参数列表、bind 可分多次传入)
- apply、call 是立即执行,bind 是返回绑定 this 之后的函数
- apply 例子:
function aaa(...args) {
console.log(this, args);
}
let obj = {
name: "AAA",
};
aaa.apply(obj, [1, 2, 3]); // aaa 对象, [1,2,3]
aaa(1, 2, 3); // Window, [1,2,3]
- call 例子:
function ccc(...args) {
console.log(this, args);
}
let obj = {
name: "CCC",
};
ccc.call(obj, 1, 2, 3); // ccc 对象, [1,2,3]
ccc(1, 2, 3); // Window, [1,2,3]
- bind 例子:
function bbb(...args) {
console.log(this, args);
}
let obj = {
name: "BBB",
};
let bbbFn = bbb.bind(obj);
bbbFn(1, 2, 3); // bbb 对象, [1,2,3]
bbb(1, 2, 3); // Window, [1,2,3]
#原型和原型链
#构造函数
- 函数被 new 关键字调用时就是构造函数
#原型
- prototype,每个函数都有一个 prototype(原型)属性,使用原型的好处是可以让所有对象实例共享它所包含的属性和方法
function Fa() {}
Fa.prototype.name = "nnn";
Fa.prototype.logName = function () {
console.log(this.name);
};
let bb1 = new Fa();
bb1.logName(); // nnn
#函数对象
- 使用 function 关键字或 Function 构造函数创建的对象都是函数对象,只有函数对象才拥有 prototype (原型)属性。
let o1 = {};
let f1 = function () {};
let f2 = new Function("", "");
console.log(o1.prototype, f1.prototype, f2.prototype); // undefined, { constructor }, { constructor }
console.log(Object.getPrototypeOf(o1) === Object.prototype); // true( o1的内部原型 [[Prototype]] )
console.log(o1.__proto__ === Object.prototype); // true
#原型对象
- 每个原型对象都有一个 constructor(构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针
let f3 = new Function("", "");
console.log(f3.prototype.constructor === f3);
- __proto__ (隐式原型):每个对象都有一个__proto__属性,并且指向它的 prototype(原型)
let oo = {};
console.log(oo.__proto__ === Object.prototype); // true
let ff = function () {};
console.log(ff.__proto__ === Function.prototype); // true
// 例外, Object.create 用来创建继承关系
var oon = Object.create(null); // 传入的 null,没有继承关系,所以如下
console.log(oon, oon.__proto__); // {} undefined
#new 操作符
- 内部实现机制:
- 一个新的空对象被创建并分配给 this
- 函数体执行。通常它会修改 this,为其添加新的属性
- 返回新对象(返回 this 的值)。
- 箭头函数:不能被当做构造函数、不能使用 new 关键字构造实例对象,原因如下
- 没有原型 prototype(new内部需要调用原函数的 prototype 属性)
- this 指向定义时所在的对象(从父作用域中继承 this),而不是使用时所在的对象(故 call、bind、apply 不好使)
- 没有自己的 arguments 对象,即使在箭头函数中调用 arguments 对象,引用的也只是父作用域中的 arguments 对象
- 没有 yield 关键字,不能用作 Generator 函数
#构造函数和原型结合
- 构造函数用于定义实例属性,原型用于定义共享的属性和方法
function Fa2(name, age) {
this.name = name;
this.age = age;
}
Fa2.prototype.logName = function () {
console.log(this.name);
};
let aa1 = new Fa2("Tom", 20);
let aa2 = new Fa2("Jonny", 21);
// 每个实例都会有自己的一份实例属性的副本
console.log(aa1.name, aa2.name); // Tom, Jonny
// 同时又共享着对方法的引用
console.log(aa1.logname === aa2.logname); // true
#原型链
- 由相互关联的原型组成的链状结构就是原型链
- 基于构造函数、实例和原型的关系
- 每个构造函数都有一个原型对象,原型对象包含一个指向构造函数的 constructor 属性
- 每个实例(对象)都包含一个指向原型对象的 __proto__ 指针
- 原型链的尽头 Object.prototype.__proto__ ,为null
Object.prototype.constructor.__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true
#闭包
- 函数内部可以访问函数外部的参数和变量,外部函数执行完毕,但内部函数对外部函数中的变量依然存在引用,这些被引用的变量的集合就是闭包
- 自由变量的查找,在函数定义的地方,向上级作用域查找(不是在执行的地方)
- 优点:变量私有化(避免全局污染)、延长变量的生命周期
- 缺点:导致变量不被垃圾回收机制清除(消耗内存)、不恰当使用会造成内存泄漏
- 场景:
- 例子1
for (var i = 0; i < 5; i++) {
// PS:setTimeout 函数会在所有的循环迭代完成后才执行。详看「事件循环」
setTimeout(function () {
console.log(i); // 5, 5, 5, 5, 5
}, 0);
}
// 闭包
for (var i = 0; i < 5; i++) {
// PS:IIFE 创建一个新的作用域,每次循环迭代时,当前的 i 值被传递给 IIFE 的参数 j,并在 IIFE 内部保存
(function (j) {
setTimeout(function () {
console.log(j); //0, 1, 2, 3, 4
}, 0);
})(i);
}
// 给页面上多个 DOM 循环绑定事件
for(var i = 0; len = btns.length; i<len; i++) {
(function(j){
btns[j].inclick = function() {
alert(j)
}
})(i)
}
- 例子2
function test(n, o) {
console.log(o);
return {
fun: function (m) {
return test(m, n);
},
};
}
var a = test(0); // undefined
a.fun(0); // 0
a.fun(1); // 0
- 柯里化:把一个多参数的函数转化成单参数函数的方法
- 高阶函数:函数可以作为参数传递 && 函数可以作为返回值输出
// 方式一:
function currying() {
// 类数组对象 -> 数组
var _args = [...arguments];
// 保存所有参数值(闭包)
var _fn = function () {
_args.push(...arguments);
return _fn;
};
// 当一个对象参与算术运算时,valueOf 方法会被调用
_fn.valueOf = function () {
return _args.reduce((acc, val, index, arr) => acc + val, 0);
};
return _fn;
}
console.log(+currying(1, 2)(3)); // 6
console.log(+currying(4)(5)(6)); // 15
console.log(+currying(7, 8, 9)); // 24
// 方式二:
function currying2(fn, ...args) {
const len = fn.length;
return function (...params) {
const _args = [...args, ...params];
if (_args.length < len) {
return currying.call(this, fn, ..._args);
}
return fn.apply(this, _args);
};
}
function add(a, b, c, d) {
return a + b + c + d;
}
const curryingAdd = currying2(add);
curryingAdd(1)(2)(3)(4); // => 10
curryingAdd(1, 2)(3)(4); // => 10
curryingAdd(1, 2, 3)(4); // => 10
#事件循环(EventLoop)
- 前置知识
- 单线程,同一时间只能执行一个任务。即所有的任务都需要排队,前一个任务结束,才会执行后一个任务
- 栈:后进先出的数据结构(LIFO)。数据元素的入栈(Push)、出栈(Pop)都是在栈顶进行操作
- 队列:先进先出的数据结构(FIFO)。数据元素在末尾入队(Enqueue)、在开头出队(Dequeue)
- 任务队列:一个事件的队列,若经过回调函数,这些事件会进入任务队列,等待主线程读取
- 回调函数:被主线程挂起来的任务。异步任务指定的回调函数,当主线程开始执行异步任务时,就会执行对应的回调函数
- JS,单线程语言
- 执行任务
- 同步任务:在主线程排队执行的任务
- 异步任务:不进入主线程,进入任务队列的任务(当任务队列通知主线程,异步任务可以执行时才会进入主线程执行)
- 宏任务(macrotask):需较长时间完成的任务,如:整体代码 script、I/O 操作、setTimeout/setInterval 回调函数
- 微任务(microtask):需较短时间完成的任务,如:async/await、Promise.then(非new Promise)、process.nextTick(node)
- 执行顺序:
- 当处于任务队列,微任务优先级 > 宏任务
- 当执行宏任务,若产生了新的微任务,会被放入到当前微任务队列,在当前宏任务执行完后被顺序执行
- 当执行微任务,若产生了新的微任务,会在当前循环继续执行
- ES6规范中,microtask 称为 jobs,macrotask 称为 task,宏任务是由宿主发起的,而微任务由JavaScript自身发起。
- 如何添加宏任务和微任务
- 宏任务:setTimeout(fn)
- 微任务:queueMicrotask(fn)、Promise.resolve().then(() => { console.log('xxx'); });
- 执行任务
- 事件循环原理
- 事件循环是在处理异步事件时进行的一种循环过程,异步事件会先加入到事件队列中被挂起,等主线程空闲时才会去执行事件队列中的事件
- 事件的执行顺序,是先执行宏任务,然后执行微任务,这个是基础
- 所有代码作为宏任务进入主线程执行栈(执行栈选择最先进入队列的宏任务一般都是 script)
- 同步代码会立即执行,遇到异步任务时,宏任务进入宏任务队列,微任务进入微任务队列
- 当执行栈中的同步任务被执行完毕,主线程会读取任务队列中的事件(执行当前宏任务之前,先读取微任务队列,有则执行微任务,没有则执行宏任务)
- 本轮宏任务执行完成,回到第2步,一直循环,直到任务队列清空..
- 例子1
Q: setTimeout 和 Promise.then 谁先执行?
A: Promise.then 先执行,因为 setTimeout 是宏任务,Promise.then 是微任务,在执行宏任务之前先清空微任务。
- 例子2
console.log(1);
// PS:setTimeout 回调进入异步任务中的宏任务队列,先执行同步任务,再执行宏任务。
setTimeout(function () {
console.log(2);
}, 0);
// PS:创建一个新的 Promise 时,传递的 executor 函数给 Promise 构造函数,这个 executor 函数会立即同步执行
new Promise((resolve, reject) => {
console.log(3);
resolve();
}).then((res) => {
console.log(4);
});
console.log(5);
// 1, 3, 5, 4, 2
- 例子3
setTimeout(() => {
console.log(1);
Promise.resolve().then(() => {
console.log(2);
});
new Promise((resolve, reject) => {
console.log(3);
resolve();
}).then((res) => {
console.log(4);
});
}, 0);
console.log(5);
// 5, 1, 3, 2, 4
- 例子4
for (var i = 0; i < 2; i++) {
setTimeout(function () {
console.log("异步任务", i);
}, 1000);
}
console.log("同步任务", i);
// 同步任务 2
// 异步任务 2、异步任务 2