V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐关注
Meteor
JSLint - a JavaScript code quality tool
jsFiddle
D3.js
WebStorm
推荐书目
JavaScript 权威指南第 5 版
Closure: The Definitive Guide
liumingyi1
V2EX  ›  JavaScript

JS 不可变数据踩坑, immer 不是最终出路,高性能场景还需自己实现

  •  2
     
  •   liumingyi1 · 173 天前 · 1568 次点击
    这是一个创建于 173 天前的主题,其中的信息可能已经有所发展或是发生改变。

    JS 不可变数据踩坑,immer 不是最终出路

    不可变数据作为函数式编程的重要组成部分,在很多熟知的模块中都广泛运用,比如 ReactRedux。因此也出现了许多操作不可变数据的库,如 immutable.jsimmerimmutability-helper

    什么是不可变数据

    不可变数据 就是一旦创建,就不能再被更改的数据。对该对象的任何修改或添加删除操作都会返回一个新的对象。要避免深拷贝把所有数据都复制一遍带来的性能损耗,使用 Structural Sharing (结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。

    2.gif

    不可变数据有哪些好处

    1 、降低了 Mutable 带来的复杂度

    function demo() {
      const data = { foo: 'bar' };
      console.log(data);
      data.foo = 'tom';
    }
    
    demo();
    

    打印 {foo: 'bar'} ,但在控制台展开后变成了 { foo: 'tom' } 会带来额外的困扰。

    2 、撤销 /重做 /时间旅行功能实现起来很轻松

    不可变数据每次返回的数据都是不同的,每次修改后将这些数据记录在队列中,修改指针指向就能轻松实现时间旅行。

    React 中使用不可变数据

    Reactstate 是不可变的,意味着你不能直接修改,只能通过 setState 来返回一个新的 state

    React 中,你不能这样做:

    state.a.b.c = 1;
    // or...
    state.c.d.f.push(2);
    

    这是 React 新手常犯的错误。熟悉 React 数据模型后通常你会这样做:

    const nextState = {
      ...state,
      a: {
        ...state.a,
        b: {
          ...state.a.b,
          c: 1,
        },
      },
    };
    

    但你可能会抱怨为什么会这么繁琐,有什么方式能简化它们吗? 记得我当时刚接触 React 的时候,从一个脚手架起步。脚手架中包含了很多常用的库,其中就有 immutable.js

    什么是 immutable.js

    immutable.jsFacebook 工程师 Lee Byron 花费 3 年时间打造,与 React 同期出现。immutable.js 提供了很多持久化不可变数据结构,包括: List, Stack, Map, OrderedMap, Set, OrderedSet 以及 Record

    其中常用的数据结构:

    • Map 键值对集合,对应于 Object
    • List 有序可重复的列表,对应于 Array
    • Set 无序且不可重复的列表,对应于 Set
    • OrderedMap 有序的键值对集合,对应于原生 Map

    一个常见的 immutable.jsredux 中应用:

    // 初始化状态
    const initialStore = fromJS({
      todoList: [
        {
          title: '任务一',
          complete: false,
        },
        {
          title: '任务二',
          complete: false,
        },
      ],
    });
    
    // reducers
    function todoListReducer(state, action) {
      switch (action.type) {
        case 'todos/ADD_TODO':
          return Immutable.update(state, 'todoList', (todoList) =>
            todoList.push(
              Immutable.Map({
                title: '',
                complete: false,
              }),
            ),
          );
        case 'todos/TOGGLE_TODO':
          return Immutable.updateIn(
            state,
            ['todoList', action.index, 'complete'],
            (complete) => !complete,
          );
        default:
          return state;
      }
    }
    
    // 创建 store
    const store = createStore(todoListReducer, initialStore);
    
    // dispatch action
    store.dispatch({ type: 'todos/TOGGLE_TODO', index: 1 });
    

    Immutable.js 中我们使用 fromJS 将原生对象转换为 Immutable.js 对象,转换之后的对象,直接修改是不不会起任何作用的。必须通过提供的 API 来修改数据,并返回新对象。

    这种模式很好地配合 React.PureComponent 做浅比较提升应用的性能。

    3.png

    常见的 API 有:

    操作 List

    • set()
    • delete()
    • insert()
    • clear()
    • push()
    • pop()
    • unshift()
    • shift()
    • update()

    操作 Map

    • set()
    • delete()
    • clear()
    • update()
    • merge()
    • mergeWith()
    • mergeDeep()
    • mergeDeepWith()

    深层级操作

    • setIn()
    • deleteIn()
    • updateIn()
    • mergeIn()

    immutable.js 如何获取数据呢,一般场景来说使用 getgetIn 方法返回具体类型的值。很多情况都会与原生 JS 打交道,所以避免不了 toJS() 方法来转换成一个原生对象。

    immutable.js 对象也提供了像原生对象一样的方法来查询 /转换数据,很多功能类似于 lodash

    比如:

    • map()
    • mapKeys()
    • mapEntries()
    • flatMap()
    • filter()
    • filterNot()
    • reverse()
    • sort()
    • sortBy()
    • groupBy()

    immutable.js 提供了大而全的方法来操作其内部对象。

    它的优点非常多,那它的缺点也很明显:

    • 是它不能很好和第三方库配合,数据类型是割裂的状态。
    • 导致很多功能都必须使用原生对象,造成编码风格不统一,增加所在项目的混乱程度。
    • 而经常 toJS() 也成为了其性能衰减严重。
    • 60KB+ 的尺寸会让很多人望而却步
    • 上手成本高,需要大量学习其内部 API

    自从第一个项目用了 immutable.js 后,我们后面的项目也只是偶尔会用一下,解决复杂场景的数据操作。自从 typescript 火起来后,它便逐渐淡出我的视野,因为它对 ts 类型支持得非常糟糕,一个普通数据操作之后变成了 any,这在大型应用中简直是噩梦。

    2019 年,紧接着 immer 火了,作为 mobx 的基础操作库,新颖使用及实现方式获得一大批人的芳心。

    什么是 immer

    Immer 简化了对不可变数据结构的处理。使用 Immer,将所有更改应用于临时草稿,它是 currentState 的代理。一旦完成了所有的变更,Immer 将根据对草稿状态的变更生成 nextState。这意味着可以通过简单地修改数据来与数据交互,同时保留不可变数据的所有好处。

    4.png

    Immer 中,基本上可以只用一个 API ,那就是 produce。简单的例子:

    const demo = {
      info: {
        user: {
          tom: 'good',
        },
      },
    };
    
    const result = produce(demo, (draft) => {
      draft.info.user.tom = 'ok';
    });
    

    通过 draft(草稿)将当前 scope 中修改的东西最终返回生成新的对象。这种方式既享受了 immutable 的特性,又让开发者有 mutable 修改数据的爽快。

    Immer 巧妙通过 Proxy.revocable 正向代理的方式实现核心逻辑,配合 Object.freeze 将修改后的数据冻结,避免再次修改。Immer 后来一度成为了开发首选项目,直到在一个复杂的项目开始。

    Immer 在极限情况下的性能问题

    Immer 官方说会比普通的 reducer 慢几倍,但极限情况就除外了。举个例子:

    const demo = {
      info: Array.from(Array(10000).keys()),
    };
    
    produce(data3, (draft) => {
      draft.info[2000] = 0;
    });
    

    在一万条平行结构的数据修改时。普通的浅拷贝只需要 0.0061 毫秒,而 immer 上升到了 24 毫秒。如果这个修改频繁触发,就会出现掉帧的情况。

    5.png

    后面我向 Immer 提了一个 Issue https://github.com/immerjs/immer/issues/867 。发现 Object.freeze 非常耗时,通过 API setAutoFreeze(false); 关闭 freeze 后,时间降到了 10 毫秒。但对比普通的浅拷贝依然是被降维打击。

    接着去除了 Immergetter/setter/class 场景下的实现,再经过一些列的优化,比如 shallowCopy 的实现。速度降到了 4 毫秒,对比之前还是有很大的提升,至少不掉帧了。

    6.png

    我们实现了一份公司自己的 immer 拷贝。我想它还有很大的性能提升空间,那就要看 immer 的作者愿不愿意实现了。

    自己实现

    那普通的浅拷贝那么快,我为什么不用普通的对象实现这样的功能呢?说做就做!

    整个元旦我都在快乐的编码实现中。其实 js 实现很简单,难就难在 typescript 类型安全上。如果做出来还像 immutable.js 那样类型不友好的话,那其实做不做意义也不大。快乐的编码 ts 类型优先。

    战斗了好几天,终于出了成果:

    github: https://github.com/MinJieLiu/immot

    为什么取名叫 immot?因为 immer 名字很好听,看 npmjsimmet 没被注册,结果不能 publish 说跟 immer 名字太像了,索性将 e 改成了 oimmotile 单词的部分(意思: 不动的),就这样吧。

    immot 的 API 灵感来自于 immutable-js,但 immutable-js 有独立的结构模型,复杂度高。immot 的设计理念是要求简单、易用,不需要过多的心智负担。因此在设计之初就亲和原生的 JSON 结构,只提供辅助函数,大小 < 1KB ,就做到像 immutable-js 一样的效果。

    immot 做到了 typescript 类型安全。$updateIn$setIn$mergeIn 中的 keyPath 路径支持类型自动提示(目前只支持小于 7 层结构)。

    7.gif

    使用

    import * as immot from 'immot';
    
    // 或者只导入其中某个函数
    import { $updateIn } from 'immot';
    

    immot 所有函数操作都会返回一个新的对象。

    $set

    用于设置 对象 /数组 /Map 中的属性值。keyPath 为字符串。

    const result = immot.$set(demo, 'a', 1);
    

    $setIn

    用于设置 对象 /数组 /Map 中的属性值。它可以为深层对象做操作,keyPath 为路径数组

    const result = immot.$setIn(demo, ['a', 'b', 1, 'c'], 'good');
    

    $merge

    用于合并 对象 /数组 中的属性列表。

    const result = immot.$merge(demo, { tom: 1, jack: 2 });
    
    const result1 = immot.$merge(demo1, [5, 6]);
    

    $mergeIn

    用于合并 对象 /数组 中的属性列表。它可以为深层对象做操作,keyPath 为路径数组

    const result = immot.$mergeIn(demo, ['a', 1, 'b'], { tom: 1, jack: 2 });
    

    $update

    通过回调函数设置 对象 /数组 /Map 中的属性值。keyPath 为字符串。

    const result = immot.$update(demo, 'money', (prev) => prev + 1);
    

    $updateIn

    通过回调函数设置 对象 /数组 /Map 中的属性值。它可以为深层对象做操作,keyPath 为路径数组

    const result = immot.$updateIn(
      demo,
      ['todoList', 0, 'complete'],
      (complete) => !complete,
    );
    

    $delete

    用于删除 对象 /数组 /Map 中的可选属性值,keyPath 为字符串或者数组

    const result = immot.$delete(demo, 'a1');
    const result1 = immot.$delete(demo, ['a1', 'a2']);
    

    $push

    类似 Array.prototype.push,但返回新数组

    const result = immot.$push(demo, 4);
    

    $pop

    类似 Array.prototype.pop,但返回新数组

    const result = immot.$pop(demo);
    

    $shift

    类似 Array.prototype.shift,但返回新数组

    const result = immot.$shift(demo);
    

    $unshift

    类似 Array.prototype.unshift,但返回新数组

    const result = immot.$unshift(demo, 4);
    

    $splice

    类似 Array.prototype.splice,但返回新数组

    const result = immot.$splice(demo, 1, 0, 'test');
    

    性能测试

    /bench 目录中有性能测试对比的样例,可以 clone 本项目测试

    cd bench
    pnpm i
    node index.mjs
    

    注意:

    1. 数值为每秒操作数量,越高越好
    2. 样例中 immer 关闭了自动冻结对象的特性,否则结果会更差。
    3. 数组性能测试图中隐藏了 immutableJS 数据,用空间换取时间的方式导致数值太高,影响对比。

    在 Node v14.17.0 的测试结果:

    常规数据和深层数据 8.png

    50000 长度的数组 9.png

    immot 简单、体积小, gzip 后不足 700 个字节,对体积要求高的项目可以重点关注,最主要是对 typescript 类型友好。用它来写 reducer 太适合了。至于为什么没有提供像 immutable.js 那样的 getIn 的方法。因为原生 JS 支持了 可选链(?.) 语法,已经不需要这样的 API 。

    结语

    整个元旦过得还算充实,主要上午要溜娃,太阳挺晒在身上挺暖和的。下午才会有零零碎碎的时间来写这个模块。整个 immot 写了 快 500 多行测试用例,代码覆盖率做到了 100%,要实现的细节真的很多。各位老铁喜欢的话就点个 star 。

    我创建了一个微信群,欢迎大家一起讨论

    微信图片_20220104225828.jpg

    13 条回复    2022-06-26 08:37:32 +08:00
    cyberpoint
        1
    cyberpoint  
       173 天前
    有点意思。immer 已经够用了,而且生态很好。immot 适合尝鲜吧,看看发展情况。
    zhy0216
        2
    zhy0216  
       173 天前   ❤️ 1
    为什么要在方法名前面加 $
    致敬 jQuery 吗。。。
    zhw2590582
        3
    zhw2590582  
       172 天前
    liumingyi1
        4
    liumingyi1  
    OP
       172 天前
    @zhy0216 如果不加的话,某个操作,如 push 可能会跟数组原生的 push 产生误解
    liumingyi1
        5
    liumingyi1  
    OP
       172 天前
    @zhw2590582 那就太好了,只不过又是 # 号
    2i2Re2PLMaDnghL
        6
    2i2Re2PLMaDnghL  
       172 天前
    $splice 有点尬住,splice 本身也有返回值(即被替换的部分作为一个 array 传出来)
    xyk0279
        7
    xyk0279  
       172 天前
    这些等 record 和 tuple 出来都得淘汰
    liumingyi1
        8
    liumingyi1  
    OP
       172 天前
    @2i2Re2PLMaDnghL 所以加$就是为了区别之间的差异,因为它不改变原数组了
    2i2Re2PLMaDnghL
        9
    2i2Re2PLMaDnghL  
       172 天前
    @liumingyi1 不是说这个,splice 既有副作用又有返回值,而 $splice 只是把副作用的部分提取出来变成返回值,本该是返回值的部分就被吃了。
    liumingyi1
        10
    liumingyi1  
    OP
       171 天前
    @2i2Re2PLMaDnghL 是的呀,本身就不是数组的 splice ,名字相近而已。immutableJS 也是这么做的
    liumingyi1
        11
    liumingyi1  
    OP
       171 天前
    @xyk0279 record 和 tuple 出来倒还更好搭配了,怎么淘汰,record 还不是一样要
    const state2 = #{ ...state1, b: 2 }
    yukinotech
        12
    yukinotech  
       67 天前
    自己本地也试过一些 benchmark ,确实绷不住,原生的太快了。

    交流群二维码失效进不去了,能再拉一下不,有些问题想讨论讨论
    dagger8224
        13
    dagger8224  
       21 小时 54 分钟前
    @liumingyi1 楼主你好,欢迎试用 https://daggerjs.org ,基于 mutation 的响应式框架
    关于   ·   帮助文档   ·   API   ·   FAQ   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   1010 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 22:31 · PVG 06:31 · LAX 15:31 · JFK 18:31
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.