tc39 有一个关于不可变数据的提案,目前已经处于 TC39 的第二阶段(stage2),polyfill 也已经有了,愿意尝鲜的人可以试一试。
这个提案为 js 新增了两个基本数据类型 Records 和 Tuples ,中文一般翻译为 记录 和 元组。 这为 JavaScript 增添了完全不可变的数据结构。
不断变化的 const
虽然 const 是常量的意思,但是在 javascript 中 const 只能保证原始值不可变(字符串,数字,BigInt,布尔值,符号 和 undefined),如果我们用的是 {} 和 [], 那你还是可以修改它们的。而且最麻烦的是
console.log({} === {}); // false
console.log([] === []); // false
const a = [];
const b = a;
a.push(1);
// a = [], b = []
a === b; // true
const str = "my string";
console.log(str === "mystring"); // true
const num = 123;
console.log(num === 123); // true
const arr = [1, 2, 3];
console.log(arr === [1, 2, 3]); // false
const obj = { a: 1 };
console.log(obj === { a: 1 }); // false
除了产生很多面试题,在使用的时候其实是非常容易出错的。尤其是在 react 中,非常容易写一个死循环出来。
const Page = () => {
const a = [];
useEffect(() => {
console.log(a);
}, [a]);
return <div />;
};
Records & Tuples 的优势
Records 和 Tuples 可以认为是 复合基本元素 ,他们是严格的 === 的。
console.log({a: 1, b: [3, 4]} === {a: 1, b: [3, 4]}) // false;
console.log(#{a: 1, b: #[3, 4]} === #{a: 1, b: #[3, 4]}) // true
const t3 = Tuple.from( [1, 2, 3] );
console.log(t3)
// new records
const r1 = #{ a: 1, b: 2 };
const r2 = #{
a: 1,
b: #{ c: 2 }, // child record
d: #[ 3, 4 ] // child tuple
};
// 与顺序无关
const r7 = #{ a: 1, b: 2 };
console.log( r7 === #{ b: 2, a: 1 } ); // true
如果我们修改一个不可变数据,那么就会报错。
const r1 = #{ a: 1, b: 2 };
r1.a = 2; // will error
const t5 = #[1, 2, [3, 4]];
t5.push('2') // will error
这样可以保证这个数据是真正的不可变数据。是可以严格的 ===,这样在 react 中真的可以的。
Records & Tuples for React
React 中我们总是认为 props 和 state 不可变的,但是其实他们是可变的。很多新手都可能因为操作 state 的导致的问题。下面就是个非常好的例子。
const Hello = ({ profile }) => {
// prop mutation: throws TypeError
profile.name = 'Sebastien updated';
return <p>Hello {profile.name}</p>;
};
function App() {
const [profile, setProfile] = React.useState(#{
name: 'Sebastien',
});
// state mutation: throws TypeError
profile.name = 'Sebastien updated';
return <Hello profile={profile} />;
}
现在通过递归调用Object.freeze() 来实现不可变性。 但是使用的时候的每次修改都要重复调用 Object.freeze() 来模拟。
同样在 React 中有许多种实现 immutable state 更新的方式:vanilla JS,Lodash Set,ImmerJS, ImmutableJS 等。但是这些库都需要引入新的 API, 或者需要特殊的 API 才能使用。而 Records & Tuples 使用起来和真正的数据差不多,几乎不会引入新的 API .
const initialState = #{
user: #{
firstName: "Sebastien",
lastName: "Lorber"
}
company: #{
name: "Lambda Scale",
}
};
const updatedState = {
...initialState,
company: {
...initialState.company,
name: 'Freelance',
},
};
截止目前,ImmerJS 已经赢得了 immutable updates 之战,因为它处理嵌套属性的方式更为简单,且与常规 JS 代码拥有很好地互操作性。
import produce from "immer";
const baseState = [
{
todo: "Learn typescript",
done: true,
},
{
todo: "Try immer",
done: false,
},
];
const nextState = produce(baseState, (draftState) => {
draftState.push({ todo: "Tweet about it" });
draftState[1].done = true;
});
onBirthDayClick2 = () => {
this.setState(
produce((draft) => {
draft.user.age += 1;
})
);
};
useMemo
除了缓存(memoizing)昂贵的操作以外,useMemo()
在避免创建新的对象标识时也很有用,它可以避免触发无用计算,re-renders,或树中更深层的副作用执行。
让我们考虑以下用例:你有一个含有多个过滤器的 UI 组件,并需要从后端获取一些数据。
使用目前版本的 React 代码可能如下:
// Don't change apiFilters object identity,
// unless one of the filter changes
// Not doing this is likely to trigger a new fetch
// on each render
const apiFilters = useMemo(() => ({ userFilter, companyFilter }), [
userFilter,
companyFilter,
]);
const { apiData, loading } = useApiData(apiFilters);
而使用 Record & Tuples,可以简化成:
const { apiData, loading } = useApiData(#{ userFilter, companyFilter });
useEffect
继续使用上面的示例:
const apiFilters = { userFilter, companyFilter };
useEffect(() => {
fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);
比较尴尬的是,这个组件的 apiFilters
对象标识每次都会发生改变,导致这个 fetch 的副作用会重新执行。而 setApiDataInState 又会触发 re-render,你最终会陷入一个 fetch/render 的无限循环。
这个问题在 React 开发中很常见,以至于 Google 中搜索 useEffect + “infineite loop”(无限循环) 关键字会有上千条结果。
在实际的使用中我一般是 JSON.stringify
来进行比较,虽然性能不怎么样,但是真的很快。
const apiFiltersString = JSON.stringify({
userFilter,
companyFilter,
});
useEffect(() => {
fetchApiData(JSON.parse(apiFiltersString)).then(setApiDataInState);
}, [apiFiltersString]);
这些方案与 Records & Tuples 对比则显得啰嗦且不实用。
const apiFilters = #{ userFilter, companyFilter };
useEffect(() => {
fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);
Records as React key
假设我们有一个 list 要渲染:
const list = [
{ country: 'FR', localPhoneNumber: '111111' },
{ country: 'FR', localPhoneNumber: '222222' },
{ country: 'US', localPhoneNumber: '111111' },
];
你会用什么作为 key?
考虑到 country 和 localPhoneNumber 在列表中并非唯一值,你有两种选择。
将数组下标作为 key:
<>
{list.map((item, index) => (
<Item key={`poormans_key_${index}`} item={item} />
))}
</>
这通常可以正常工作,但它并非最佳方式,尤其是如果 list 中每项数据进行重新排序的话。
组合成 key:
<>
{list.map((item) => (
<Item
key={`${item.country}_${item.localPhoneNumber}`}
item={item}
/>
))}
</>
这种方式能更好地解决 list 重新排序的问题,但只发生在我们确保 couple/tuple 为唯一值的情况下。
这种情况下,直接用 Records 作为 key 会更加方便:
const list = #[
#{ country: 'FR', localPhoneNumber: '111111' },
#{ country: 'FR', localPhoneNumber: '222222' },
#{ country: 'US', localPhoneNumber: '111111' },
];
<>
{list.map((item) => (
<Item key={item} item={item} />
))}
</>
这个方案是 Morten Barklund 提出的。
总结
React 的大部分性能和行为问题都与对象标识有关。
Records & Tuples 通过提供某种 “自动记忆” 的方式,确保对象标识开箱即用且 “更稳定”,帮助我们更容易地解决可变导致的 react 问题。
使用 TypeScript,API 会更加方便,提示更加智能。