解构前端框架之响应式和状态管理
先回顾一下上篇用例中的代码:
const App = () => div([
h(Counter, state),
button(
['Click Me'],
{
onClick: () => {
state.count += 1;
render(h(App), document.body); // 手动触发重绘
},
},
),
]);
render(h(App), document.body);
上篇说onClick
里的注释很重要很重要,因为它引出了响应性话题:我们需要手动触发状态改变后的重绘逻辑,这正是jQuery被淘汰的原因。该例子中只要在按钮按下后更新状态还好,假如还有<input>
标签呢?我们不仅要在状态改变时更改输入框里面的值,还要在用户输入后将输入变化同步给应用状态,即所谓的“双向绑定”。一个两个元素都需要手动绑定一组状态更新逻辑,应用复杂之后根本顶不住,稍有疏忽就会产生BUG。因此React和Vue在我心里最大的贡献是实现了响应性,开发者只需要关注状态变更,由框架完成重绘或同步UI状态给应用状态的操作。
React
useState
到目前为止,我们所谓的重绘是将组件函数重新执行了一遍,这建立在组件函数都是纯函数的假设之上,同时框架内部有VDOM缓存,通过比对新旧状态触发Diff Patch,这是典型的React模式。该用例不能自动触发重绘的根源在于:onClick
里面修改state
的动作对框架来说不可感知。还记得我们前面提到的class的坏处吗?state
是一个数据结构,render
是操作这个数据结构的一段逻辑,那么onClick
中state.count += 1
就是绕过了类设计者的心理预期,“偷偷摸摸”修改状态的行为,下面是便于理解的伪码:
class AnonymousClass {
state = { count: 0 };
render = () => div([
h(Counter, this.state),
button(
['Click Me'],
{
onClick: () => {
this.state.count += 1; // No, please no!
},
},
),
]);
}
要克服这个困难,且不能由用户每次手动去绑定重绘逻辑(不然就倒退成了jQuery),那就抽象出一个方法,用户只能使用这个方法修改状态,否则不保证响应性,方法里面封装了触发重绘的逻辑。显然不可能每个类都编写这样的方法,于是提炼到基类中,最好由框架提供。这就是我们熟知的setState
,伪码如下:
class React.Component {
setState(state) {
if (!shallowEquals(state, this.state)) {
this.state = state;
triggerRerender(); // 触发重绘
}
}
}
class AnonymousClass extends React.Component {
state = { count: 0 };
render = () => div([
h(Counter, this.state),
button(
['Click Me'],
{
onClick: () => {
this.setState({ count: this.state.count + 1 });
},
},
),
]);
}
笔者接触React的时间其实要晚于Vue,那时已经是React Hooks元年了。所以我几乎没怎么书写过class组件。那么在函数式组件中,要怎么达成同样效果呢?答案已经呼之欲出了,状态在外面放哪儿根本无所谓,重点是提供一个包装过的方法,这个方法看起来只是修改状态的,其实里面还封装了触发重绘的逻辑,这不就是useState
吗!
于是我们可以做一件有趣的事情,抛开React官方的useState
,自己造个一次性青春版,这是在真实的React项目中编写的例子,你会发现它产生重绘的效果和官方的useState
几乎一致:
import { createRoot } from 'react-dom/client';
let memo: unknown;
function useState<T>(init: T): [T, (value: T) => void] {
const setState = (state: T) => {
if (memo !== state) {
memo = state;
root.render(<App />); // trigger rerender
}
};
if (!memo) setState(init); // initialize
return [memo as T, setState];
}
const App = () => {
const [state, setState] = useState(0);
return <>
<div>{state}</div>
<button onClick={() => setState(state + 1)}>Increment</button>
</>;
};
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(<App />);
useEffect
JS并非纯函数式语言,我们在实际应用中也不可避免地和外部状态打交道,函数中操作外部状态的行为称之为“副作用(Effect)”。React模式每次渲染会将组件函数重新执行一遍,这就不可避免地带来一个问题:有时我们希望组件的副作用只在特定情况下执行,比如使用Timer,我们很可能希望setTimeout
只在组件初始化的时候执行一次,以后除非timeout
变化了,否则都不该执行:
const Foo = () => {
const [timeout] = useState(1000);
setTimeout(bar, timeout); // ???
return <></>;
}
像上面这样不能达成目标,即使timeout
不变,其他因素引起Foo
重绘,每次执行都会挂载一个Timer,也没有清理掉之前的Timer。解决方案依然是在函数外设置缓存,记下Timer ID和上次的timeout
值,Foo
里面通过与缓存的比对判断是否需要执行setTimeout
:
let lastTimeoutId;
let lastTimeoutValue = 1000;
const Foo = () => {
const [timeoutValue] = useState(lastTimeoutValue);
if (!lastTimeoutId || lastTimeoutValue !== timeoutValue) {
clearTimeout(lastTimeoutId);
lastTimeoutId = setTimeout(() => console.log('trigger'), timeoutValue);
}
return <></>;
}
显而易见,这又是一个应该由框架封装的能力,我们将副作用用一个函数包裹,并告知框架在哪些状态变化时才执行之。理解这一点之后,在刚刚绕过React.useState
的基础上,我们也可以“淘汰”React.useEffect
自己实现一个低配版:
let initOrClear: (() => void) | boolean = false; // 一个清理副作用的函数或者表示已初始化的true
let lastDeps: unknown[] = [];
const diffDeps = (oldDeps: unknown[], newDeps:unknown[]) => {
for (let i = 0; i < oldDeps.length; ++i) {
if (oldDeps[i] !== newDeps[i]) return true;
}
return false;
};
export function useEffect<T>(effect: () => void | (() => void), deps: Array<T>): void {
if (!initOrClear || diffDeps(lastDeps, deps)) { // if any deps has changed
if (typeof initOrClear === 'function') initOrClear();
initOrClear = effect() ?? true;
lastDeps = deps;
}
}
const App = () => {
const [timeoutValue, setTimeoutValue] = useState(1000);
useEffect(() => {
const timeoutId = setTimeout(() => console.log('timeout'), timeoutValue);
return () => clearTimeout(timeoutId);
}, [timeoutValue]);
return <>
<button onClick={() => setTimeoutValue(timeoutValue + 1)}>Increment</button>
</>;
};
useState
和useEffect
可以作为很多其他Hooks实现的基石。现在我们要做的,就是汇总以上知识,在自己的微型React框架中实现真正可复用的Hooks,而不是上面的一次性“青春版”。
实现的难点在于怎么封装“青春版”Hooks用到的那些全局变量,比如memo
和initOrClear
,因为我们不知道用户会调用多少次Hook,不可能预先准备足够的全局变量。那用数据结构吧,因为有一个查找旧状态进行比对的过程,首先想到哈希表,但是用什么作为键呢?我最初的想法是直接WeakMap
用状态作为键,值代表状态是否dirty
,很快意识到思路不对,例如useState([])
,别忘了组件函数每次都会重新执行,所以每次都会创建一个新的[]
,和上次的[]
不是一个东西。而且我一开始并没有想到将状态存在组件VNode上,反而想偷懒,用一个全局状态存储,每一项代表一个Hook创建的状态,那么每一项都需要和其所在的组件关联起来,useEffect
的实现也变复杂了。尝试了各种方法都不太对劲,最后翻了一下Preact的源码才恍然大悟:将状态存在组件上,设置两个全局变量currentComponent
和currentHookId
,每次组件函数执行之前将currentComponent
设置为该组件,将currentHookId
置0
,这样组件内部调用Hook时就能通过currentComponent
拿到当前组件,通过currentHookId
拿到Hook所创建状态的编号并作为哈希表的键,这很好地解释了:
- React要求Hooks只能在组件内部执行,否则拿不到
currentComponent
; - React要求Hooks不能放置在分支语句下面,必须是函数体top level,因为走不同分支可能导致
currentHookId
错位。
据说最新v19+的React,这些限制已经放松了,有空我们再来深入研究下。
export type UseStateHookState = { type: 'useState', state: unknown, dirty: boolean };
export type UseEffectHookState = { type: 'useEffect', clearEffect?: () => void };
export interface VNodeComponent<T> extends VNodeBase<T, 'component'> {
vdom?: VNode<T>,
component: (state?: unknown) => VNode<T>,
state?: unknown,
hookState: Map<number, UseStateHookState | UseEffectHookState>,
}
let currentComponent: VNodeComponent;
export const getCurrentComponent = () => currentComponent;
let currentHookId = 0;
export const getCurrentHookId = () => currentHookId++;
export const h = (component: (state: unknown) => VNode, state?: unknown) => {
const vnode: VNodeComponent = {
tag: 'component',
component,
component: (s?: unknown) => {
currentComponent = vnode;
currentHookId = 0;
return component(s);
},
hookState: new Map(),
state,
};
return vnode;
};
完整的Hooks实现代码见这里。不过,这里的实现暂时还没有考虑到useEffect
的异步性质,副作用是在组件渲染后同步触发的。异步的话题我们留在下一节再讨论。
Vue
class组件将一个数据结构和它相关的逻辑内聚在一起本意是好的,响应式的问题只是我们按照符合自己思维模式的方式this.state.count += 1
改变状态的时候,框架不知道我们做了这样的改变。如果,我是说如果,有一个框架能够让我们以这种更自然的方式编写代码,一个状态更新了,那么所有关联的状态和副作用都自动更新或触发,不需要手动声明依赖关系,你会更青睐这个框架吗?
没错,这样的框架是存在的,它就是Svelte Mobx Valtio Vue。实现这个机制的关键在于两个设计模式:代理模式和观察者模式。如果说React是对人们修改状态的方法做了限制(setState
),那么Vue就是对我们初始化状态的方法做了限制(ref
)。我们使用框架API初始化状态之后拿到的其实是一个代理对象,而这个代理本身又是一个被观察的目标,当它改变时,会主动推送更改至所有观察者。由于初始化只要做一次,心智负担通常轻很多。
JS从语言层面支持对象代理,我们用一小段代码就可以说清楚Vue的响应式原理:
type Effect = (oldVal: unknown, newVal: unknown) => void;
const observers: Effect[] = [];
const target = { value: 42 };
// Vue3
const proxy = new Proxy(target, {
set(t, p, newValue) {
const oldValue = Reflect.get(t, p);
const result = Reflect.set(t, p, newValue);
observers.forEach((ob) => ob(newValue, oldValue));
return result;
},
});
// Vue2
// const proxy: Record<string | symbol, unknown> = {};
// Object.defineProperty(proxy, 'value', {
// set(newValue) {
// const oldValue = target.value;
//
// target.value = newValue;
// observers.forEach((ob) => ob(newValue, oldValue));
// },
// get() {
// return target.value;
// },
// });
const watch = (callback: Effect) => {
observers.push(callback);
return () => observers.splice(observers.indexOf(callback), 1);
};
const unwatch = watch((o, n) => console.log(`oldValue: ${o}, newValue: ${n}`));
proxy.value = 0; // oldValue: 42, newValue: 0
unwatch();
proxy.value = 42; // silent
现在我们作为框架的实现者,要做的就是把上面这段代码进一步抽象,使之成为用户可用的API。用户初始化状态时,在API内部,创建代理对象,并预设一些触发Diff Patch的observers
,最后返回这个代理给用户即可。通过在状态初始化时做一点额外工作,以后更改状态时就能“精确定位”到关联的VDOM、衍生状态、副作用等等。
Vue3说它的Hooks只需要执行一次,这是针对setup
函数只需要执行一次来说的。在去掉所有的语法糖之后,setup
返回的那个函数(在Vue语境中称为渲染函数)才真正和React语境中的函数组件是等价的。因此,得益于我们的微型React和Vue使用同一套VDOM后端,在我们的框架中可以写出这样一段“疯狂”的代码:
const counter = {
setup() {
const ref = vue.ref(0);
vue.watch(ref, (n) => console.log(`Outside: ${n}`));
return () => {
const [count, setCount] = react.useState(ref.value);
react.useEffect(() => console.log(`Inside: ${count}`), [count]);
return fragment([
div([`${ref.value}`, `${count}`]),
button(['Outside'], {onClick: () => {ref.value += 1;}}),
button(['Inside'], {onClick: () => setCount(count + 1)}),
]);
};
},
};
把使用Vue Hooks定义的状态理解为类的数据,返回的渲染函数理解为操作数据的类成员函数,React Hooks提供了在纯函数内部定义状态和副作用的手段,理清楚这一点之后应该不难理解setup
其实是个变相的constructor:
class Counter {
state = { vue hooks },
render = () => {
react hooks;
return vdom;
}
}
具体实现上有一个注意点:Vue Hook定义的状态可能与渲染函数无关,当它们改变时不需要触发渲染函数的重新执行。解决方案也很简单,渲染函数要用到的状态,在渲染函数被执行时一定会get
它的值,因此我们只在getter
里面增加状态改变触发Diff Patch的观察者,这也是Vue原理所说的先触摸(touch)再追踪(track)。其他实现细节和React组件大同小异,甚至因为setup()
返回的渲染函数就是一个React语境中的组件函数,可以直接复用之前的VNodeComponent
类型而不用额外定义一个新的VNode
类型。完整代码在这里。