immutability-helper:不可变数据工具的使用及源码分析

1,295 阅读11分钟

前言

小程序开发辅助平台之前在编辑器处存在体验问题,在自定义编辑器内,鼠标快速拖拽原子组件会出现组件脱离鼠标及拖拽过程卡顿感严重:

编辑器是否好用,关乎整个小程序开发辅助平台的体验瓶颈。经一段时间对编辑器进行性能及体验优化处理,达到效果:

鼠标高速地随意拖拽组件都不会出现脱离鼠标的问题,体验进一步提升。在众多性能优化手段中,今天本篇文章主要讲方法之一:不可变数据工具的使用。

项目之初,对相关技术栈不太熟悉,导致了大篇幅使用常规深/浅拷贝方式。采用不可变数据工具immutability-helper替换到项目中采用deepClone方式处理状态后,取得非常不错的性能提升效果。出于好奇,也去查阅了immutability-helper源码库,收获颇丰。

本篇文章会从以下几个方面进行介绍:

  1. 可变数据的副作用
  2. 什么是不可变数据?
  3. React中使用不可变数据的重要性;
  4. immutability-helper使用简介;
  5. mmutability-helper性能实测;
  6. mmutability-helper核心源码分析;

可变数据的副作用

在JavaScript中分为原始类型和引用类型,而引用类型在使用过程中经常会产生如以下副作用:

const company = { retail:{name: '360buy'}};

const company1 = company;

console.log(company1 === company);

//true

company1.retail.name = 'jd';

console.log(company);
//{ retail:{name: 'jd'}}

当修改了company1中属性后,company的属性值也随之改变。因为这两个变量的指针指向了同一块内存,当一个变量被修改后,内存随之变动,而另一个变量由于指向同一块内存,自然也随之变化了,这就是引用类型的副作用。

开发者到这儿,马上会想到使用拷贝方式将company内存再复制出来一份,让company1company两不干扰即可。使用浅拷贝或者深拷贝可以解决一些问题,但有时我们只修改了一部分数据,却要对整个大量数据进行复制,这会造成不必要的CPU和内存消耗。

什么是不可变数据?

根据上面描述,相信开发者已经大概知道什么是不可变数据。

不可变数据Immutable Data 就是一旦创建,就不能再被更改的数据。对不可变数据的任何修改或添加删除操作都会返回一个新的不可变数据。

各种深/浅拷贝就在实现不可变数据。本文探讨的是,相对于常规的深/浅拷贝,有无一种更高性能的不可变数据实现方式。

React中使用不可变数据的重要性

Why is immutability so important (or needed) in JavaScript?

问题大致:在使用了 ReactReact Native 框架开发了一段时间之后,发现每次在使用 Redux 时,都需要利用 Immutable.js 来实现不可变数据结构的增删改。小哥就在想,使用可变数据不是更加简单吗? 比如有一个数组,直接 push 该数组,不是更加方便,还能复用原有的内存,为什么还要 费很多的周折 [...array] / concat 变成不可变数据结构呢?这样不是会创建一个新数组,不是会更加浪费内存吗?而且在 写法上,也不如 push来的简洁,方便。

以下从高赞回答中做一些汇总:

原因1:从 React 角度来讲

第一:React UI更新的原则就是 Immutable, 试想你在修改状态的时候, 直接将 state.push("balabala") , 然后 setState(state) , React UI 会更新吗?答案是否定的。因为 React StateDiff 更新的时候,是通过shallowEqual去比较,比较结果是 false 才去更新,如果你是可变数据的,即便数组的内容已经 通过 push 方法改变了。但数组的地址不会变,比较结果还是 trueReact对视图不会进行更新。

第二:有了不可变数据的依赖,React 程序才知道什么时候,或者应不应该需要优化组件渲染和昂贵计算,什么时候,或者应不应该更新视图。

原因2:从不可变数据 本身来讲

不可变数据 本身会为应用程序带来:可预测性的程序,高性能的程序,允许状态追踪的程序。

可预测性

可变数据虽然方便,但是屏蔽或者说隐藏了改变带来了副作用,比如,你直接向数组中 push 一个元素,用起来是很舒服,但是别忘了,整个应用程序,任何一个地方都可以修改这个数组,而且无法预测这个数组在应用程序的哪个地方,进行了什么样的操作,这样一来,便会造成难以排查的 bug,反观不可变数据,任何一次操作,都会返回一个全新的数据结构,这样一来,针对该数据结构的控制会变得简单容易理解方便维护,比如借助第三方工具 redux,你完全可以知道你在哪个地方,进行了什么样的操作,让数据最后变成了什么样子。

高性能

这个一开始,也出乎笔者的意料,不可变数据可是每次都返回一个新的数据结构啊,这么会提高性能,在阅读了一些材料之后,得出了以下结论。

尽管向不可变对象添加值意味着需要创建一个新实例,其中需要复制现有值,并且需要将新值添加到新对象中,这会消耗内存,但 使用 Immer 或者 immutability-helper 创建的不可变对象可以最大幅度的利用结构共享来减少内存,至于为什么这些库创建的不可变数据,为什么会最大程度的利用内存? 往下看。

状态可追踪

用过 redux 开发者工具的同伴都知道,你的每一次 dispatch,在应用程序的哪个地方,触发了什么样的 action,最终得到了什么的结果,在 redux 工具当中一目了然。可变数据无法做到这样的可追踪。

useMemo 等一系列钩子优化组件和计算,也可以归结为不可变数据的状态的可追踪,有了不可变数据的依赖,React 程序 才知道什么时候,或者应不应该需要优化组件渲染和昂贵计算,什么时候,或者应不应该更新视图。

immutability-helper使用简介

支持处理不可变数据的工具很多,其中immutability-helper,该库也是React官方文档中性能优化部分推荐工具。

  • {$push: array} 同数组的 push 方法,将参数 array 中的所有项 push 到目标数组中

  • {$unshift: array} 同数组的 unshift 方法,将参数 array 中的所有项 unshift 到目标数组中

  • {$splice: array of arrays} 同数组的 splice 方法,对于参数 arrays 中的每一项,使用该项提供的参数对目标数组调用 splice()

    PS: 参数 arrays 中的项是按顺序应用的,所以顺序很重要。在操作过程中,目标的指针可能会发生变化

  • {$set: any} 使用 any 值替换目标

  • {$toggle: array of strings} 将参数 array 中提供的下标或者属性的值切换成相反的布尔值

  • {$unset: array of strings} 从目标对象中移除参数 array 中的键列表

  • {$merge: object} 将参数 object 的键与目标合并

  • {$apply: function} 将当前值传递给函数并用新的返回值更新它

  • {$add: array of objects} 向 Set 或 Map 中添加值。添加到 Set 时,参数 array 为要添加的对象数组,添加到 Map 时,参数 array 为 [key, value] 数组

  • {$remove: array of strings} 从 Set 或 Map 中移除参数 array 中的键列表

API 用法及示例

初始化四个变量,之后的各种 API 操作都是基于这四个变量

const initialObject = {
    name: 'Jack',
    age: 22,
    gender: 'Man'
};
const initialArray = ['a', 'b', 'c', 'd', 'e'];
const initialSet = new Set(['2', '0', '2', '3', '兔', '年', '快', '乐']);
const initialMap = new Map([['id', '1'], ['color', 'blue'], ['alias', 'map']]);

{$push: array}

/**
 * API: {$push: array}
 * 同数组的 push 方法,将数组 array 中包含的所有元素添加到 initialArray 的后面,作为一个新数组返回
 */
const pushArray = update(initialArray, { $push: ['f'] });
console.log('pushArray:', pushArray);  // => [ 'a', 'b', 'c', 'd', 'e', 'f' ]

{$unshift: array}

/**
 * API: {$unshift: array}
 * 同数组的 unshift 方法,将数组 ['f'] 中包含的所有元素添加到 initialArray 的前面,作为一个新数组返回
 */
const unshiftArray = update(initialArray, { $unshift: ['f'] });
console.log('unshiftArray:', unshiftArray);   // => [ 'f', 'a', 'b', 'c', 'd', 'e' ]

{$splice: array of arrays}

/**
 * API: {$splice: array of arrays}
 * 同数组的 splice 方法
 *      数组 arrays 中包含的是所有需要执行的操作集合
 *      元素 array 中第一个元素代表下标,第二个元素代表需要删除的个数,第三个元素代表需要插入到 initialArray 中的的元素
 * 
 * PS:  1、可以在 arrays 中执行多个集合;
 *      2、两个操作不是同时执行,而是按顺序执行,后面的操作会在前面一个操作的执行结果上执行
 */
const spliceArray = update(initialArray, { $splice: [[1, 2], [2, 0, 'f', 'g']] });
console.log('spliceArray:', spliceArray);  // => [ 'a', 'd', 'f', 'g', 'e' ]

{$set: any}

/**
 * API: {$set: any}
 * 可以将数组或者对象中某一下标或者属性的值进行替换
 */
// 将 initialArray 数组中下标为 1 的元素修改为 'f'
const setArray = update(initialArray, { 1: { $set: 'f' } });
console.log('setArray', setArray);  // => [ 'a', 'f', 'c', 'd', 'e' ]

// 将 initialObject 对象中 age 属性值修改为 26
const setObject = update(initialObject, { age: { $set: 26 } });
console.log('setObject', setObject);    // => { name: 'Jack', age: 26, gender: 'Man' }

{$toggle: array of strings}

/**
 * API: {$toggle: array of strings}
 * 可以将数组或者对象中下标集合或者属性集合的值进行切换:任何 Truthy 都会切换成 false,任何 Falsy 值都会切换成 true
 */
// 将 initialArray 中下标为 1、2 的元素值进行切换
const toggleArray = update(initialArray, { $toggle: [ 1, 2 ] });
console.log('toggleArray:', toggleArray);    // => [ 'a', false, false, 'd', 'e' ]

const toggleObject = update(initialObject, { $toggle: [ 'name', 'gender' ] });
console.log('toggleObject:', toggleObject);  // => { name: false, age: 22, gender: false }

{$unset: array of strings}

/**
 * API: {$unset: array of strings}
 * 从目标数组或者对象中移除 array 中的下标或者属性列表
 */
// 删除数组 initialArray 中下标为 1 和 2 的两个元素,但是保留占位
const unsetArray = update(initialArray, { $unset: [1, 2] });
console.log('unsetArray:', unsetArray.length, unsetArray); // 5    [ 'a', <2 empty items>, 'd', 'e' ]

// 删除对象 initialObject 中 name 和 gender 属性
const unsetObject = update(initialObject, { $unset: ['name', 'gender'] });
console.log('unsetObject', unsetObject);    // unsetObject { age: 22 }

{$merge: object}

/**
 * API: {$merge: object}
 * 从目标数组或者对象中合并 object 中下标或者属性相同的元素,下标或属性相同时 object 中的元素会替换掉目标中的元素
 */
// 将 initialArray 数组中的 'a', 'b', 'c' 替换为 1, 2, 3
const mergeArray = update(initialArray, { $merge: [1, 2, 3] });
console.log('mergeArray:', mergeArray);    // => [ 1, 2, 3, 'd', 'e' ]

// 将 initialObject 和 { name: 'Rose', gender: 'Woman', hobby: 'Swimming' } } 对象进行合并
const mergeObject = update(initialObject, { $merge: { name: 'Rose', gender: 'Woman', hobby: 'Swimming' } });
console.log('mergeObject', mergeObject);    // => { name: 'Rose', age: 22, gender: 'Woman', hobby: 'Swimming' }

{$apply: function}

/**
 * API: {$apply: function}
 * 为目标数组或者对象中某个下标或者属性应用 function
 */
const apply = (val) => val + '--apply'
// 为 initialArray 数组中下标为 1 的元素执行 apply 函数
const applyArray = update(initialArray, { 1: { $apply: apply } });
console.log('applyArray:', applyArray);    // => [ 'a', 'b--apply', 'c', 'd', 'e' ]

// 为 initialObject 对象中 name 属性执行 apply 函数
const applyObject = update(initialObject, { name: { $apply: apply } });
console.log('applyObject:', applyObject);  // => { name: 'Jack--apply', age: 22, gender: 'Man' }

{$add: array of objects}

/**
 * API: {$add: array of objects}
 * 向 Set 中添加元素时,array 是一个对象的数组,向 Map 中添加元素时, array 是一个 [key, value] 的数组
 */
// 将 ['Hello', 'World'] 中的元素添加到 initialSet 后,并返回一个新的 Set
const addSet = update(initialSet, { $add: ['Hello', 'World'] });
console.log('addSet:', addSet);    // => Set { '2', '0', '2', '3', '兔', '年', '快', '乐', 'Hello', 'World' }

// 将 [[3, 'Hello'], ['width', '20px']] 中的元素添加到 initialMap 中,并返回一个新的 Map
const addMap = update(initialMap, { $add: [[3, 'Hello'], ['width', '20px']] });
console.log('addMap', addMap);  // => Map { 'id' => '1', 'color' => 'blue', 3 => 'Hello', 'width' => '20px' }

{$remove: array of strings}

/**
 * API: {$remove: array of strings}
 * 从 Set 或者 Map 中移除 array 中的键列表
 */
// 删除 initialSet 中的 '猪' 和 '年' 这两个元素
const removeSet = update(initialSet, { $remove: ['兔', '年'] });
console.log('removeSet:', removeSet);  // => removeSet: Set { '2', '0', '2', '3', '快', '乐' }

// 删除 initialMap 中的 'color'和 'alias' 对应的两个键值对
const removeMap = update(initialMap, { $remove: ['color', 'alias'] });
console.log('removeMap:', removeMap);  // => Map { 'id' => '1' }

除此之外还可以自定义指令。这些就不一一介绍了,大家遇到了就自行查阅一下文档。

mmutability-helper性能实测

开始实测 immutability-helper 对于 react 渲染的帮助。代码利用 Profiler API 来查看渲染代价。

function App() {
  const [user, setUser] = useState({
    name: "wsafight",
    company: {
      name: "测试公司",
    },
    schools: [
      { name: "测试小学", start: "1998-01-02", end: "2004-01-02" },
      { name: "测试高中", start: "2005-01-02", end: "2007-01-02" },
    ],
  });

  /**
   * Profiler 组件,可以查看渲染
   */
  const renderCallback = (...info) => {
    console.log("渲染原因", info[1]);
    console.log("本次更新 committed 花费的渲染时间", info[2]);
  };

  const handleSchoolsChange = () => {
    user.schools[0].name = "测试小学1";
    setUser({ ...user });
  };

  const handleSchools2 = () => {
    // immutability-helper
    const newUser = update(
      user,
      convertImmutabilityByPath("schools[0].name", {
        $set: "测试小学2",
      }),
    );
    setUser(newUser);
  };

  const handleSchools3 = () => {
    user.schools[0].name = "测试小学3";
    // 深拷贝
    const newUser = JSON.parse(JSON.stringify(user));
    setUser(newUser);
  };

  // 使用 useMemo 优化性能,也可以使用 memo 或者 shouldComponentUpdate
  // 如果 user.schools 不变,则不会重新渲染
  const renderSchools = useMemo(() => {
    return (
      <div>
        {user.schools.map((item) => {
          return (
            <div key={item.name}>
              {item.name}
              {item.start}
              {item.end}
            </div>
          );
        })}
      </div>
    );
  }, [user.schools]);

  return (
    <div className="App">
      <Profiler id="render" onRender={renderCallback}>
        <header className="App-header">
          {user.name}
          <button onClick={handleSchools}>修改学校1</button>
          <button onClick={handleSchools2}>修改学校2</button>
          <button onClick={handleSchools3}>修改学校3</button>
          <div>{renderSchools}</div>
        </header>
      </Profiler>
    </div>
  );
}

我们来看一下结果会怎么样。

测试按钮 1:

  • 点击 修改学校1,触发 handleSchools 函数
  • 渲染原因 update,本次更新 committed 花费的渲染时间 0.8999999999068677
  • 渲染失败,由于 user.schools 没有改变,renderSchools 不会重新渲染
  • 再次点击 修改学校1,触发 handleSchools 函数
  • 渲染原因 update,本次更新 committed 花费的渲染时间 0.10000000009313226

测试按钮 2:

  • 点击 修改学校2,触发 handleSchools 函数
  • 渲染原因 update,本次更新 committed 花费的渲染时间 1.6000000000931323
  • 渲染成功
  • 再次点击 修改学校2,触发 handleSchools 函数
  • 没有进行任何修改,同时也没有触发 renderCallback

测试按钮 3:

  • 点击 修改学校3,触发 handleSchools 函数
  • 渲染原因 update,本次更新 committed 花费的渲染时间 1.300000000745058
  • 渲染成功
  • 再次点击 修改学校3,触发 handleSchools 函数
  • 渲染原因 update,本次更新 committed 花费的渲染时间 0.5

根据上述条件,我们可以看到 immutability-helper 的第二个好处,如果当前数据没有改变,将不会改变对象,从而不会触发渲染。

这里尝试把 schools 数据长度增加到 10002,再做一下测试。发现花费的渲染时间没有太多改变,均在 40 ms 左右,此时我们用 console.time 测试一下深拷贝和 immutability-helper 的时间差距。

const handleSchools2 = () => {
  console.time("浅拷贝");
  const newUser = update(
    user,
    convertImmutabilityByPath("schools[0].name", {
      $set: "测试小学2",
    }),
  );
  console.timeEnd("浅拷贝");
  setUser(newUser);
};

const handleSchools3 = () => {
  user.schools[0].name = "测试小学3";
  console.time("深拷贝");
  const newUser = JSON.parse(JSON.stringify(user));
  console.timeEnd("深拷贝");
  setUser(newUser);
};

得出的结果如下所示

  • 浅拷贝: 1.807861328125 ms
  • 浅拷贝: 0.165771484375 ms(第二次调用)
  • 深拷贝: 8.59716796875 ms

测试下来有 4 倍的性能差距,再尝试在数据中添加 4 个 schools 大小的数据.

  • 浅拷贝: 3.60302734375 ms
  • 浅拷贝: 0.10107421875 ms(第二次调用)
  • 深拷贝: 28.789794921875 ms

可以看到,随着数据的增大,耗费的时间差距也变得非常恐怖。

immutability-helper核心源码分析

immutability-helper代码也就三百行左右,去掉相关提示报错函数,实现逻辑还是很好理解的。

对应操作指令函数:

const defaultCommands = {
  $push(value: any, nextObject: any, spec: any) {
    // 数组添加,返回 concat 新数组
    return value.length ? nextObject.concat(value) : nextObject;
  },
  $unshift(value: any, nextObject: any, spec: any) {
    return value.length ? value.concat(nextObject) : nextObject;
  },
  $splice(value: any, nextObject: any, spec: any, originalObject: any) {
    // 循环 splice 调用
    value.forEach((args: any) => {
      if (nextObject === originalObject && args.length) {
        nextObject = copy(originalObject);
      }
      splice.apply(nextObject, args);
    });
    return nextObject;
  },
  $set(value: any, _nextObject: any, spec: any) {
    // 直接替换当前数值
    return value;
  },
  $toggle(targets: any, nextObject: any) {
    const nextObjectCopy = targets.length ? copy(nextObject) : nextObject;
    // 当前对象或者数组切换
    targets.forEach((target: any) => {
      nextObjectCopy[target] = !nextObject[target];
    });

    return nextObjectCopy;
  },
  $unset(value: any, nextObject: any, _spec: any, originalObject: any) {
    // 拷贝后循环删除
    value.forEach((key: any) => {
      if (Object.hasOwnProperty.call(nextObject, key)) {
        if (nextObject === originalObject) {
          nextObject = copy(originalObject);
        }
        delete nextObject[key];
      }
    });
    return nextObject;
  },
  $add(values: any, nextObject: any, _spec: any, originalObject: any) {
    if (type(nextObject) === "Map") {
      values.forEach(([key, value]) => {
        if (nextObject === originalObject && nextObject.get(key) !== value) {
          nextObject = copy(originalObject);
        }
        nextObject.set(key, value);
      });
    } else {
      values.forEach((value: any) => {
        if (nextObject === originalObject && !nextObject.has(value)) {
          nextObject = copy(originalObject);
        }
        nextObject.add(value);
      });
    }
    return nextObject;
  },
  $remove(value: any, nextObject: any, _spec: any, originalObject: any) {
    value.forEach((key: any) => {
      if (nextObject === originalObject && nextObject.has(key)) {
        nextObject = copy(originalObject);
      }
      nextObject.delete(key);
    });
    return nextObject;
  },
  $merge(value: any, nextObject: any, _spec: any, originalObject: any) {
    getAllKeys(value).forEach((key: any) => {
      if (value[key] !== nextObject[key]) {
        if (nextObject === originalObject) {
          nextObject = copy(originalObject);
        }
        nextObject[key] = value[key];
      }
    });
    return nextObject;
  },
  $apply(value: any, original: any) {
    // 传入函数,直接调用函数修改
    return value(original);
  },
};

核心功能,update入口:

export class Context {
  
  // 复制所有指令函数
  private commands = assign({}, defaultCommands);
  constructor() {
    this.update = this.update.bind(this);
    // Deprecated: update.extend, update.isEquals and update.newContext
    (this.update as any).extend = this.extend = this.extend.bind(this);
    (this.update as any).isEquals = (x, y) => x === y;
    (this.update as any).newContext = () => new Context().update;
  }
  get isEquals() {
    return (this.update as any).isEquals;
  }
  set isEquals(value: (x, y) => boolean) {
    (this.update as any).isEquals = value;
  }
  public extend<T>(directive: string, fn: (param: any, old: T) => T) {
    this.commands[directive] = fn;
  }

  // 核心功能,update入口
  // 如update(data,{$push:[1,2,3]})
  public update<T, C extends CustomCommands<object> = never>(
    object: T, // data
    $spec: Spec<T, C>, // {$push:[1,2,3]}
  ): T {
    // 操作命令是函数, 改为$apply: $spec
    const spec = (typeof $spec === 'function') ? { $apply: $spec } : $spec;
    
    let nextObject = object;
     // 遍历对象,获取数据项和指令
    getAllKeys(spec).forEach(key => {
      // 传入的是一个对象,如果当前 key 是指令的话,就进行操作
      if (hasOwnProperty.call(this.commands, key)) {
        // 性能优化,遍历过程中,如果 object 还是当前之前数据
        const objectWasNextObject = object === nextObject;
        // 调用对应指令修改对象
        nextObject = this.commands[key](spec[key], nextObject, spec, object);

        // 修改后,两者使用传入函数计算,还是相等的情况下,直接使用之前数据
        // 这样的话,数据没有修改,对象也不会改变
        if (objectWasNextObject && this.isEquals(nextObject, object)) {
          nextObject = object;
        }
      } else {
        // 不在指令集中,做其他操作
        // 类似于 update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
        // 解析对象规则后继续递归调用 update, 不断递归,不断返回
        const nextValueForKey =
          type(object) === 'Map'
            ? this.update((object as any as Map<any, any>).get(key), spec[key])
            : this.update(object[key], spec[key]);
        const nextObjectValue =
          type(nextObject) === 'Map'
              ? (nextObject as any as Map<any, any>).get(key)
              : nextObject[key];
        // 内部数据有改变的情况下,进行 copy 操作
        if (!this.isEquals(nextValueForKey, nextObjectValue)
          || typeof nextValueForKey === 'undefined'
          && !hasOwnProperty.call(object, key)
        ) {
          if (nextObject === object) {
            nextObject = copy(object as any);
          }
          if (type(nextObject) === 'Map') {
            (nextObject as any as Map<any, any>).set(key, nextValueForKey);
          } else {
            nextObject[key] = nextValueForKey;
          }
        }
      }
    });
    
    return nextObject;
  }
}

参考文献