2. 数据结构
普通对象
普通对象在 JS 里的经常作为键值对数据结构来使用。对象的 key 只能是 string 或者 symbol。
基本使用
JS 里对象的使用比较符合一般动态语言的直觉。
对象面量用花括号来声明。声明的时候,可以直接初始化一些属性。当属性的 key 不是一个标准的变量名时,就需要用中括号。下面的 name-1
这个 key 不是一个标准的变量名(包含了 -
)。
// pg-title: 对象面量
// 声明一个空对象:
const obj = {};
console.log(JSON.stringify(obj));
// 声明对象的时候,可以直接初始化一些属性:
const people = {
name: 'Test',
age: 17,
['name-1']: 2, //
};
console.log(JSON.stringify(people));
通过 .
符号或者中括号来访问、新增、修改属性,通过 delete
来删除属性。
// pg-title: 对象属性的访问、修改、删除
const people = {
name: 'Test',
age: 17,
['name-1']: 2,
};
// 访问:
console.log(people.age);
console.log(people['name-1']);
// 修改属性:
people.age = 19;
console.log(people.age);
// 动态增加属性:
people.weight = '50kg';
const newKey2 = 'name-2';
people[newKey2] = 'name b';
people[1] = 'a'; // number 1 会被转为 string '1'
console.log(JSON.stringify(people, null, 4));
// 删除属性:
delete people.age;
console.log(people.age);
遍历对象
JS的语法说明里,对遍历对象 key 的顺序并没有做定义。但是大部分浏览器是对于数字 key,会进行大小排序,其它 key 则是按插入顺序排序。如果你需要一个严格的按插入顺序遍历,就需要使用 Map。
// pg-title: object key 的遍历顺序
const key3 = 'ddd';
const obj = {
99: '99999',
3: '333',
'b': 'bbbb',
'aaaa': 'aaaaa',
key3, // key3: key3 的简写
}
obj.k = 'k';
const keys = Object.keys(obj);
console.log(JSON.stringify(keys)); // ["3","99","b","aaaa","k"]
我们一般会采用 Object.keys
, Object.values
与 Object.entries
这三个方法来遍历对象:
// pg-title: 遍历 object 的一般方法
const people = {
name: 'KKK',
age: 17,
weight: 18,
}
// 获取对象的 keys:
const keys = Object.keys(people);
console.log(JSON.stringify(keys));
// 获取对象的 values:
const values = Object.values(people);
console.log(JSON.stringify(values));
// 获取对象的键值对:
const entries = Object.entries(people);
console.log(JSON.stringify(entries));
const anotherPeople = Object.fromEntries(entries); // 从键值对创建对象
console.log(people === anotherPeople);
上面几种方法都是遍历对象的属性都符合如下特征:
- 是对象自身的,而不是原型链上继承来的;
- 都是可遍历的,即其属性描述符的
enumerable
为true
。 - 非 symbol 属性
这种遍历行为是比较符合直觉的。还有一些比较特殊的遍历方式,基本就是上面三个维度中有一个维度不一样。我们现构建一个简单的原型链来,然后再来演示这些特殊的便利方式。
// pg-title: 遍历 object 的特殊方法
// 构建两个对象,让 objB 的原型指向 objA
// 并且都设置一些特殊一点的属性
const symbolKey1 = Symbol('s1');
const objA = {
a1: 'objA-a1',
a2: 'objA-a2',
[symbolKey1]: 'objA-s1',
}
Object.defineProperty(objA, 'unenumerable1', {
value: 'unenumerable value in objA',
})
const symbolKey2 = Symbol('s2');
const objB = {
b1: 'objB-b1',
[symbolKey2]: 'objB-s2'
}
Object.setPrototypeOf(objB, objA);
Object.defineProperty(objB, 'unenumerable2', {
value: 'unenumerable value in objB',
})
// for ... in 循环,除了一般遍历外,还会遍历原型链上的可遍历、非 symbol 属性
// 可以看到,从 objB 上遍历到了其原型链上继承来的"一般"属性 —— a1, a2
//
// for ... in 遍历的结果:
for (let k in objB) {
console.log(k);
}
// Object.getOwnPropertyNames,除了获取一般属性的 key 以外,
// 还会获取不可遍历的、非 symbol 属性。
//
// Object.getOwnPropertyNames 的结果:
const ownPropertyNamesOfB = Object.getOwnPropertyNames(objB);
console.log(JSON.stringify(ownPropertyNamesOfB));
// Object.getOwnPropertySymbols 也非常好理解,就是获取自身的 symbol 属性
// Object.getOwnPropertySymbols 的结果:
const ownSymbolProps = Object.getOwnPropertySymbols(objB);
console.log(ownSymbolProps);
以 null 为原型的对象
可能是为了让普通对象变得更存粹一点,JS 把很多 Object.prototype
(它是普通对象的默认原型)上的方法都废弃了,然后搬到 Object
对象上。但是还是保留了两个最基本的方法:
Object.prototype.valueOf
, 对象在转成原始值与转成数字的时候调用。Object.prototype.toString
, 对象在转成字符串的时候调用。
这两个方法是为多态而存在的,所以没办法放到 Object
对象上。当要对对象要进行隐式类型转换的时候,就会用到这些方法。
虽然他们不会被遍历到(原型链上的方法一般要声明成 enumerable: false
),但是它们的存在一定程度还是污染了对象。如果我们需要一个非常纯的普通对象,那可以通过各种方法把一个普通对象的原型直接指向 null
,但是这样的对象在隐式转换的时候就会报错。
// pg-title: 以 null 为原型的普通对象
const pureObj = Object.create(null); // 直接以 null 为原型创建一个对象
pureObj.key1 = "111";
pureObj.key2 = 111;
// 遍历这个纯对象的键值:
const entries = Object.entries(pureObj);
console.log(JSON.stringify(entries));
// 但是进行隐藏转换的时候,会报错:
try {
console.log(`${pureObj}`)
} catch (e) {
console.log(e.toString());
}
大部分情况下,创建这种纯对象的意义不大。
解构
JS 还提供了一些解构访问对象的语法糖。对象解构的基本语法格式与其面量声明的格式相呼应,采用花括号对来解构。
基本的使用,就是在等号的左边用花括号列出想要解构出来的属性。如下:
- 把 people 的 age 属性,赋值给新声明的
age
变量。 - 把 people 的 name 属性,赋值给新声明的
firstName
变量。特别注意,并没有声明name
这个变量!
// pg-title: object 解构赋值的基本使用
const people = {
name: 'KKK',
age: 17,
weight: 18,
}
const {age, name: firstName} = people;
console.log(age);
console.log(firstName);
从什么的例子就可以看出,解构时并不需要把对象的所有属性都解构出来。但是如果要所有属性都解构出来,那还要一一列出每个属性的 key 就很麻烦。ES6 还提供了展开运算符 ...
,在解构赋值的场景,它表示剩余其它属性。
// pg-title: object 解构赋值的 ... 表达式
const people = {
name: 'KKK',
age: 17,
weight: 18,
}
const {age, ...rest} = people;
console.log(age);
// ...rest 把 people 的其它属性解构到变量 rest 里:
console.log(JSON.stringify(rest, null, 4));
解构赋值的同时,一般会伴随着声明新的变量。但是后者并不是必须的,我们可以提前声明好变量。就像下面这样:
// pg-title: object 解构赋值给已经声明好的变量
const people = {
name: 'KKK',
age: 17,
weight: 18,
}
let firstName, age;
({age, name: firstName} = people); // 出于语法解析歧义,最外面必须用 () 扩起来
console.log(age);
console.log(firstName);
上面的例子都是把对象解构赋值给变量 JS 还支持把对象解构给整个新对象,这有点像 Object.assign
。
// pg-title: object 解构赋值给另一个对象
const defaultOpts = {
showLineNumber: true,
encoding: 'utf8',
initRows: 10,
}
const myOpts = {
...defaultOpts,
showLineNumber: false,
};
console.log(JSON.stringify(myOpts));
解构可以嵌套,虽然很少这么用:
// pg-title: 对象解构嵌套
const people = {
name: 'KKK',
age: 17,
weight: 18,
address: {
country: 'China',
},
}
const {address: {country}} = people;
console.log(country);
大概是因为这种对象解构赋值,其实是对象访问的语法糖。行为上理所应当保持一致,所以对象解构会查找原型链上的属性。
// pg-title: 对象解构会查找原型链上的属性。
const symbolKey1 = Symbol('s1');
const objA = {
a1: 'objA-a1',
a2: 'objA-a2',
[symbolKey1]: 'objA-s1',
}
Object.defineProperty(objA, 'unenumerable1', {
value: 'unenumerable value in objA',
})
const symbolKey2 = Symbol('s2');
const objB = {
b1: 'objB-b1',
[symbolKey2]: 'objB-s2'
}
Object.setPrototypeOf(objB, objA);
// 解构赋值,显式的指定要解构的属性,可以解构到原型链上的属性:
const {a1, unenumerable1, [symbolKey1]: symbolKey1Value } = objB;
console.log(a1);
console.log(unenumerable1);
console.log(symbolKey1Value);
但是,展开运算符更像是一般遍历方式(自身可遍历的非 Symbol 属性)的语法糖,所以用它无法解构到原型链上的属性。
// pg-title: ... 解构不会查找原型链上的属性。
const symbolKey1 = Symbol('s1');
const objA = {
a1: 'objA-a1',
a2: 'objA-a2',
[symbolKey1]: 'objA-s1',
}
Object.defineProperty(objA, 'unenumerable1', {
value: 'unenumerable value in objA',
})
const symbolKey2 = Symbol('s2');
const objB = {
b1: 'objB-b1',
[symbolKey2]: 'objB-s2'
}
Object.setPrototypeOf(objB, objA);
// 展开运算符,在展开对象的时候访问不到原型链上的属性:
const newObj1 = {...objB};
console.log(newObj1.a1);
console.log(newObj1.unenumerable1);
console.log(newObj1[symbolKey1]);
console.log(JSON.stringify(newObj1, null, 4));
// 展开运算符,在解构赋值的时候访问不到原型链上的属性:
const {b1, ...newObj2} = objB;
console.log(newObj2.a1);
console.log(newObj2.unenumerable1);
console.log(newObj2[symbolKey1]);
console.log(JSON.stringify(newObj2, null, 4));
数组
JS 的数组是一种 key 为数字(数字字符串)的特殊对象。它的使用体验符合动态语言数组的特点。
基本使用
声明数组方式有多种,但是用得最多的还是直接使用面量来声明。
// pg-title: 数组的声明方式
// 用中括号来声明以数组面量:
const emptyArr = [];
const arr1 = [1, '2', 'c', {}, null, undefined];
console.log(JSON.stringify(arr1));
// 用 Array 构造函数来声明一些数组
// Array 只传一个参数,且参数是自然数的时候,会创建一个对应长度的数组:
const arr2 = Array(10).fill(0);
console.log(JSON.stringify(arr2));
// Array 参数不止一个的时候,直接把所有参数当做数组元素:
const arr3 = Array(1, 2, 3, 'a');
console.log(JSON.stringify(arr3));
// 也可以从其它 Array-Like 或者 iterable 对象直接创建一个:
const elmentList = document.querySelectorAll('*'); // elements 是一个 node list (Array Like)
const elments = Array.from(elmentList);
console.log(elments.length);
下标
访问和修改数组的元素比较简单明了,使用中括号加小标即可:
// pg-title: 数组下标
const arr = ['a', 'b', 'c'];
console.log(arr[0] === 'a');
arr[1] = 'x';
console.log(arr[1] === 'x');
// 访问越界的下标,默认返回 undefined
// JS 的数组本质是 object,当时设计也没有严格的限制
// 越界行为。
console.log(arr[1000] === undefined);
python 可以用负数下标来表示"倒数第几个",这种表达方式很方便。JS 数组现在也开始支持负数下标,但是目前只能在数组的方法里使用,中括号下标依然不支持。
// pg-title: 负数下标
// arr.at(index) 方法等同于 arr[index] 的读
const arr = ['a', 'b', 'c'];
console.log(arr.at(-1) === 'c');
// arr.with(index, newVal) 方法类似于 arr[index] = newVal
// 只不过它不会修改原数组,而是浅拷贝出一个新数组再修改
console.log(arr.with(-1, 'x').at(-1) === 'x');
// 原来的数组 arr 的最后一个元素并没有变。
console.log(arr.at(-1) === 'c');
数组需要下标作为参数的方法,大多都支持负数下标。
不得不说现在 JS 这种"纯函数"情节有点病态了,我感觉 Array.prototype.with
直接对数组产生副作用更有用一点。真要"纯",就再出一个 toWith
就好了。
副作用方法
数组的方法大部分都是纯函数,但是数组也提供了一些副作用方法用来修改数组。有一些方法比较合理,且一眼就能知道有副作用。
数组尾部的 push 与 pop。
// pg-title: push & pop
const arr = [];
arr.push('a');
console.log(arr[0] === 'a');
const popItem = arr.pop();
console.log(arr.length === 0);
console.log(popItem === 'a');
与 push, pop 相对应的,在数组头部添加、删除元素的方法叫 unshift 与 shift.
// pg-title: shift & unshift
const arr = ['a', 'b', 'c'];
arr.unshift('xxx');
console.log(arr[0] === 'xxx');
const shiftItem = arr.shift();
console.log(arr.length === 3);
console.log(shiftItem === 'xxx');
上述这些副作用方法我感觉还是比较符合直觉的。一看就知道会修改原数组。但是还有一些方法,就不是那么明显了。
- sort,会对原数组进行排序
- reverse,会对原数组进行顺序倒置
- splice,会切割、(替换)原数组子串
这些方法用的时候要特别注意,它们都会修改原数组,要确保这是符合心理预期的。不过所幸后来 JS 新出了它们的纯函数版本:
查找
与 ES6 之前只能用 indexOf
比起来,现在的 JS 现在提供了相对丰富的查找方法。
查找符合条件的第一个元素,使用 find
与 findLast
。后者的查找顺序是从后往前查找。
// pg-title: find & findLast
// 先准备一个数组,然后声明一个判断偶数的回调函数
const arr = [1, 2, 3, 4, 5, 6, 7];
/**
* 判断偶数的回调函数。
*
* @param item 当前遍历的元素
* @param index 当前元素的下标
* @param theArray 整个数组
* @returns {boolean}
*/
function isEvenNumber(item, index, theArray) {
if (typeof item === 'number') {
return item % 2 === 0;
} else {
return false;
}
}
console.log(arr.find(isEvenNumber)); // 2
console.log(arr.findLast(isEvenNumber)); // 6
// 如果找不到,就会返回 undefined。
// 把数组元素全部 map 成 'x',里面就不存在偶数了
console.log(arr.map(_ => 'x').find(isEvenNumber) === undefined);
这里特意把 isEvenNumber
的参数全部写出来,是为了展示一下遍历数组回调函数的典型签名:
- 第一个参数,当前遍历的元素
- 第二个参数,当前遍历元素在数组的下标
- 第三个参数,数组本身
几乎所有的数组遍历回调函数的参数都是这样的签名。虽然,有一两个不太一样,但是也是几乎相似。
find
与 findLast
都是在数组中查找目标元素并返回这个元素,而 findIndex
与 findLastIndex
则是返回目标元素的下标。
// pg-title: findIndex
const arr = ['a', 'b', 'c'];
console.log(arr.findIndex(it => it === 'b') === 1);
console.log(arr.findIndex(it => it === 'xxx')); // -1,当找不到目标元素的时候就返回 -1
我个人感觉 findIndex
, findLastIndex
与 indexOf
在找不到模板元素的时候,返回 -1
实在不好,不符合语义。
当需要判断数组是否存在某个元素的时候,可以使用 includes
方法。
// pg-title: includes
console.log(['1', 2, '3'].includes(2)); // true
console.log(['1', 2, '3'].includes('2')); // false
也可以使用 some
方法,它与 includes
不同的是其参数是一个回调函数,而不是一个具体值。
// pg-title: some
const arr = [1, 2, 3];
console.log(arr.some(n => n % 2 === 0));
当需要判断数组的每一个元素是否符合某种条件的时候,可以使用 every
方法。
// pg-title: every
const isEvenNumber = it => it % 2 === 0;
const arr = [1, 2, 3, 4, 5, 6];
const arr2 = arr.map(n => n * 2);
console.log(arr.every(isEvenNumber)); // false
console.log(arr2.every(isEvenNumber)); // true
排序
数组的排序有两个坑。
sort
方法有副作用。大部分情况下应该使用toSorted
方法- 比较函数的默认行为是把元素都转成 string 来进行字符串比较。所以,
9
会比100000
“大” 。
// pg-title: 排序
const arr = [14000, 9, 88, 100];
// 默认是升序,所以"最大的" 9 排到了最后。
console.log(JSON.stringify(arr.toSorted()));
// 想要对 number 进行排序,那就要自己写 compare 函数。
// 并且要自己保证数组里的元素都是 number
const compareNumAsc = (a, b) => a - b;
console.log(JSON.stringify(arr.toSorted(compareNumAsc)));
比较函数的会传入两个参数 a, b
,分别表示前一个元素与当前元素。在选的排序里:
- a 与 b 不需要调换,则返回
0
- a 需要排在 b 前面,则返回 1
- a 需要排在 b 后面,则返回 -1
遍历与转换
JS 还提供了一些遍历数组与转换的方法。
在 JS 里,比较少用到循环语句。很多时候都用一些语义化的数组操作(方法)代替了,上面提到的那些方法也算语义化的方法。
当仅仅只是想要遍历数组时,使用 forEach
。
// pg-title: forEach
const arr = [1, 2, 3];
const result = arr.forEach(it => {
it = it + 1;
console.log(it);
});
console.log(result === undefined); // forEach 不返回任何值
console.log(arr[0] === 1); // 原数组并没有被改变
当想要过滤数组中的元素时,使用 filter
。
// pg-title: filter
const lines = ['Hey', '', ' ', 'I am'];
// 过滤掉没内容的空行
const validLines = lines.filter(it => it.trim() !== '');
console.log(JSON.stringify(validLines));
console.log(JSON.stringify(lines)); // filter 是纯函数,原数组没被修改
当想要把数组中的元素根据规则进行映射的时候,可以使用 map
。
// pg-title: map
const arr = [1, 2, 3, 4, 5];
const double = n => n * 2;
const newArr = arr.map(double)
console.log(JSON.stringify(newArr));
console.log(JSON.stringify(arr)); // map 是纯函数,原数组没被修改
当需要把多维数组降维的时候,可以使用 flat
。
// pg-title: flat
const arr = [[2, 2], [3, 3, 3], [4, 4, 4], [[[1]]]];
// Infinity 表示降维无限次,直到变为一维数组。默认参数是降维一次
const newArr = arr.flat(Infinity);
console.log(newArr.at(-1) === 1);
console.log(newArr[0] === 2);
console.log(JSON.stringify(newArr));
console.log(JSON.stringify(arr)); // flat 是纯函数,原数组没被修改
需要对数组进行累加操作的时候,可以使用 reduce
与 reduceRight
。后者累加的顺序是从后往前。
// pg-title: reduce
const arr = [1, 2, 3, 4, 5];
const total = arr.reduce((rslt, it) => {
return rslt + it;
});
console.log(total === 15);
链式调用
JS 的纯函数方法基本都会返回一个新数组,所以社区也比较习惯基于这个特性写链式调用。
// pg-title: 解析URL的query
// 把下面这个URL query解析成一个 object,并且过滤掉没有 value 的 query item
const queryStr = 'page=3&page_size=20&utm_source=search&age';
const queryEntries = queryStr
.split('&')
.map(it => it.split('='))
.filter(([key, val]) => !!val);
const query = Object.fromEntries(queryEntries);
console.log(JSON.stringify(query)); // {"page":"3","page_size":"20","utm_source":"search"}
副作用与性能
我个人感觉不要过份强调函数一定要"纯",这可能会引起性能问题。可以看看 MDN - When to not use reduce 关于 reduce 的介绍。遍历过程中,每次都要生成一个新对象,这种空间复杂度几乎是 O(N^2)
(很久没写算法了,不知道对不对)。
我认为,实际一点为好,着重两点:
- 语义要好。一看就知道是干嘛的,会不会有副作用;
- 没有
immutable data
需求的时候,没必要完全保证函数的"纯";
举个例子实现一个 indexBy
函数,把数组转成对象:
function indexBy(arr, keyFn) {
return arr.reduce((rslt, it, idx) => {
const key = keyFn(it, idx, arr);
// 累加函数里,rslt 对象被反复的修改
// 这个函数一点都不纯!
rslt[key] = it;
return rslt;
}, {});
}
function pureIndexBy(arr, keyFn) {
return arr.reduce((rslt, it, idx) => {
const key = keyFn(it, idx, arr);
// 这个累加器每次都生成一个新对象
// 非常的纯!但是性能更差。
// 因为每次都要重新遍历 rslt 对象,然后生成一个新对象
return {
...rslt,
[key]: it,
}
}, {});
}
然而,对于 indexBy
与 pureIndexBy
的调用方,这两个函数都是纯函数。因为它们都不会修改 arr
数组。那么内部的实现细节"纯"与否,一点都不重要!要我选,我会选性能更好的那个实现。
数组的空槽
所谓数组的空槽,大概类似于 !(index in arr)
。就是说,对应的下标在数组对象里没有定义。我们用一个 array-like 对象来表示:
const arrLike = {
0: 'a',
2: 'b',
length: 5,
}
// 这个数组(假设它是,数组本身也是对象),只有 0、2 下标定义了,
// 但是 length 又说这个数组长度为 5。那么对于这个数组来说,它
// 的空槽下标就是:1, 3, 4。
console.log('1' in arrLike); // false
console.log('3' in arrLike); // false
console.log('4' in arrLike); // false
MDN 把这种带有空槽的元素的数组称为稀疏数组。
数组的很多方法都会跳过空槽元素,有些时候其结果并非是你预期的。来看看 every
方法如何跳过这些元素的:
// pg-title: every 与空槽元素
// 声明一个稀疏数组。可以用逗号直接声明。
const arr = ['a', ,'b', , ,]; // 这个数组相当于上面的 arrLike 的结构
const isEachItemIsString = arr.every((it, idx) => {
console.log('check idx: ' + idx);
return typeof it === 'string';
})
console.log(isEachItemIsString); // true
console.log(arr.length); // 5
即便上面的数组还有很多空槽。但是当你用 every
去判断整个数组的每个元素是不是都是 string
的时候,它竟然返回 true
。而且从 console 里可以看到,它只遍历到了 0
跟 2
这两个下标对应的元素。
这种稀疏数组的不知是因何存在。是为了性能?还是有历史遗留的原因?不得而知。总之大部分场景下,还是尽量避免写出这种稀疏数组来。
如果哪天遍历数组发现这种奇怪的问题,可以往这个方向想想。兴许是空槽元素搞的鬼。
Set、Map
JS 里主要的 hashtable 数据结构是 Set、Map。
它们的遍历顺序是元素的插入顺序,这种方式比遍历 Object 的 keys 更符合一般直觉。
// pg-title: Set & Map 的遍历顺序
const s1 = new Set(['1', '2', '3'])
s1.add(-1);
const arr1 = [...s1.values()];
console.log(arr1.at(-1) === -1);
const m1 = new Map();
m1.set(9, 'a');
m1.set('-1', 'b');
const arr2 = [...m1.keys()];
console.log(arr2[0] === 9);
console.log(arr2[1] === '-1');
不像其它语言,会给对象(类)提供类似 get hash
之类的接口,JS 对象的所谓 hash code
其实是一个随机数,详见 V8 Engine - Hash Code。这是 Set 与 Map 允许以对象为 key 的基础。
// pg-title: 以对象为 key
const s1 = new Set();
s1.add({});
s1.add({});
const obj3 = {};
s1.add(obj3);
console.log(s1.has({})); // false
console.log(s1.has(obj3)); // true
const m1 = new Map();
m1.set(obj3, 'the key is a object');
console.log(m1.has({})); // false
console.log(m1.has(obj3)); // true
console.log(m1.get(obj3));
上面的例子中,创建了很多个空对象。很显然这些空对象不是同一个对象,所以它们随机分配到的 hash code
自然也不一样。
WeakSet, WeakMap, WeakRef
这几个数据结构都是与 GC 相关的。Weak 意为使用的是弱引用。这些数据结构是专门为对象设计的,MDN 的说法是,原始值没有 GC 生命周期。这个具体指什么,还需要进一步了解。
JS里变量、对象的属性对对象的引用为强引用。当前流行的 JS 引擎实现,在进行 GC 查找的时候,会通过强引用进行搜索。搜索得到,就说明对象还有用,就不会被回收。所以强引用会阻止对象被 GC。
我们来看个常见的内存泄漏场景 —— Event Hub。
<!-- pg-title: 模拟内存泄漏 - Event Hub -->
<button id="render-btn">Re-Render</button>
// event hub,整个页面运行生命周期里,都会存在。
class EventHubV1 {
#callbacks = [];
subscribe(cb) {
// 这里将 cb push 到 callbacks 数组,
// 数组对里面对象元素的引用是强引用;
this.#callbacks.push(cb);
return () => {
this.#callbacks = this.#callbacks.filter(it => it !== cb);
}
}
publish() {
this.#callbacks.forEach(it => it());
}
}
class EventHubV2 {
#callbacks = [];
subscribe(cb) {
const ref = new WeakRef(cb);
this.#callbacks.push(ref);
return () => {
this.#callbacks = this.#callbacks.filter(it => it !== ref);
}
}
publish() {
this.#callbacks.forEach(ref => {
const cb = ref.deref();
if (cb) {
cb();
}
});
}
}
const hub = new EventHubV1();
class MyComponent {
constructor() {
// 为了内存泄漏看起来明显一点,我们一次泄漏 50MB 的内存。
const _100MbInByte = 50 * 1024 * 1024;
const mountAt = new Date().getTime();
this.largeData = Array(Math.ceil(_100MbInByte / 8)).fill(mountAt);
}
mount() {
this.unsub = hub.subscribe(() => {
console.log(`event trigger, ${this.largeData[0]}`);
})
}
unmount() {
// 正确的做法是在 unmount 的时候,去 unsub 掉。但是由于粗心,我们忘记 unsub
// this.unsub();
}
}
let currCompnoent = null;
window.addEventListener('load', () => {
document.querySelector('#render-btn').addEventListener('click', () => {
if (currCompnoent) {
currCompnoent.unmount();
}
currCompnoent = new MyComponent();
currCompnoent.mount();
console.log('Render completed!');
hub.publish();
});
})
可以试试运行上面的例子,然后再浏览器的控制台里看看页面的内存占用情况。
在使用 subscribeEvent
时,由于忘记移除 cb
的注册(强引用)。所以即便组件实例 unmount
了,window.hub.XXX
这个数组依然对 cb
有强引用,那么发生 GC 的时候,这个 cb
不会被 GC 掉,进而不会 GC 掉 this
(因为 cb 的词法上下文里强引用了 this
)。每按一次 “Re-Render” 按钮,可以从控制台看到内存上涨了大约 50 MB,而且过一会也不会下降。
在使用 subscribeEventV2
时,在把 cb
放到 #callbacks
数组之前,用一个 WeakRef
包了一下,从而切断了 #callbacks
与 cb
之间的强引用。重新运行下,再试试都按几次 “Re-Render” 按钮,看看内存情况。试着运行下会发现,内存依然会上涨(因为 GC 还未到来),但是过一会随着 GC 的进行,内存开始回落了。
需要注意的是,WeakRef
所引用的对象,在失去所有强引用之后,并不会立即被 GC。GC 不受 JS 运行线程控制。如果你按 “Re-Render” 按得非常快,会发现一些"本不该"被调用的 cb
依然会被调用,因为大概率并不会立即发生 GC。停下来等一会(等发生GC)之后,再去点击 “Re-Render” 按钮,会发现它们终于消失了。所以,不要有这种 “本不该” 的预期。
TypedArray
正如前面所讲的,JS 的数组其实是对象。arr[1]
这样的数组下标访问,本质上是访问对象 arr
上的属性 1
。这跟其它静态语言里的经典数组的概念很不一样。典型的数组是这样的:其占用的是一段连续的固定内存空间,数组里存储的是这段连续内存空间的起始地址,并且每个元素的 size 都是一样的。当需要访问某个下标的元素时,可以很快的通过公式 “目标元素地址 = 数组起始地址 + 下标 * 元素的size” 计算出目标元素的地址。
在性能敏感的场景下,如图像、音频处理,用JS的假数组去承载,性能肯定比不上这种经典的真数组。所以 JS 提供了 TypedArray 系列的数据结构,它们就是真正的经典数组。具体的类型见 TypedArray objects 。
我感觉实践 TypedArray 的最好方式试着处理图像、音频。如,给图片打码、把图片变灰、把音频的声音变高等。这需要了解各类文件的编码结构,有时间再来试试。