111

朋友推荐下,看了别的大佬关于immer的一篇文章: juejin.cn/post/721984... 感觉不是很得劲儿,所以决定换个思路重新写一下:

  1. 什么是immer? npm:www.npmjs.com/package/imm... 考虑一个经典的待办列表场景,要求可以通过一个input框增加todos/点击单行todo可以修改这一行todo的完成状态:
js 复制代码
const getCompeletedStyleSheet: (completed: boolean) => React.CSSProperties = (
  completed
) => ({
  textDecoration: completed ? "line-through" : "none",
  cursor: "pointer",
});

function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: "学习React", completed: false },
    { id: 2, text: "编写示例代码", completed: false },
  ]);

  const addTodo = () => {
    // ...待补充...
  };

  const toggleTodoStatus = () => {
    // ...待补充...
  };

  return (
    <div>
      <h1>待办事项列表</h1>
      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            onClick={toggleTodoStatus}
            style={{ ...getCompeletedStyleSheet(todo.completed) }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
      <input
        type="text"
        placeholder="添加新任务"
        onKeyDown={(e) => {
          if (e.key === "Enter" && e.target.value.trim() !== "") {
            addTodo();
            e.target.value = "";
          }
        }}
      />
    </div>
  );
}

export default TodoApp;

其中addTodo和toggleTodoStatus方法的常规实现如下:

js 复制代码
    // addTodo
    const addTodo = (text: string) => {
        setTodos(prev => {
            return [...prev, {id: Date.now(), text, completed: false}]
        })
    }
    
    // toggleTodoStatus
    const toggleTodoStatus = (id) => { 
        const newTodos = [...todos]; 
        const todo = newTodos.find((t) => t.id === id); 
        if (todo) { 
            todo.completed = !todo.completed; 
            setTodos(newTodos); 
        } 
    };

总体代码如下:

js 复制代码
const getCompeletedStyleSheet: (completed: boolean) => React.CSSProperties = (
  completed
) => ({
  textDecoration: completed ? "line-through" : "none",
  cursor: "pointer",
});

function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: "学习React", completed: false },
    { id: 2, text: "编写示例代码", completed: false },
  ]);

  const addTodo = (text: string) => {
    setTodos((prev) => {
      return [...prev, { text, id: Date.now(), completed: false }];
    });
  };

  const toggleTodoStatus = (id: number) => {
    const newTodos = [...todos];
    const todo = newTodos.find((t) => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
      setTodos(newTodos);
    }
  };

  return (
    <div>
      <h1>待办事项列表</h1>
      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            onClick={() => toggleTodoStatus(todo.id)}
            style={{ ...getCompeletedStyleSheet(todo.completed) }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
      <input
        type="text"
        placeholder="添加新任务"
        onKeyDown={(e: any) => {
          if (e.keyCode === 13 && e.target.value.trim() !== "") {
            addTodo(e.target.value);
            e.target.value = "";
          }
        }}
      />
    </div>
  );
}

export default TodoApp;

这里有一个react最基本的概念,为什么在实现addTodos的时候我们不能像下面这么做?

js 复制代码
  const addTodo = (text: string) => {
    setTodos((prev) => {
      prev.push({ id: Date.now(), text, completed: false });
      return prev
    });
  };

这貌似是一个在vue中极其常规的做法(vue写的不多),甚至vue中都不用调用setTodos,直接push好像就可以了。原因简单来说就是react和vue的更新机制导致的。这里要稍微说一些题外话,react和vue的都是"声明式"渲染框架,声明式渲染指的是只需要操作数据层,而不操作视图层,数据层到视图层的映射完全交给框架完成。用大白话说就是,我们从来不通过改写domEle.innerContent来渲染dom的某一部分,而是通过setState(react)或者ref(vue3)来通知框架,进而触发视图更新。

那么问题来了,vue和react是如何做到让数据变化后通知到视图层呢?vue3的做法是,在ref的实现中通过Proxy去代理我们递给他的数据,我们在每次set操作的时候框架层面是有感知的,进而触发组件粒度的更新;react的做法比较暴力,调用setState会触发全量视图更新,准备重渲染的时候只需要比较数据是否发生变化,就能知道是否需要重渲染dom。

有人可能会说哇塞vue好厉害,更新粒度这么细。实际上差别也没有想象的那么大,首先现代浏览器的js执行比你想象的快很多,主要渲染耗时花销都是dom的渲染,纯js执行(react runtime)不会太慢,其次react内部有很多优化,比如我们前面提到的"数据比较":react在更新的时候会使用Object.is来比较新旧数据的引用是否发生变化,如果结果为true,就会跳过这一部分的更新。在react中,这一块数据被称为"不可变数据"。使用不可变数据的好处显而易见,我只需要知道引用是否发生了变化,我就能知道是否需要重新渲染视图。

说了这么多,上面那段代码不生效的原因也很清晰了:push方法并不会改变原有数据引用,所以react判断这里的数据没有发生改变,自然不会触发视图的更新。

但是,用正确的方式"..."来写react代码着实有点让人觉得很丑陋,我能push为什么不直接push,非要做展开?这时候immer库就是来做这件事情的。

让我们用immer改写addTodo和toggleTodoStatus

js 复制代码
// 使用immer来更新状态 
import { produce } from 'immer';

const addTodo = (text) => { 
    setTodos( 
        produce(todos, (draft) => { 
                draft.push({ id: Date.now(), text, completed: false }); 
            }) 
        ); 
}; 

const toggleTodo = (id) => { 
    setTodos( produce(todos, (draft) => { 
            const todo = draft.find((t) => t.id === id); 
            if (todo) { todo.completed = !todo.completed; } 
        }) 
    ); 
};

我们通过一个produce函数,完全取代了...!有人会杠,与其使用引入一个新包,我不如...。我只能说仁者见仁吧,团队里大家都很熟练、代码写的都很好的时候确实不需要immer,写惯了...从不写push的人也不需要用immer,这就不在本文的讨论范围内了。

  1. immer实现

说了这么多,我们要开始实现immer了。

根据上面的例子,我们用ts先实现一下immer.produce的类型定义:

ts 复制代码
    // produce函数的第二个参数,用于修改不可变数据
    type Producer<T> = (draft: T) => void;

    // produce函数类型定义
    type Produce<T> = (immutableData: T, producer: Producer<T>) => T

想一想,immer.produce这个函数要实现什么功能?

简单呀,直接一个深copy,然后执行一下producer修改状态就行了嘛!那我们先实现一下(深拷贝就不实现了,直接lodash)

ts 复制代码
const produce: <T>(immutableData: T, producer: (draft: T) => void) => T = (immutableData, producer) => {
  // 创建原始数据的深拷贝
  const copy = lodash.cloneDeep(immutableData);

  // 在拷贝上应用更新函数
  producer(copy);

  // 返回修改后的拷贝
  return copy;
}

放进我们的todoApp中测试一下,完全没问题!说明这个方法在我们的场景下是没问题的!

但很明显,如果这么简单,也没必要专门有个immer库来解决问题了是不?那么immer到底为我们做了什么?

借用一下大佬的代码,看得很清楚:

js 复制代码
import { produce } from "immer"; 

const obj = { a: {}, b: [1, 2, 3], c: { d: { e: [3, 4, 5], f: 6, }, f: 7, }, }; 

const copy = produce(obj, () => { // nothing to do }); 
const modified = produce(obj, (draft) => { draft.b.push(4); draft.c.f = 8; }); 

console.log(copy === obj); // true 
console.log(modified === obj); // false 
console.log(modified.a === obj.a); // true 
console.log(modified.b === obj.b); // false 
console.log(modified.c === obj.c); //false 
console.log(modified.c.d === obj.c.d); // true

  
作者:叁十四城  
链接:https://juejin.cn/post/7219847723092820026  
来源:稀土掘金  
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

如果用我们的produceByCloneDeep,很明显上面的所有log都会返回false。我们逐条分析log:

  1. copy === obj true:copy的producer函数什么都没有做,所以produce函数"原封不动"地把这个对象还给我们了。
  2. modified === obj false:modified的producer函数有一些改动,所以produce函数创建了一个新的对象引用。
  3. modified.a === obj.a true:我们没有改动空对象a,所以a的引用没有发生变化。
  4. modified.b === obj.b false:我们对b进行了push操作,所以b的引用发生了变化。
  5. modified.c === obj.c false:我们对c.f进行了修改操作,所以c的引用发生了变化。
  6. modified.c.d === obj.c.d true:我们只对c.f进行了修改操作,所以c.d的引用没有发生变化。

所以我们要想办法,让"发生变化的节点"以及"它的父节点"根据producer函数创建一个新对象引用,让"没有发生变化的节点"保持原样。

那么第一步,我们怎么来区分发生变化的节点和没有发生变化的节点呢?换句话说,我们的代码实现中,如何能够知道producer函数对draft的某些属性进行了修改?

答案很简单,就在vue里:Proxy/defineProperty。

我们先用es6 Proxy实现一下。注意,这一步我们只需要"监听"到外层对属性进行修改了,不做其他操作。用大白话说,就是打印一下被修改属性的kv值。

ts 复制代码
type Draft<T> = {
  [K in keyof T]: T[K] extends object ? Draft<T[K]> : T[K];
};

export const produce: <T extends Record<string, any>>(immutableData: T, producer: (draft: Draft<T>) => void) => T = (immutableData, producer) => {
  const draft = createDraft(immutableData);

  应用更新函数
  producer(draft);

  // 返回修改后的草稿
  return draft;
}

const createDraft: <T extends Record<string, any>, >(tgt: T) => T = (immutableData) => {
  const proxy = new Proxy(immutableData, {
    set(_, propName, newVal) {
      console.log(`property ${String(propName)} as been set to ${typeof newVal === 'object' ? JSON.stringify(newVal) : newVal}`)
      return true;
    }
  })

  return proxy
}

// 运行这段代码:
  const obj = {
    a: {},
    b: [1, 2, 3],
    c: {
      d: {
        e: [3, 4, 5],
        f: 6,
      },
      f: 7,
    },
  };

  const copy = produce(obj, () => {
    // nothing to do
  });
  const modified = produce(obj, (draft) => {
    draft.a = {};
    draft.b.push(4);
    draft.c.f = 8;
  });

结果如下: 看起来只有a的修改被proxy监听到了?但我们明明对b进行了push,对c.f进行了修改啊?

细细一想,b.push是一个push操作,并不修改原数组,所以不会触发setter(也可以说,数组是一个子对象,我们目前还没有监听子对象的变更);我们只代理了obj,没有代理obj.c,所以c.f的修改也不会触发setter(因为c的引用没有发生变化)

针对数组的push,我们只能通过get陷阱函数实现(因为obj.b实际上也是对b的一个get)。 针对子节点的代理,我们只需要递归调用createDraft。

完整createDraft实现如下:

ts 复制代码
const createDraft: <T extends Record<string, any>, >(tgt: T) => T = (target) => {
  const proxy = new Proxy(target, {
    set(_, propName, newVal) {
      console.log(`normal property [${String(propName)}] as been set to ${typeof newVal === 'object' ? JSON.stringify(newVal) : newVal}`)
      return true;
    },

    get(target, propName: string) {
      if (Array.isArray(target[propName])) {
        console.log(`array property [${String(propName)}] as been changed`)
      }

      if (typeof target[propName] === 'object' && typeof target[propName] !== null) {
        Reflect.set(target, propName, createDraft(target[propName]))
      }
      return target[propName];
    }
  })

  return proxy
}

到现在,我们成功监听了所有的属性变更。虽然索引变更和length变更不一定是我们需要的,但别急,我们后面再说。

我们现在已经能够知道哪些属性发生了变化了,并且,从我们的代码中我们可以很清晰的明确一点:只有在producer函数内进行修改的属性,才会生成代理对象,否则不会生成代理对象。

那么,要实现我们immer的produce方法,达成让【"发生变化的节点"以及"它的父节点"根据producer函数创建一个新对象引用,让"没有发生变化的节点"保持原样。】的需求,我们就需要收集这些变化。

怎么收集呢?很容易想到嘛,用一个对象试试(删除了之前那些log,并且对ProxyHandler做了抽离):

ts 复制代码
export const produce: <T extends Record<string, any>>(immutableData: T, producer: (draft: T) => void) => T = (immutableData, producer) => {
  // const mutations = new WeakMap<object, object>();
  const mutations: any = {};

  const draft = createDraft(immutableData, mutations);

  // 在草稿上应用更新函数
  producer(draft);
  console.log('###mutations', mutations)

  // 返回修改后的草稿
  return draft;
}

const createDraft: <T extends Record<string, any>>(immutableData: T, mutations: any) => T = (immutableData, mutations) => {
  type T = typeof immutableData;

  const handler: ProxyHandler<T> = {
    set(_, propName: string, newVal) {
      // 在修改之前创建一个浅拷贝,数组和对象区分对待
      const copied = Array.isArray? [...immutableData] : {...immutableData};
      
      const success = Reflect.set(copied, propName, newVal);
      
      return success;
    },

    get(target, propName: string) {
      const value = Reflect.get(target, propName);

      return value && typeof value === 'object' ? createDraft(value, mutations) : value;
    }
  }

  const proxy = new Proxy(immutableData, handler)

  return proxy
}

代码中使用了Reflect.set和Reflect.get,基本等价于obj[property] = xxx/obj[property]。

至此,我们也收集到了应有的变动mutaions,接下来的问题就是,把这些变动放到他应该在的位置,并且保证没有变动的属性引用不变。

问题来了---我们收集到的这些kv值,只有一些莫名其妙的3、a、f和一个莫名其妙的lenth属性,我们不需要这些东西,实际上我们只需要一个属性的路径,类似"obj.a"、"obj.c.d.f"这种东西真的再好不过了。

可能吗?想了想,可能,代价有点大。我们每一次set的时候,需要去记录一个路径...想了想觉得有点麻烦,还有别的办法吗?

换个思路,如果mutations对象的key可以是当前"被修改"的对象整体的【引用】,value是被修改后的值,类似于:

js 复制代码
{
    [1, 2, 3]: [1 ,2 ,3, 4]
}

我们在最后递归初始对象的时候,只需要判断当前对象是否是这个mutations对象的一个key,是不是就可以知道这个对象【被修改】了?

所以实现的方法就是HashMap:

ts 复制代码
export const produce: <T extends Record<string, any>>(immutableData: T, producer: (draft: T) => void) => T = (immutableData, producer) => {
  const mutations = new Map<object, object>();
  // const mutations: any = {};

  const draft = createDraft(immutableData, mutations);

  // 在草稿上应用更新函数
  producer(draft);
  console.log('###mutations', mutations)

  // 返回修改后的草稿
  return draft;
}

const createDraft: <T extends Record<string, any>>(immutableData: T, mutations: Map<object, object>) => T = (immutableData, mutations) => {
  type T = typeof immutableData;

  const handler: ProxyHandler<T> = {
    set(_, propName: string, newVal) {
      // 在修改之前创建一个浅拷贝
      const copied = Array.isArray(immutableData) ? [...immutableData] : { ...immutableData }
      
      const success = Reflect.set(copied, propName, newVal);

      mutations.set(immutableData, copied)
      return success;
    },

    get(target, propName: string) {
      const value = Reflect.get(target, propName);

      return value && typeof value === 'object' ? createDraft(value, mutations) : value;
    }
  }

  const proxy = new Proxy(immutableData, handler)

  return proxy
}

再打印一下mutations:

还有问题,数组[1,2, 3]的value有个莫名其妙的empty值...

原因也很简单,js push的时候,先修改数组对应索引(我们的场景下是3)为对应值(我们的场景下为4),然后再修改数组长度length(我们的场景下为4,这也是为什么前面打印了一个length出来)。

针对第二种情况,我们代码中的copied的值会是:

js 复制代码
    copied // [1, 2, 3, empty]

由于哈希表key唯一,所以这个不正确的结果会覆盖[1, 2, 3, 4]。怎么办?很简单啊,针对数组场景,如果哈希表中存在这个key(我们的对象)了,就直接返回true:

ts 复制代码
    set(_, propName: string, newVal) {
      // 在修改之前创建一个浅拷贝
      const copied = Array.isArray(immutableData) ? [...immutableData] : { ...immutableData }

      if (mutations.has(immutableData)) {
        return true
      } else {
        const success = Reflect.set(copied, propName, newVal);

        mutations.set(immutableData, copied)
        return success;
      }
    },

再看看结果:

好 最后一步就是遍历初始值和mutations对象,把对象做合并: todo

完整代码

js 复制代码
import { cloneDeep } from 'lodash'

type Draft<T> = {
  [K in keyof T]: T[K] extends object ? Draft<T[K]> : T[K];
};

export const produceByCloneDeep: <T>(baseState: T, producer: (draft: Draft<T>) => void) => T = (baseData, producer) => {
  // 创建原始数据的深拷贝
  const copy = cloneDeep(baseData);

  // 在拷贝上应用更新函数
  producer(copy);

  // 返回修改后的拷贝
  return copy;
}

export const produce: <T extends Record<string, any>>(immutableData: T, producer: (draft: T) => void) => T = (immutableData, producer) => {
  const mutations = new Map<object, object>();
  // const mutations: any = {};

  const draft = createDraft(immutableData, mutations);

  // 在草稿上应用更新函数
  producer(draft);

  let res = immutableData;

  if (mutations.size) {
    res = applyMutations(immutableData, mutations);
  }

  // 返回修改后的草稿
  return res;
}

const createDraft: <T extends Record<string, any>>(immutableData: T, mutations: Map<object, object>) => T = (immutableData, mutations) => {
  type T = typeof immutableData;

  const handler: ProxyHandler<T> = {
    set(_, propName: string, newVal) {
      // 在修改之前创建一个浅拷贝
      const copied = Array.isArray(immutableData) ? [...immutableData] : { ...immutableData }

      if (mutations.has(immutableData)) {
        return true
      } else {
        const success = Reflect.set(copied, propName, newVal);

        mutations.set(immutableData, copied)
        return success;
      }
    },

    get(target, propName: string, receiver) {
      const value = Reflect.get(target, propName, receiver);

      return value && typeof value === 'object' ? createDraft(value, mutations) : value;
    }
  }

  const proxy = new Proxy(immutableData, handler)

  return proxy
}

const applyMutations: <T extends Record<string, any>>(immutableData: T, mutations: Map<object, object>) => T = (immutableData, mutations) => {
  type T = typeof immutableData;

  if (mutations.has(immutableData)) {
    return mutations.get(immutableData) as T;
  }

  if (Array.isArray(immutableData)) {
    const copiedArray = [...immutableData];

    for (const [key, value] of Object.entries(immutableData)) {
      if (value && typeof value === 'object') {
        // @ts-ignore
        copiedArray[key] = applyMutations(value, mutations);
      }
    }

    return copiedArray as unknown as T;
  } else {
    const copiedObject: Record<string, any> = { ...immutableData };

    for (const [key, value] of Object.entries(immutableData)) {
      if (value && typeof value === 'object') {
        copiedObject[key] = applyMutations(value, mutations);
      }
    }

    return copiedObject as T;
  }
};
相关推荐
优雅永不过时_v2 分钟前
基于vite适用于 vue和 react 的Three.js低代码与Ai结合编辑器
前端·javascript
WildBlue6 分钟前
🧊 HTML5 王者对象 Blob - 二进制世界的魔法沙漏
前端·javascript·html
啷咯哩咯啷10 分钟前
Vue3构建低代码表单设计器
前端·javascript·vue.js
凌览13 分钟前
斩获 27k Star,一款开源的网站统计工具
前端·javascript·后端
爱学习的小学渣16 分钟前
JS用法:Map,Set和异步函数
前端·javascript
独立开阀者_FwtCoder34 分钟前
"页面白屏了?别慌!前端工程师必备的排查技巧和面试攻略"
java·前端·javascript
Hilaku42 分钟前
说实话,React的开发体验,已经被Vue甩开几条街了
前端·javascript·vue.js
星语卿42 分钟前
Js事件循环
javascript
datagear43 分钟前
如何在DataGear 5.4.1 中快速制作HTTP数据源服务端分页的数据表格看板
javascript·数据可视化
namehu1 小时前
“什么?视频又双叒叕不能播了!”—— 移动端视频兼容性填坑指南
javascript·html