..

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.valuesObject.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);

上面几种方法都是遍历对象的属性都符合如下特征:

  • 是对象自身的,而不是原型链上继承来的;
  • 都是可遍历的,即其属性描述符enumerabletrue
  • 非 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 相对应的,在数组头部添加、删除元素的方法叫 unshiftshift.

// 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 现在提供了相对丰富的查找方法。

查找符合条件的第一个元素,使用 findfindLast。后者的查找顺序是从后往前查找。

// 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 的参数全部写出来,是为了展示一下遍历数组回调函数的典型签名:

  • 第一个参数,当前遍历的元素
  • 第二个参数,当前遍历元素在数组的下标
  • 第三个参数,数组本身

几乎所有的数组遍历回调函数的参数都是这样的签名。虽然,有一两个不太一样,但是也是几乎相似。

findfindLast 都是在数组中查找目标元素并返回这个元素,而 findIndexfindLastIndex 则是返回目标元素的下标

// 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, findLastIndexindexOf 在找不到模板元素的时候,返回 -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 是纯函数,原数组没被修改

需要对数组进行累加操作的时候,可以使用 reducereduceRight。后者累加的顺序是从后往前。

// 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,
    }
  }, {});
}

然而,对于 indexBypureIndexBy 的调用方,这两个函数都是纯函数。因为它们都不会修改 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 里可以看到,它只遍历到了 02 这两个下标对应的元素。

这种稀疏数组的不知是因何存在。是为了性能?还是有历史遗留的原因?不得而知。总之大部分场景下,还是尽量避免写出这种稀疏数组来。

如果哪天遍历数组发现这种奇怪的问题,可以往这个方向想想。兴许是空槽元素搞的鬼。

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 包了一下,从而切断了 #callbackscb 之间的强引用。重新运行下,再试试都按几次 “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 的最好方式试着处理图像、音频。如,给图片打码、把图片变灰、把音频的声音变高等。这需要了解各类文件的编码结构,有时间再来试试。