react + redux + comlink 构建你的高性能页面
Redux是状态管理。状态管理属于主线程。
React是一个流行的Web框架,有些人喜欢React的组件抽象,有些是因为其庞大的生态系统,而有些则是因为其元平台特性。
我对React的阅读越多,看到React和Redux一起出现的次数就越多。那让我开始思考:我的目标是将非线程架构引入主流Web开发。React和Redux是否符合这一理念?试一试吧!
对我而言,React(就像Preact,Svelte或lit-html一样)主要提供一个令人感兴趣的功能:理想情况下以一种有效的方式将状态转换为DOM。您的业务逻辑操作一个状态对象,并且您的UI框架使用该对象来相应地更新您的UI。这些框架使您可以清楚地将状态和UI分开。尽管如此,我经常看到人们将业务逻辑封装到与组件内部状态相关联的(可视)组件中。这是Redux可以提供帮助的地方。Redux是一种流行的“状态容器”,它将您的状态及其所有突变集中在组件外部的一个位置,从而加强了上述分隔。
正如我刚才所说前,我的口头禅是“仅用于UI工作UI线程”。状态管理不是UI的工作,因此Redux不应在UI线程上运行!由于我自己还没有编写React或Redux,所以我认为我会在几乎每个UI框架中使用规范样本:TodoMVC。当然,Redux有一个Todo MVC示例!
为了让打包支持 worker,本文中我使用了 rollup,当然webpack 也支持 worker,因为我对 rollup 比较熟悉,总而言之,使用哪个构建系统不重要。
开始干活!
首先,我提出了一个无用的计数器应用程序。它有一个reducer。您可以增加和减少它。它使用Redux进行状态管理,使用React进行UI交互,并使用react-redux作为两者之间的粘合剂。让我们看一些代码:我们的状态只是一个计数器。我们可以执行两个操作:递增和递减该计数器。
const reducer = (state = 0, { type }) => { switch (type) { case "INCREMENT": return state + 1; case "DECREMENT": return state - 1; default: return state; } }; const store = createStore(reducer);
这个store变量包含我们的状态容器。通过该存储,我们可以subscribe()陈述更改或dispatch()改变状态的动作。存储界面的(重要部分)如下所示:
interface Store { dispatch(action): void; getState(): State; subscribe(listener: () => void): UnsubscribeFunc; }
对于我们的主要应用程序组件CounterDemo,我们将向connect()我们的状态存储中编写一些原始的HTML以及由此产生的组件:
const CounterDemo = connect(counter => ({ counter }))( ({ counter, dispatch }) => ( <div> <h1>Welcome</h1> <p>The current counter is: {counter}</p> <button onClick={() => dispatch({ type: "INCREMENT" })}>+</button> <button onClick={() => dispatch({ type: "DECREMENT" })}>-</button> </div> ) );
最后一步,我们需要渲染主应用程序组件,该组件由react-redux的组件包裹<Provider>:
ReactDOM.render( <Provider store={store}> <CounterDemo /> </Provider>, document.getElementById("root") );
就这样,我们构建了一个漂亮的 Counter 应用。
接下来我们使用 comlink 对它进行一点点改造。
由于该应用程序非常简单,因此我们的 reducer 函数也是如此。但是,即使是大型应用程序,根据我的经验,状态管理也很少会绑定到主线程上。我们正在做的所有事情也可以在工作程序中完成,因为我们没有使用任何仅主线程的API(例如DOM)。因此,让我们从主文件中删除所有Redux代码,并将其放入worker的新文件中。另外,我们将引入Comlink。Comlink是一个让你可以愉快使用 web worker的库。而不用手动 postMessage()。Comlink不用费力,而是在代理的帮助下实现了RPC的概念(令人惊讶)。Comlink将为您提供一个代理,该代理将“记录”对其执行的所有操作(如方法调用)。Comlink会将这些记录发送给工作人员,针对真实对象重播它们,然后将结果发送回去。这样,即使实际对象位于工作线程中,您也可以在主线程上的对象上工作。考虑到这一点,我们可以转到store一个工作程序并将其代理回主线程:
// worker.js import { createStore } from "redux"; import { expose } from "comlink"; const reducer = (state = 0, { type }) => { // ... same old ... }; const store = createStore(reducer); expose(store);
在主线程上,我们将使用此文件创建一个工作程序,并使用Comlink创建代理:
// main.js import { wrap } from "comlink"; const remoteStore = wrap(new Worker("./worker.js")); const store = remoteStore; ReactDOM.render( <Provider store={store}> <CounterDemo /> <//>, document.getElementById("root") );
remoteStore
拥有所有的方法和属性的store
有,但一切都是异步。更具体地说,这意味着remoteStore
的接口如下所示:
interface RemoteStore { dispatch(action): Promise<void>; getState(): Promise<State>; subscribe(listener: () => void): Promise<UnsubscribeFunc>; }
原因是有点类似RPC的本质。每个方法调用postMessage()
都由Comlink转换为,并且必须等待工作人员返回答复。此过程本质上是异步的。好处是我们只是将所有处理移到工作线程中,而不是从主线程移开了。我们可以使用与remoteStore
以前相同的方式store
。我们只需要记住await
在调用方法时就使用它。
问题
如界面所示,subscribe()
期望将回调作为参数。但是函数不能通过发送postMessage()
,所以会抛出该错误。因此,Comlink提供了proxy()
。包装一个值proxy()
将导致Comlink不发送值本身,而是发送一个代理。因此,就像使用自己的Comlink一样。
另一个问题是getState()
期望同步返回一个值,但是Comlink使它异步。为了解决这个问题,我们必须动手做,并保留我们收到的最新状态值的本地副本。
让我们将所有这两个修复程序放入以下包装中remoteStore
:
import { proxy } from 'comlink';
export default async function remoteStoreWrapper(remoteStore) { const subscribers = new Set(); let latestState = await remoteStore.getState(); remoteStore.subscribe( proxy(async () => { latestState = await remoteStore.getState(); subscribers.forEach(f => f()); }) ); return { dispatch: action => remoteStore.dispatch(action), getState: () => latestState, subscribe(listener) { subscribers.add(listener); return () => subscribers.delete(listener); } }; }
注意:您可能已经注意到,我subscribe()
在这里重新实现了而不是仅仅调用remoteStore.subscribe()
。原因是Comlink存在一个长期存在的问题:当MessageChannel
垃圾桶的一端被垃圾收集时,大多数浏览器将无法对另一端进行垃圾收集,从而永久性地导致内存泄漏。考虑到proxy()
创建一个MessageChannel
,并且subscribe()
可能会被调用很多,所以我选择重新实现订阅机制,以避免建立泄漏的内存。将来,WeakRefs将帮助Comlink解决此问题。
在我们的主文件中,我们必须使用此包装器将其RemoteStore
转换为与以下程序完全兼容的文件Store
:
const store = remoteStore; const store = await remoteStoreWrapper(remoteStore);
完成所有这些操作后,程序就可以运行起来了。一切看起来和行为都相同,但是Redux现在在主线程外运行。
结论
Comlink可以帮助您将逻辑传递给工作人员,而无需进行大规模的重构。我确实在这里采取了一些捷径(例如忽略的返回值remoteStore.subscribe()),但总而言之,这是一个充分利用工作程序的Web应用程序。不仅业务逻辑与视图是分离的,而且状态的处理也不会花费我们任何宝贵的主线程预算。另外,将状态管理移至工作程序意味着对工作程序依赖项的所有解析也都在主线程之外进行。