夫大人者,与天地合其德,与日月合其明,与四时合其序,与鬼神合其吉凶。——《周易》

在 JavaScript 中, (a == 1 && a == 2 && a == 3) 是否有可能为 true

参考解决思路:a是一个对象或函数,每次调用取值都不一样,以有序的规律变化就能实现多等

方案1:使用 getter

1
2
3
4
5
6
7
let temp = 1;
Object.defineProperty(global, 'a', {
get() {
return temp++;
}
});
console.log(a === 1 && a === 2 && a === 3); // true

方案2:重写valueOf/toString

1
2
3
4
5
6
7
const a = {
value: 1,
valueOf() {
return this.value++;
}
};
console.log(a == 1 && a == 2 && a == 3); // true

从表面看,应该是valueOf()每次都被调用了,但是为什么会这样?我们又没有调用它。这里的valueOf为什么会被调用?这主要是==转换规则(换成 ===,这种方式就不成立了)

  • 如果一个是null,一个是undefined,则它们相等
  • 如果一个是数字,一个是字符串,先将字符串转换成数字,然后使用转换后的值进行比较
  • 如果其中的一个值为true,则转换成1再进行比较;如果其中一个值为false,则转换成0再进行比较
  • 如果一个值是对象,另一个值是数字或者字符串,则将对象转换成原始值再进行比较。转换成字符串时,会先调用toString(),如果没有toString()方法或者返回的不是一个原始值,则再调用valueOf(),如果还是不存在或者返回不是原始值,则会抛出一个类型错误的异常。返回的原始值会被转换成字符串;如果转换成数字时,也是类似的,不过是会先调用valueOf(),再调用toString(),返回的原始值会被转换成数字
  • 其他不同类型之间的比较均不相等

所以在这里使用 a 与这些字符进行比较时会被转换成数字,此时会默认调用字符串的valueOf()方法,我们将这个方法进行重写,用于拦截处理a的值

同理可以使用toString方法处理,因为字符串转数字类型时会涉及到valueOf()toString(),道理一样,只要符合递增规则的,a就可以实现多等,因为此a非彼a

方案3:ES6 Proxy

1
2
3
4
const a = new Proxy({i: 0}, {
get: (target, name) => name === Symbol.toPrimitive ? () => ++target.i : target[name]
});
console.log(a == 1 && a == 2 && a == 3); // true

Symbol.toPrimitive是一个内置的Symbol值,用于定义对象在被转换为原始值(如数字、字符串)时的行为。当一个对象需要被转换为原始值时,JavaScript 会调用对象的Symbol.toPrimitive方法(如果存在)。

当 a 参与 == 比较的时候,js 会尝试将 a 转化为原始值,由于 a 是一个对象,js 会调用 a 的 a[Symbol.toPrimitive] 方法,get 拦截器返回的函数会被调用,返回 target.value 的当前值,并将 value 递增。

方案4:使用不可见字符

1
2
3
4
5
var  a = 1;
var1 = a;
var2 = a;
var3 = a;
console.log( a ===ᅠ1 && a ===ᅠ2 && a ===ᅠ3 );

这些变量的名字看起来像是数字,但实际上它们的名字中包含了不可见的 Unicode 字符(例如零宽空格或其他不可见字符),这些字符在代码中不可见,但 JavaScript 引擎会将其视为变量名的一部分。

1
2
console.log(encodeURIComponent("ᅠ1")); // 输出 "%EF%BE%A01"
console.log("ᅠ1".charCodeAt(0)); // 输出 65408

方案5:join+shift

对于对象数组进行比较时,这里数组 a 每次比较的时候都会默认调用toString(),然后toString()又会默认调用join(),这里将join()的实现改为shift(),意思是删除第一个数组元素值并返回。这样每次调用都会导致 a 数组删除第一个值并且返回删除掉的那个值,结合这样的规律,每次比较都取出对应位置的值。

1
2
3
4
5
6
7
8
9
10
var a = [1, 2, 3];
a.join = a.shift;
console.log(a); // [ 1, 2, 3, join: [Function: shift] ]
// console.log(a == 1 && a == 2 && a == 3); // true
console.log(a == 1); // true
console.log(a); // [ 2, 3, join: [Function: shift] ]
console.log(a == 2); // true
console.log(a); // [ 3, join: [Function: shift] ]
console.log(a == 3); // true
console.log(a); // [ join: [Function: shift] ]

数据类型

1
2
3
4
5
6
7
typeof [] // 'object'
[] == [] // false,不用的引用
typeof null // 'object'
typeof(()=>1) // 'function'
typeof undefined // 'undefined'
typeof Array // 'function'
typeof(new Number(1)) // 'object'

根据 w3c 标准,分为基本数据类型(7)和对象(1)。基本类型: string(1), number(2), null(3), undefied(4), boolean(5), bignumber(6), symbol(7)。对象:object(1)。

数组和函数都是 object 类型。

1
2
3
4
5
6
7
8
9
10
var arr = []
arr[0] = 1
arr['0'] = 2
arr['aaa'] = 3
arr['1'] = 555
console.log(arr[0]) // 2
console.log(arr['0']) // 2
console.log(arr.length) // 2
console.log(arr[0] + arr['0']) // 4
console.log(Object.keys(arr)) // [ '0', '1' ,'aaa']

js 中的数组本质上就是对象,意味着可以用数字索引来访问元素,也可以使用字符串键来访问属性。当使用字符串键的时候,如果该字符串可以转成有效的数字索引,则将其视为数字索引,因此 arr[1]arr['1'] 是一个东西。数组的 length 只反映基于数字索引(也包含可以隐式转换成数字)的实际元素的数量。所以虽然 arr 对象有 3 个 key,其长度还是 2。

Object.is 用于比较两个值是否完全相等,解决了 NaN0-0 使用 === 存在的问题。

1
2
3
4
5
6
NaN === NaN // false
Object.is(NaN, NaN) // true

0 === -0 // true
Object.is(0, -0) // false
Object.is(0, +0) // true

instanceof 运算符

只有对象才能使用这个运算符(基础类型不能使用),会进行原型链的查找,因此有下面的结果:

1
2
3
4
null instanceof Object // false, typeof null 返回 object 的原因是历史原因
1 instanceof Number // false, 1 是基本类型
Number(1) instanceof Number // false,Number(1) 是函数调用,返回基本类型 1
new Number(1) instanceof Number // true,此时才是对象

原始类型和其包装类

字符串是原始类型,它的行为和对象不同。虽然可以向字符串添加属性,但是这些属性不会持久化,并且在某些操作中会被忽略

1
2
3
4
5
6
7
8
const s = '123'
s.c = '4'
s.d = '5'
console.log('s:', s) // 123
console.log(`s.c: ${s.c}, s.d: ${s.d}`); // undefined, undefined
const [a,b] = s
const {c,d} = s
console.log(a,b,c,d) // 1 2 undefined undefined

s.c = '4', s.d = '5' 这两句话在试图给一个字符串添加属性的时候,js 会创建一个临时的对象包装器来处理这个操作,但是这个对象包装器会在操作完成后立即被销毁。因此第 5 行输出 s.a 和 s.b 都是 undefined。接下来第 6 行是数组的解构赋值,将字符串 s 当做一个可迭代对象,并逐个提取字符。因此 a:’1’, b:’2’。再接下来第 7 行使对象解构,尝试从字符串 s 中提取属性 c 和属性 d。由于字符串没有这两个属性,则 c 和 d 都是 undefined

如果把这个题目改一改 s 并不是原始的字符串,而是String对象则情况如下:

1
2
3
4
5
6
7
8
const s = new String('123') // 这个会输出 1 2 4 5
s.c = '4'
s.d = '5'
console.log('s:', s) // 123
console.log(`s.c: ${s.c}, s.d: ${s.d}`); // 4, 5
const [a,b] = s
const {c,d} = s
console.log(a,b,c,d) // 1 2 4 5

隐式转换

1
2
3
0 == '' // true,隐式转换,字符串 -> 数字
0 == [] // true,隐式转换,对象 -> 原始值(首先调用toString,然后是valueOf,对于数组的 toString,默认是 join 方法,空数组 join 是空字符串,从而走回了字符串和数字的比较)
0 == [''] // true,和上面的情况同理

优先级:Symbol.toPrimitive > valueOf > toString

1
2
3
4
5
6
7
8
9
10
11
const obj = {
[Symbol.toPrimitive](hint) {
if (hint === 'number') return 42;
if (hint === 'string') return 'foo';
return 'default';
}
};

console.log(+obj); // 42(hint 为 "number")
console.log(`${obj}`); // "foo"(hint 为 "string")
console.log(obj + ''); // "default"(hint 为 "default")
1
2
3
4
[] + [] // '' -> toPrimitive -> valueOf -> [] -> toString -> ''
[] + {} // '[object Object]'
// 空数组会经过 toPrimitive, valueOf, toString 转为空字符串
// 空对象会经过 toPrimitive, valueOf -> {} -> toString 转为 '[object Object]'

浮点数的精度问题

在编程的时候要慎用 toFixed,因为它可能会欺骗我们。

1
2
3
4
console.log(2.55.toFixed(1)); // 2.5
console.log(1.45.toFixed(1)); // 1.4
console.log(1.55.toFixed(1)); // 1.6
console.log(3.55.toFixed(1)); // 3.5

从上面的结果来看,规则非常模糊,上取整、下取整、四舍五入都有可能发生。造成以上结果的原因是:浮点数的存储、运算、显示这 3 个步骤都有可能不精确的!

  1. 存储:0.3 不精确,0.3.toPrecision(30),0.2.toPrecision(30),有的数偏小,有的偏大
  2. 运算:0.3 - 0.2,因为 0.3 存储偏小,0.2 存储偏大,减法运算会将误差进一步扩大!也有可能运算将不精确缩小,甚至没有了! 0.3+0.2 = 0.5
  3. 显示:会在一定误差内做近似处理

浮点数的整数位数占用的位数会影响小数部分的精度,截断的时候可能少截一位导致数偏小,也有可能多一位导致数变大!

1
2
3
4
5
6
2.45.toFixed(1) // '2.5'
1.45.toFixed(1) // '1.4'

// 查看各自的高精度表示,toFixed(1) 会对第二位小数进行四舍五入
2.45.toPrecision(20) // '2.4500000000000001776'
1.45.toPrecision(20) // '1.4499999999999999556'

原型链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person(name) {
this.name = name
}
const p1 = new Person('张三')
console.log(p1.__proto__ === Person.prototype) // true
console.log(Person.prototype.constructor === Person) // true

console.log(Person.__proto__ === Function.prototype) // true
console.log(Function.prototype.__proto__ === Object.prototype) // true
console.log(Object.prototype.__proto__ === null) // true

console.log(Object.__proto__ === Function.prototype) // true
console.log(Function.__proto__ === Function.prototype) // true

console.log(Object.__proto__ === Function.__proto__) // true

所有函数的 __proto__ 都指向同一个原型对象 Function.prototypeObject 本身是一个内置的构造函数(用于创建普通对象),作为函数,它的 __proto__ 指向 Function.prototypeFunction 也是一个构造函数,Function是构造所有函数的基类(包括它自身)。Function.__proto__ 同样指向 Function.prototype

原型链示意图

为什么需要原型?JS 语言需要实现面向对象,而原型是面向对象的实现手段之一。一个面向对象的语言必须做到这一点:能判断一个实例的类型。在 JS 中通过原型就可以知道某个对象属于哪个类型,换句话说:原型的存在避免了类型的丢失

所有的数组,对象,函数(因为他们都是 w3c 中的 object 类型)都有一个__proto__,被称为隐式原型。所有的函数都有一个prototype属性,被称为显式原型。prototype是函数的一个属性而已,它和原型没有绝对的关系,每个对象都有一个原型,但是只有函数才会有prototype属性。

1
2
3
4
5
6
7
var a = function(){};
var b=[1,2,3];

//函数才有prototype属性
console.log(a.prototype);//>> function(){}
//非函数,没有prototype属性
console.log(b.prototype);//>> undefined

每个实例对象都有一个属性__proto__,指向他的构造函数(constructor)的 prototype 属性(原型对象),一个对象的原型就是它的构造函数的 prototype 属性的值。

1
2
3
4
5
// 隐式原型等于其构造函数上的显式原型
const obj = {}
obj.__proto__ === Object.prototype
const arr = []
arr.__proto__ === Array.prototype

当一个对象的属性不存在的时候会向其构造函数的显式原型中查找(即该对象本身的隐式原型)。对象的__proto__也有自己的__proto__,层层向上,直到__proto__null。换句话说,原型本身也有自己的原型。这种由原型层层链接起来的数据结构成为原型链。因为null不再有原型,所以原型链的末端是null

使用__proto__是有争议的,也不鼓励使用它。因为它从来没有被包括在 EcmaScript 语言规范中,但是现代浏览器都实现了它。__proto__属性已在 ECMAScript 6 语言规范中标准化,用于确保Web浏览器的兼容性,因此它未来将被支持。但是,它已被不推荐使用,现在更推荐使用 Object.getPrototypeOf/Reflect.getPrototypeOfObject.setPrototypeOf/Reflect.setPrototypeOf(尽管如此,设置对象的原型是一个缓慢的操作,如果性能要求很高,应该避免设置对象的原型,应该尽量使用 Object.create() 来为某个对象设置原型)。

1
2
3
4
5
6
7
8
9
10
11
// obj 的隐式原型是 Object.prototype
var obj={
methodA(){
console.log("coffe");
}
}

var newObj = Object.create(obj);//以obj为原型创建一个新的对象

//methodA实际上是newObj原型对象obj上的方法。也即newObj继承了它的原型对象obj的属性和方法。
newObj.methodA();//>> coffe

面试题:说一下你对原型和原型链的理解

标准答案:js 中每个对象都有其原型对象(隐式原型),通过 __proto__ 可以找到该对象的原型对象,原型对象自己本身也有自己的原型对象,从而形成 1 条链条,这就是原型链。原型链的顶端是null

加分点 1:和其他语言进行对比。
生成对象一般有两种:

  1. 基于类创建对象:java,c++,python
  2. 基于已有对象生成对象:Self,Io,js
    其实在 js 语言创始时候(1996),基于类创建对象是主流,但是 js 为了实现起来简单(历史原因)选择了第 2 种

加分点 2:从数据结构上进行拓展。
原型链的本质是单链表。之所以没有采用双链表是因为我们大多数场景是需要为子类找到父类,通过父类找子类 1 方面需求几乎没有,2 来实现繁琐性能不高。

创建对象的几种方法

  1. 对象字面量
  2. 显式使用构造函数
  3. Object.create
1
2
3
const o = {}
// 上面的是使用对象字面量创建对象,其实是和下面的方法类似
const oo = Object.create(Object.prototype)
1
2
3
4
5
6
7
8
9
10
11
12
const o1 = {name:'a'}
const o11 = new Object({name:'a'})

const F = function (name) {this.name = name} // F.prototype.constructor === F
const o2 = new F('a') // o2.__proto__ === F.prototype, F.__proto__ === Function.prototype
// F 这个函数对象是 Function 的实例
// o2 instanceof F // true
// o2 instanceof Object // true
// F.prototype.__proto__ === Object.prototype // true

const P = {name:'a'}
const o3 = Object.create(P)

只有函数有 prototype,只有对象有__proto__。函数也是对象,因此它也有 __proto__

实现继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Parent(name) { this.name = name }
Parent.prototype.getName = function () { return this.name }

function Child(name) {
Parent.call(this, name) // 调用父类的构造函数
this.age = 10
}
// 让 Child 的原型对象继承 Parent 的原型对象 -> Child 的实例就可以访问 Parent 的原型方法
// 不直接使用 `Child.prototype = Parent.prototype` 因为那样会导致修改 Child.prototype 时也影响 Parent.prototype(引用相同对象,子类添加新方法会影响父类)
// 不直接使用 `Child.prototype = new Parent()`,因为这样会执行 Parent 构造函数(父类的构造函数执行了 2 次),可能带来不必要的副作用(如修改全局状态)
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child // 修正 Child.prototype 的 constructor 属性(原先指向 Parent),使其正确指向 Child 构造函数

// 测试
const o = new Child('child')
console.log(o) // Child { name: 'child', age: 10 }
console.log(o instanceof Parent) // true
console.log(o instanceof Child) // true
console.log(o.constructor) // [Function: Child]

在上面的代码中 Object.create(Parent.prototype) 创建了一个新对象,并将这个新对象的 [[Prototype]](即 __proto__ )指向 Parent.prototype。这样 Child.prototype 可以访问 Parent.prototype 的方法,但修改 Child.prototype 不会影响 Parent.prototype。执行完上述代码之后 Child.prototype.__proto__ === Parent.prototype

基于这个原理,我们可以抽象出继承工具函数:

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
function extend(subClass, superClass) {
// 改进:添加一个空函数并将其创建的对象实例插入到原型链中,因为超类的实例可能比较大或者包含比较多的运算
const F = function () { };
F.prototype = superClass.prototype;
subClass.prototype = new F();
subClass.prototype.constructor = subClass;

// 改进引用父类的方式,从而子类中可以使用 `子类名.superClass_`得到父类的名称,比起Person.call(this, name)更具有通用性,同时有了superClass_也可以直接调用超类中的方法
subClass.superClass_ = superClass.prototype;
if (superClass.prototype.constructor === Object.prototype.constructor) {
superClass.prototype.constructor = superClass;
}
}

function Person(name) {
this.name = name;
}
Person.prototype.getName = function () {
return this.name;
};

function Author(name, books) {
this.books = books;
Author.superClass_.constructor.call(this, name);
}
extend(Author, Person);
Author.prototype.getBooks = function () {
return this.books;
}
Author.prototype.getName = function () {
const name = Author.superClass_.getName.call(this);
return name + '->' + this.getBooks().join(',');
};

const a = new Author('村上春树', ['挪威的森林', '1Q84'])
const info = a.getName();

在 node 中可以使用 util.inherits 来简化继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person(name) {
this.name = name;
}
Person.prototype.getName = function () {
return this.name;
};

function Author(name, books) {
Person.call(this, name);
this.books = books;
}
Author.prototype.getBooks = function () {
return this.books;
}
require('util').inherits(Author, Person);

nodejs 中实现的原理:

1
2
3
4
5
6
7
8
9
10
11
exports.inherits = function(ctor, superCtor) {
ctor.super_ = superCtor; // 存储父类引用(方便调用父类方法)
ctor.prototype = Object.create(superCtor.prototype, {
constructor: {
value: ctor,
enumerable: false,
writable: true,
configurable: true
}
});
};

判断属性是否存在

  1. Object.keys 判断自身可枚举属性
  2. Object.prototype.hasOwnProperty 判断自有属性(不扫描原型链)
  3. in 或者 Reflect.has 可以判断自有属性和原型链属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 可枚举属性
o = {a:1}
Object.defineProperty(o, 'b', {
enumerable: false,
value: 100
})
console.log(Object.keys(o)); // 只会输出 ['a']

// 自有属性
o = {a:1}
Object.defineProperty(o, 'b', {
enumerable: false,
value: 100
})
Object.prototype.c = 200

console.log(o.a, o.b, o.c) // 1 100 200
const existA = o.hasOwnProperty('a')
const existB = o.hasOwnProperty('b')
const existC = o.hasOwnProperty('c')
console.log(existA, existB, existC); // true, true, false

作用域和闭包

1
2
3
4
fn1() // 不会报错,因为函数会声明前置
function fn1(){}
fn2() // 会报错,var声明前置的时候是undefined
var fn2 = function {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function f() {
var a = [];
for (var i = 0; i < 2; i++) {
var ff = function () {
console.log(i)
};
a.push(ff);
}
a.push(function () {
i++;
});
return a;
}

const [f1, f2, f3] = f();
f1(); // 2
f3(); // 这句话让闭包内的 i 增加了 1
f2(); // 3

上面会打印什么?答案是 2 和 3。这是因为闭包的另一个机制,同一个变量被引用它的多个闭包所共享。我们在for循环内部创建了两个函数,在循环外部创建了一个函数,这三个函数的都引用了f中的i,因而i被这三个函数的闭包所共享,也就是说在i离开自己所属的作用域时(f退出前),将只会发生一次拷贝,并将新创建的三个函数的闭包中的i的对应的指针设定为那一份拷贝的内存地址即可。对于这一个共享的拷贝地址,除了这三个闭包之外,没有其他方式可以访问到它。

必须再次强调的是,被引用的变量拷贝到闭包中的时机发生在、被引用的变量离开自己所属的作用域时,即状态为非活动时。

考虑下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function f() {
const a = [];
for(let i = 0; i < 2; i++) {
var ff = function () {
console.log(i)
};
a.push(ff);
}
return a;
}

const [f1, f2] = f();
f1(); // 0
f2(); // 1

我们知道 ES6 中引入了 let 关键字,由它声明的变量所属块级作用域。在上面的例子中,我们在 for 循环体的初始化部分使用了 let,这样一来 i 的作用域被设定为了该循环的块级作用域内。不过另一个细节是,循环体中的 i ,也就是 ff 中引用的 i,在每次迭代中都会进行重新绑定,换句话说循环体中的 i 的作用域是每一次的迭代。因此在循环体中,当每次迭代的 i 离开作用域时,它的状态变为非活动的,因此它的内容被拷贝到引用它的闭包中

闭包常常会和 IIFE 一起使用,比如:

1
2
3
4
5
6
7
8
9
10
11
12
var a = [];
for(var i = 0; i < 2; i++) {
a.push((function (i) { // f1, i1
return function () { // f2
console.log(i) // i2
}
})(i)); // i3
};

const [f1, f2] = a;
f1(); // 0
f2(); // 1

在上面的例子中,让人迷惑的除了闭包的部分之外,就是 i1,i2 和 i3 了。

  • i1 是 f1 的形参
  • i2 是 f2 中对外层作用域中的变量的引用
  • i3 是全局的变量 i,IIFE 执行时 i 对应的值将被作为实参来调用 f1
  • 当 f1 被调用时,也就是 IIFE 执行阶段,它内部创建了一个新的函数 f2,同时也创建了 f2 对应的闭包
  • 由于 f2 中引用了外层作用域中的 i,即 f1 执行期间的 i,且 i 为活动内容,所以 f2 的闭包中添加一条 Key 为 i,Value 为指向 f1 中活动的 i 绑定到的内存单元的地址
  • 当 IIFE 执行完毕,即 f1 要退出的时候,其栈上活动对象 i 就会离开作用域,因此需要将 i 拷贝到引用它的闭包中。

到目前为止,我们看到的例子都引用的直接外层作用域中的变量,那么我们再来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
function f(x) { // f1
return function (y) { // f2
return function (z) { // f3
console.log(x + y + z)
}
}
}

const xy = f(1);
const xyz = xy(2);
xyz(3); // 6

为了方便描述,我们分别标记了 f1,f2,f3。我们在 f3 内部,引用了 x 和 y,并且 x 并不是 f3 的直接外部作用域。那么这个闭包的构建过程时怎样的?

在 JS 中,函数也是以对象的形式存在的,如果将与函数关联的闭包想象成函数对象的一个类型为 Map<string, Value> 的属性也不过分,比如:

1
2
3
4
5
6
7
8
9
const CLOSURE = Symbol('closure');
const FUN_BODY = Symbol('fun-body');
const FUN_PARAMS = Symbol('fun-params');

const funObj = {
[FUN_PARAMS]: [/* parameters list */],
[FUN_BODY]: [/* instructions */],
[CLOSURE]: new Map<string, Value>(), // Value 可以被多个 closure 共享
}

即使在引擎的实现阶段,因为性能或者实现差异不采用这样的设计,但本质上与这个结构含义是一致的。为了能在运行阶段创建函数对象,在编译阶段就需要收集到必要的信息:

  • 形参列表
  • 函数体
  • 引用的外部变量

比如在编译 f3 的阶段,我们发现它内部引用了外部的 x 和 y,由于 x 不是直接存在于父及作用域 f2 中的,为了使得未来使用 f2 创建 f3 的时候,仍能够找到 x 的绑定,我们需要将 x 加入到 f2 的闭包中。所以在编译阶段,我们会在 f2 的信息中标注它内部引用了外部变量 x。这样在创建 f2 的时候,x 就会被拷贝到它的闭包中了,等到使用它再创建 f3 的时候,f3 中的 x 也就有了着落。

最后来一个拓展练习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function f(x) { 
return [
function () { x++ },
function (y) {
return function (z) {
console.log(x + y + z)
}
}
]
}

const [f1, xy] = f(1);
const xyz = xy(2);
f1();
xyz(3); // 7

闭包在实际开发中重要用于封装变量,收敛权限。

什么是闭包,是否会造成内存泄漏

在一个函数的环境中,闭包 = 函数 + 外部的词法环境

1
2
3
4
5
// 这就是闭包。即使 sub 函数是空实现并没有引用外部的变量
function m(){
var a = 1
function sub(){}
}

广义上来说,上面的闭包定义是没有问题的。但是我们经常说的闭包是狭义上的,也就是下面的:

1
2
3
4
5
6
7
8
9
function m(){
var a = 1
function sub(){
a;
}
return sub()
}
const s = m()
// 一般来说,函数调用完成后,其词法环境就可以销毁了。但是通过 s 可以间接访问了 m 函数的 a 变量,从而导致了 m 函数的词法环境还不能被销毁

内存泄漏分为 2 种情况:

  1. 持有了本该被销毁的函数,造成其词法环境无法被销毁
  2. 当有多个函数共享词法环境的时候可能导致词法环境膨胀,从而导致:无法访问也无法销毁的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
// 在这个例子中,bigData 是无法被访问的,但是由于 big 函数和 small 函数共享了词法环境,外部是能访问 smallData 的,导致内存泄漏
function createXXX(){
const bigData = 'x'.repeat(1000000)
const smallData = 'x'
function big(){
bigData;
}
function small(){
smallData;
}
return small;
}
const xxx = createXXX()

闭包和提权漏洞

首先看一道面试题:如何在不改变下面代码的情况下修改 obj 对象?

1
2
3
4
5
6
7
8
9
10
11
var o = ((function () {
var obj = {
a: 1,
b: 2
}
return {
get(k) {
return obj[k]
}
}
}))()

obj 是在 o 函数的内部定义的,并且内部通过访问器对外部提供访问。我们可以在 Object 的原型上添加方法,返回自己本身!

1
2
3
4
5
6
7
8
9
10
11
12
// obj[k] 还可以返回原型
Object.defineProperty(Object.prototype, 'hack', {
get() {
return this
}
})
const r = o.get('hack')
console.log('拿到 obj 对象:', r);
r.a = 'hacked'
r.hello = 'c'
delete r.b
console.log(o.get('a'), o.get('b'), o.get('hello'));

Node 服务器如果出现上述情况将会非常严重!可能导致一个恶意的第三方库搞花活篡改了另一个第三方库的代码,导致服务器崩溃!防御手段就是将原型置空!

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = ((function () {
var obj = {
a: 1,
b: 2
}
// 将原型置空
Object.setPrototypeOf(obj, null)
return {
get(k) {
return obj[k]
}
}
}))()

这也就是为什么一些第三方库使用 Object.create(null) 来创建一个空对象,防止被篡改。

实现 new 运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function myNew() {
const constr = Array.prototype.shift.call(arguments) // 第一个参数是构造函数
const obj = Object.create(constr.prototype) // 创新一个新对象,该对象的原型是constr
const result = constr.apply(obj, arguments) // 改变this的指向,让this指向刚才传创建的obj
return result instanceof Object ? result : obj // 判断返回值是不是对象,如果是返回,如果不是返回刚才创建的新对象
}

function Person(name, age) {
this.name = name;
this.age = age;
}

Person.prototype.sayHello = function () {
console.log(`I am ${this.name},is ${this.age} years old!`)
}

const p = myNew(Person, '张三', 18)
console.log(p) // Person { name: '张三', age: 18 }
p.sayHello() // I am 张三,is 18 years old!

注意 myNew 函数的第 5 行,是为了兼容以下这种情况

1
2
3
4
5
function Person(name, age) {
this.name = name;
this.age = age;
return { name: '张三', age: 18 }
}

实现 call 函数

思路:

  1. 参考 call 的语法规则,需要设置一个参数 thisArg ,也就是 this 的指向;
  2. 将 thisArg 封装为一个 Object;
  3. 通过为 thisArg 创建一个临时方法,这样 thisArg 就是调用该临时方法的对象了,会将该临时方法的 this 隐式指向到 thisArg 上
  4. 执行 thisArg 的临时方法,并传递参数;
  5. 删除临时方法,返回方法的执行结果。
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
/**
* 用原生JavaScript实现call
*/
Function.prototype.myCall = function(thisArg, ...arr) {

// 1.判断参数合法性
if (thisArg === null || thisArg === undefined) {
//指定为 null 和 undefined 的 this 值会自动指向全局对象(浏览器中为window)
thisArg = window;
} else {
// 当以非构造函数形式被调用时,Object 等同于 new Object()。
thisArg = Object(thisArg);//创建一个可包含数字/字符串/布尔值的对象,
//thisArg 会指向一个包含该原始值的对象。
}

// 2.搞定this的指向
const specialMethod = Symbol("anything"); //创建一个不重复的常量
//如果调用myCall的函数名是func,也即以func.myCall()形式调用;
//根据上篇文章介绍,则myCall函数体内的this指向func
thisArg[specialMethod] = this; //给thisArg对象建一个临时属性来储存this(也即func函数)
//进一步地,根据上篇文章介绍,func作为thisArg对象的一个方法被调用,那么func中的this便
//指向thisArg对象。由此,巧妙地完成将this隐式地指向到thisArg!
let result = thisArg[specialMethod](...arr);

//3.收尾
delete thisArg[specialMethod]; //删除临时方法
return result; //返回临时方法的执行结果
};

let obj = {
name: "coffe1891"
};

function func() {
console.log(this.name);
}

func.myCall(obj);//>> coffe1891

实现 apply 函数

  1. 传递给函数的参数处理,不太一样,其他部分跟 call 一样;
  2. apply接受第二个参数为类数组对象, 这里用了《JavaScript权威指南》一书中判断是否为类数组对象的方法。
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
/**
* 用原生JavaScript实现apply
*/
Function.prototype.myApply = function(thisArg) {
if (thisArg === null || thisArg === undefined) {
thisArg = window;
} else {
thisArg = Object(thisArg);
}

//判断是否为【类数组对象】
function isArrayLike(o) {
if (
o && // o不是null、undefined等
typeof o === "object" && // o是对象
isFinite(o.length) && // o.length是有限数值
o.length >= 0 && // o.length为非负值
o.length === Math.floor(o.length) && // o.length是整数
o.length < 4294967296
)
// o.length < 2^32
return true;
else return false;
}

const specialMethod = Symbol("anything");
thisArg[specialMethod] = this;

let args = arguments[1]; // 获取参数数组
let result;

// 处理传进来的第二个参数
if (args) {
// 是否传递第二个参数
if (!Array.isArray(args) && !isArrayLike(args)) {
throw new TypeError(
"第二个参数既不为数组,也不为类数组对象。抛出错误"
);
} else {
args = Array.from(args); // 转为数组
result = thisArg[specialMethod](...args); // 执行函数并展开数组,传递函数参数
}
} else {
result = thisArg[specialMethod]();
}

delete thisArg[specialMethod];
return result; // 返回函数执行结果
};

实现 bind 函数

区别于 call 和 apply,bind 不会立即执行函数,而是返回一个绑定了 this 的新函数。bind 可以预先传递部分参数(柯里化)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 用原生JavaScript实现bind
*/
Function.prototype.myBind = function(objThis, ...params) {
const thisFn = this;//存储调用函数,以及上方的params(函数参数)
//对返回的函数 secondParams 二次传参
let funcForBind = function(...secondParams) {
//检查this是否是funcForBind的实例?也就是检查funcForBind是否通过new调用
const isNew = this instanceof funcForBind;

//new调用就绑定到this上,否则就绑定到传入的objThis上
const thisArg = isNew ? this : Object(objThis);

//用call执行调用函数,绑定this的指向,并传递参数。返回执行结果
return thisFn.call(thisArg, ...params, ...secondParams);
};

//复制调用函数的prototype给funcForBind
funcForBind.prototype = Object.create(thisFn.prototype);
return funcForBind;//返回拷贝的函数
};
1
2
3
4
5
6
7
8
9
10
11
12
let func = function(p,secondParams){//其实测试用的func其参数可以是任意多个
console.log(p.name);
console.log(this.name);
console.log(secondParams);
}
let obj={
name:"1891"
}
func.myBind(obj,{name:"coffe"})("二次传参");
//>> coffe
//>> 1891
//>> 二次传参

实现函数重载

重载是面向对象编程语言(比如Java、C#)里的特性,JavaScript语言并不支持该特性。所谓重载(overload),就是函数名称一样,但是随着传入的参数个数不一样,调用的逻辑或返回的结果会不一样。jQuery之父John Resig曾经提供了一个非常巧妙的思路实现重载,代码如下:

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
(() => {//IIFE+箭头函数,把要写的代码包起来,避免影响外界,这是个好习惯

// 当函数成为对象的一个属性的时候,可以称之为该对象的方法。

/**
* @param {object} 一个对象,以便接下来给这个对象添加重载的函数(方法)
* @param {name} object被重载的函数(方法)名
* @param {fn} 被添加进object参与重载的函数逻辑
*/
function overload(object, name, fn) {
var oldMethod = object[name];//存放旧函数,本办法灵魂所在,将多个fn串联起来
object[name] = function () {
// fn.length为fn定义时的参数个数,arguments.length为重载方法被"调用"时的参数个数
if (fn.length === arguments.length) {//若参数个数匹配上
return fn.apply(this, arguments);//就调用指定的函数fn
} else if (typeof oldMethod === "function") {//若参数个数不匹配
return oldMethod.apply(this, arguments);//就调旧函数
//注意:当多次调用overload()时,旧函数中
//又有旧函数,层层嵌套,递归地执行if..else
//判断,直到找到参数个数匹配的fn
}
};
}

// 不传参数时
function fn0() {
return "no param";
}

// 传1个参数
function fn1(param1) {
return "1 param:" + param1;
}

// 传两个参数时,返回param1和param2都匹配的name
function fn2(param1, param2) {
return "2 param:" + [param1, param2];
}

let obj = {};//定义一个对象,以便接下来给它的方法进行重载

overload(obj, "fn", fn0);//给obj添加第1个重载的函数
overload(obj, "fn", fn1);//给obj添加第2个重载的函数
overload(obj, "fn", fn2);//给obj添加第3个重载的函数

console.log(obj.fn());//>> no param
console.log(obj.fn(1));//>> 1 param:1
console.log(obj.fn(1, 2));//>> 2 param:1,2
})();

每次调用 overload 时,新函数会捕获 oldMethod(即之前的函数),形成闭包。多次调用 overload 添加不同的函数时,会形成一个链式结构,逐层检查参数个数。

Promise相关

怎样控制 Promise 的并发

Promise.all可以让Promise并发执行,但是这些Promise是创建的时候就开始执行了,所有数组中的Promise可以理解为同时执行,如何限制并发执行的个数呢?只能从Promise创建的时候开始考虑了。维护一个正在执行的Promise的队列记为executing,并发数记为poolLimit,当正在执行的Promise数量大于等于poolLimit就需要等待executing一个Promise执行完腾出位置了,Promise.race提供了这样一组api:接收一个Promise数组,当第一个Promise执行完的时候,整个Promise.race执行结束。我们正好可以利用这个特性,及时腾出位置加入其它的Promiseexecuting中!

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
function asyncPool(poolLimit, array, iteratorFn) {
let i = 0;
const ret = [];
const executing = new Set();
const enqueue = function () {
// 边界处理,array为空数组
if (i === array.length) {
return Promise.resolve();
}
// 每调一次enqueue,初始化一个promise(使用Promise.resolve.then包装一下,这样返回的都是promise了)
const item = array[i++];
const p = Promise.resolve().then(() => iteratorFn(item, array));
// 放入promises数组
ret.push(p);
executing.add(p);
// promise执行完成后,从执行队列中删除
p.then(() => {
executing.delete(p);
});

// 使用Promise.race,每当executing队列中promise数量低于poolLimit,就实例化新的promise并执行
let r = Promise.resolve();
if (executing.size >= poolLimit) {
r = Promise.race(executing);
}
// 递归,直到遍历完array
return r.then(() => enqueue());
};
return enqueue().then(() => Promise.all(ret));
}
1
2
3
4
5
6
7
const timeout = i => new Promise(resolve => setTimeout(() => resolve(i), i));
require('util').log('start');
asyncPool(2, [1000, 5000, 3000, 2000], timeout).then(results => {
require('util').log(results);
});
// 8 Sep 17:20:07 - start
// 8 Sep 17:20:13 - [ 1000, 5000, 3000, 2000 ]

其实所谓的 Promise 并发控制,就是控制Promise实例化的个数。然而这样的实现效果本质上来说已经摈弃了Promise.all,期待标准库中可以提供这个功能。

如何取消 Promise

Promise 一旦创建是不能取消的,我们只能对其进行包装:

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
const makeCancelable = (promise) => {
let hasCanceled_ = false;
const wrappedPromise = new Promise((resolve, reject) => {
promise.then((val) =>
hasCanceled_ ? reject({isCanceled: true}) : resolve(val)
);
promise.catch((error) =>
hasCanceled_ ? reject({isCanceled: true}) : reject(error)
);
});
return {
promise: wrappedPromise,
cancel() {
hasCanceled_ = true;
},
};
};

const somePromise = new Promise(r => setTimeout(r, 1000));//创建一个异步操作
const cancelable = makeCancelable(somePromise);//为异步操作添加可取消的功能
cancelable
.promise
.then(() => console.log('resolved'))
.catch(({isCanceled, ...error}) => console.log('isCanceled', isCanceled));
// 取消异步操作
cancelable.cancel();

出处:https://github.com/crazycodeboy/RNStudyNotes/tree/master/React%20Native%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/React%20Native%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E4%B9%8B%E5%8F%AF%E5%8F%96%E6%B6%88%E7%9A%84%E5%BC%82%E6%AD%A5%E6%93%8D%E4%BD%9C

编程风格

不要混用同步和异步,容易给 debug 造成比较大的困惑,下面的代码是反面教材:

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
// BAD
const cache = {};
function readFile(fileName, callback) {
if (cache[filename]) {
return callback(null, cache[filename])
}

fs.readFile(fileName, (err, fileContent) => {
if (err) return callback(err);

cache[fileName] = fileContent;
callback(null, fileContent);
});
}

// GOOD
const cache = {};
function readFile(fileName, callback) {
if (cache[filename]) {
return process.nextTick(() => callback(null, cache[filename]));
}

fs.readFile(fileName, (err, fileContent) => {
if (err) return callback(err);

cache[fileName] = fileContent;
callback(null, fileContent);
});
}

node.js 相关

nodejs中的global对象什么时候初始化

global对象在src/node.cc中的被创建,在bootstrap/node.js中被初始化。在src/node.cc的LoadEnvironment方法中,有以下几行代码是用来创建global对象的。

1
2
3
4
5
6
// Add a reference to the global object
Local<Object> global = env->context()->Global();

// Expose the global object as a property on itself
// (Allows you to set stuff on `global` from anywhere in JavaScript.)
global->Set(FIXED_ONE_BYTE_STRING(env->isolate(), "global"), global);

其中env->context()->Global()获取了当前context中的Global全局对象,global->Set(FIXED_ONE_BYTE_STRING(env->isolate(), “global”), global)将全局对象本身挂载在其global对象上,这样全局对象中有了一个global对象指向了全局对象本身,我们在该context中可以直接使用global对全局对象的引用进行访问。以上程序之后,这样我们就可以在context的任何地方对全局对象进行操作。

初始化在bootstrap/node.js中的setupGlobalVariables进行,其在上述逻辑执行后被执行,所以可以直接操作全局对象。这个函数中将process、Buffer直接放在global对象上,因此我们可以直接访问这些全局对象了。进一步地,setTimeout等定时器通过setupGlobalTimeouts方法放在global上。

为什么cluster开启多个worker的时候可以监听同一个端口不会报错

master进程内部启动了一个TCP服务器,真正监听端口的只有这个服务器,当来自前端的请求触发服务器的connection事件后,master会将对应的socket句柄发送给worker子进程。cluster内部的负载均衡均衡算法是Round-robin。master进程监听端口,将请求分发给下面的worker进程。

事件循环

参考国外大神写的一系列文章

1
2
3
4
function foo() {
setTimeout(foo, 0)
}
foo()

上面的代码为什么不会导致栈溢出?

因为我们是把函数放在一个异步的环境中了,foo 将一个函数通知给了计时线程后就不管了,foo 函数就执行完了,其执行栈就被清空了!计时线程等时间到了之后将 foo 加入事件队列,取出来执行,执行的过程和第一次执行一模一样!

怎么实现一个精准的 setInterval

首先要明白为什么不精准?

  1. 事件循环影响回调的执行时机
  2. 嵌套 5 层以上有最小 4ms 的限制
  3. 失活页面间隔会被强制调整到 1s

解决方案:

  1. 每次回调的时候重新调整时间偏差,Date.now, perfermance.now
  2. requestAnimationFrame 不受页面失活影响,但是其他因素影响(os 本身的忙碌程度,正在玩游戏)
  3. web worker:运行在后台线程中,不受主线程事件循环的影响,但是封装费事

CommonJS 和 ESM 的区别是什么

从标准来源上说:CJS 是 node 社区标准,定义了一个 require 函数,一个全局可用的 module 对象,上面有一个 exports 属性;ESM 是官方标准,是新增的语法。
从时态上面来说:CJS 是运行时确定依赖关系,ESM 同时支持编译时(静态)和运行时(import()语法)。

因此 import a from 变量 这种写法在 ESM 中会直接语法报错,在函数中使用 import 关键字也是错误的!

静态导入带来了非常多的好处,最显著的就是不用等到运行时就能确定依赖关系。前端技术中常见的树摇优化(Tree Sharking)就使用了静态分析依赖关系从而移除不必要的导入来减少了代码体积。

一些场景设计题

参考资料