不可变数据与 React - 图1

https://sebastienlorber.com/records-and-tuples-for-react

tc39 有一个关于不可变数据的提案,目前已经处于 TC39 的第二阶段(stage2),polyfill 也已经有了,愿意尝鲜的人可以试一试。

这个提案为 js 新增了两个基本数据类型 Records 和 Tuples ,中文一般翻译为 记录 和 元组。 这为 JavaScript 增添了完全不可变的数据结构

不断变化的 const

虽然 const 是常量的意思,但是在 javascript 中 const 只能保证原始值不可变(字符串,数字,BigInt,布尔值,符号 和 undefined),如果我们用的是 {} 和 [], 那你还是可以修改它们的。而且最麻烦的是

  1. console.log({} === {}); // false
  2. console.log([] === []); // false
  3. const a = [];
  4. const b = a;
  5. a.push(1);
  6. // a = [], b = []
  7. a === b; // true
  8. const str = "my string";
  9. console.log(str === "mystring"); // true
  10. const num = 123;
  11. console.log(num === 123); // true
  12. const arr = [1, 2, 3];
  13. console.log(arr === [1, 2, 3]); // false
  14. const obj = { a: 1 };
  15. console.log(obj === { a: 1 }); // false

除了产生很多面试题,在使用的时候其实是非常容易出错的。尤其是在 react 中,非常容易写一个死循环出来。

  1. const Page = () => {
  2. const a = [];
  3. useEffect(() => {
  4. console.log(a);
  5. }, [a]);
  6. return <div />;
  7. };

Records & Tuples 的优势

Records 和 Tuples 可以认为是 复合基本元素 ,他们是严格的 === 的。

  1. console.log({a: 1, b: [3, 4]} === {a: 1, b: [3, 4]}) // false;
  2. console.log(#{a: 1, b: #[3, 4]} === #{a: 1, b: #[3, 4]}) // true
  3. const t3 = Tuple.from( [1, 2, 3] );
  4. console.log(t3)
  5. // new records
  6. const r1 = #{ a: 1, b: 2 };
  7. const r2 = #{
  8. a: 1,
  9. b: #{ c: 2 }, // child record
  10. d: #[ 3, 4 ] // child tuple
  11. };
  12. // 与顺序无关
  13. const r7 = #{ a: 1, b: 2 };
  14. console.log( r7 === #{ b: 2, a: 1 } ); // true

如果我们修改一个不可变数据,那么就会报错。

  1. const r1 = #{ a: 1, b: 2 };
  2. r1.a = 2; // will error
  3. const t5 = #[1, 2, [3, 4]];
  4. t5.push('2') // will error

这样可以保证这个数据是真正的不可变数据。是可以严格的 ===,这样在 react 中真的可以的。

Records & Tuples for React

React 中我们总是认为 props 和 state 不可变的,但是其实他们是可变的。很多新手都可能因为操作 state 的导致的问题。下面就是个非常好的例子。

  1. const Hello = ({ profile }) => {
  2. // prop mutation: throws TypeError
  3. profile.name = 'Sebastien updated';
  4. return <p>Hello {profile.name}</p>;
  5. };
  6. function App() {
  7. const [profile, setProfile] = React.useState(#{
  8. name: 'Sebastien',
  9. });
  10. // state mutation: throws TypeError
  11. profile.name = 'Sebastien updated';
  12. return <Hello profile={profile} />;
  13. }

现在通过递归调用Object.freeze() 来实现不可变性。 但是使用的时候的每次修改都要重复调用 Object.freeze() 来模拟。

同样在 React 中有许多种实现 immutable state 更新的方式:vanilla JS,Lodash Set,ImmerJS, ImmutableJS 等。但是这些库都需要引入新的 API, 或者需要特殊的 API 才能使用。而 Records & Tuples 使用起来和真正的数据差不多,几乎不会引入新的 API .

  1. const initialState = #{
  2. user: #{
  3. firstName: "Sebastien",
  4. lastName: "Lorber"
  5. }
  6. company: #{
  7. name: "Lambda Scale",
  8. }
  9. };
  10. const updatedState = {
  11. ...initialState,
  12. company: {
  13. ...initialState.company,
  14. name: 'Freelance',
  15. },
  16. };

截止目前,ImmerJS 已经赢得了 immutable updates 之战,因为它处理嵌套属性的方式更为简单,且与常规 JS 代码拥有很好地互操作性。

  1. import produce from "immer";
  2. const baseState = [
  3. {
  4. todo: "Learn typescript",
  5. done: true,
  6. },
  7. {
  8. todo: "Try immer",
  9. done: false,
  10. },
  11. ];
  12. const nextState = produce(baseState, (draftState) => {
  13. draftState.push({ todo: "Tweet about it" });
  14. draftState[1].done = true;
  15. });
  16. onBirthDayClick2 = () => {
  17. this.setState(
  18. produce((draft) => {
  19. draft.user.age += 1;
  20. })
  21. );
  22. };


useMemo

除了缓存(memoizing)昂贵的操作以外,useMemo() 在避免创建新的对象标识时也很有用,它可以避免触发无用计算,re-renders,或树中更深层的副作用执行。
让我们考虑以下用例:你有一个含有多个过滤器的 UI 组件,并需要从后端获取一些数据。

使用目前版本的 React 代码可能如下:

  1. // Don't change apiFilters object identity,
  2. // unless one of the filter changes
  3. // Not doing this is likely to trigger a new fetch
  4. // on each render
  5. const apiFilters = useMemo(() => ({ userFilter, companyFilter }), [
  6. userFilter,
  7. companyFilter,
  8. ]);
  9. const { apiData, loading } = useApiData(apiFilters);

而使用 Record & Tuples,可以简化成:

  1. const { apiData, loading } = useApiData(#{ userFilter, companyFilter });

useEffect

继续使用上面的示例:

  1. const apiFilters = { userFilter, companyFilter };
  2. useEffect(() => {
  3. fetchApiData(apiFilters).then(setApiDataInState);
  4. }, [apiFilters]);

比较尴尬的是,这个组件的 apiFilters 对象标识每次都会发生改变,导致这个 fetch 的副作用会重新执行。而 setApiDataInState 又会触发 re-render,你最终会陷入一个 fetch/render 的无限循环。

这个问题在 React 开发中很常见,以至于 Google 中搜索 useEffect + “infineite loop”(无限循环) 关键字会有上千条结果。

在实际的使用中我一般是 JSON.stringify 来进行比较,虽然性能不怎么样,但是真的很快。

  1. const apiFiltersString = JSON.stringify({
  2. userFilter,
  3. companyFilter,
  4. });
  5. useEffect(() => {
  6. fetchApiData(JSON.parse(apiFiltersString)).then(setApiDataInState);
  7. }, [apiFiltersString]);

这些方案与 Records & Tuples 对比则显得啰嗦且不实用

  1. const apiFilters = #{ userFilter, companyFilter };
  2. useEffect(() => {
  3. fetchApiData(apiFilters).then(setApiDataInState);
  4. }, [apiFilters]);

Records as React key

假设我们有一个 list 要渲染:

  1. const list = [
  2. { country: 'FR', localPhoneNumber: '111111' },
  3. { country: 'FR', localPhoneNumber: '222222' },
  4. { country: 'US', localPhoneNumber: '111111' },
  5. ];

你会用什么作为 key?

考虑到 country 和 localPhoneNumber 在列表中并非唯一值,你有两种选择。

将数组下标作为 key

  1. <>
  2. {list.map((item, index) => (
  3. <Item key={`poormans_key_${index}`} item={item} />
  4. ))}
  5. </>

这通常可以正常工作,但它并非最佳方式,尤其是如果 list 中每项数据进行重新排序的话。

组合成 key

  1. <>
  2. {list.map((item) => (
  3. <Item
  4. key={`${item.country}_${item.localPhoneNumber}`}
  5. item={item}
  6. />
  7. ))}
  8. </>

这种方式能更好地解决 list 重新排序的问题,但只发生在我们确保 couple/tuple 为唯一值的情况下。

这种情况下,直接用 Records 作为 key 会更加方便:

  1. const list = #[
  2. #{ country: 'FR', localPhoneNumber: '111111' },
  3. #{ country: 'FR', localPhoneNumber: '222222' },
  4. #{ country: 'US', localPhoneNumber: '111111' },
  5. ];
  6. <>
  7. {list.map((item) => (
  8. <Item key={item} item={item} />
  9. ))}
  10. </>

这个方案是 Morten Barklund 提出的。

总结

React 的大部分性能和行为问题都与对象标识有关。

Records & Tuples 通过提供某种 “自动记忆” 的方式,确保对象标识开箱即用且 “更稳定”,帮助我们更容易地解决可变导致的 react 问题。

使用 TypeScript,API 会更加方便,提示更加智能。