📦 改进一个简单朴素的 react 数据管理层 NOVUS
PS: 建议配合上篇文章食用 📦 实现一个简单朴素的 react 数据管理层 NOVUS
PS3: 前端项目中的数据层被造轮子无数,有很多优秀的库,大厂更不用说内部成熟的解决方案。这只是我在项目中实践的一个解决方案,仅供参考。
当时的出发点主要是想搭上 TS 的语法提示,让可推断的流畅性帮助我们更好的编码,然后完成和主要业务的配合,不过很好的完成了项目的数据支撑。
回过头看其中也遇到了很多问题和隐患:
- 每次 setState 都需要更新全部订阅,即使在一个 EventLoop 中也可能会触发多次
- 每次更新订阅都是同步执行,订阅组件过多的情况,更新操作直接涉及到 render 和相关计算,即使考虑到 react 内置的 fiber,这个也可能会阻塞 CPU,无法响应用户操作,造成卡顿
- 每次订阅操作都需要手动订阅,还需要保证和函数内一致
- 每次订阅的任一状态更新都会触发整个 model 的全部订阅,粒度不精细
- 作为一个基础层的支持,缺少完善的测试
其中一部分是考虑不周,另一方面也要当时的考量。这次改进其中很影响使用的点,目标是用起来不那么磕碜。
这里的 setState 和 react 内部的不一样,后面讨论不要奇怪为什么。
出发点和想保留的特性:
- setState 是显式的更新状态,无意去直接在状态上做代理或者自动检测,想的是更明显的主动更新状态和渲染。
- 及时更新,不像 react 内部实现,每次 setState 之后需要在回调中才能读取更新后的值,这里还是想只要 setState 就更新到数据层上去,同步读取也能得到更新之后的值。
那么问题的解决就只能是在 setState 之后不立马触发事件通知了:
旧的:
/*** 纯函数的数据更新函数,同步执行** @param state 要更新的属性或者函数*/protected setState = <K extends keyof S>(state: Pick<S, K> | ((states: S) => Pick<S, K>)) => {if (typeof state === "function") {const newState = state(this.state)this.state = { ...this.state, ...newState }} else {this.state = { ...this.state, ...state }}this.notifyChanges()}/*** 通知上层触发订阅的 connect 转换函数,每次修改 state 必须触发!!!*/protected notifyChanges = () => {// 需要通知订阅器就同步去通知,立即马上,同一个队列中的多次调用会有卡顿问题novus.notifyChangesListeners(this.namespace)}
旧版本每次调用 setState 都会直接去同步通知全部的订阅,每次调用都会忠实的执行订阅函数,所以不同函数中连续调用会出现问题:
解决方案很简单,让 setState 缓一缓,考虑到 Event Loop 的机制:
/*** 通过标志变量控制多次的事件通知在一个 EventLoop 中只触发一次*/protected waitingNotify = false/*** 通知上层触发订阅的 connect 转换函数,每次修改 state 必须触发!!!*/protected notifyChanges = () => {/** v1 需要通知订阅器就同步去通知,立即马上,同一个队列中的多次调用会有卡顿问题novus.notifyChangesListeners(this.namespace)**/// v2 需要通知订阅器,稍等一下,下个任务队列结束就通知,先把这个任务队列的全部 setState 执行完毕// setState 是同步的,但是事件通知不是同步if (this.waitingNotify) returnthis.waitingNotify = truenew Promise((r) => r()).then(() => {novus.notifyChangesListeners(this.namespace)this.waitingNotify = false})}
这样 notifyChanges 在同一个 EventLoop 中就只会调用一次了。
PS: 其中涉及到 EventLoop 的点在Promise.then
中,我们的目的是将全部 setState 触发的 notifyChanges hold 住,直到本轮事件循环结束。从 Event Loop 的宏任务、微任务的逻辑上考虑,Promise.then 表示把当前回调放到微任务中,所以当前宏任务结束就会去微任务中检查,也就能够在其他宏任务之前触发我们的目标 notifyChanges。
这样,就算函数链中调用了再多次状态更新,触发的事件通知也只会在这一个事件循环结束后才会触发,避免触发太多无用的计算和渲染。
粒度更细,目的是让变更的影响仅限于必要的渲染和计算。
订阅机制是基于整个数据模型的,状态变更 -> 通知订阅 -> 订阅重新计算全部的组件状态 -> 组件状态触发渲染。
旧版本的机制是直接计算出结果就更新到状态,因为计算结果是新的值,无论是否整个状态是不是确实有变动,都会导致重新渲染的触发。
下面简单浅比较一下,能够稍微避免不必要的渲染:
setState(oldState => {let newState = mapStatetoHooks(novus.models, novus)/*** 这个地方* 1. 视图与状态是一一对应,如果 state 没有变,那么不应该通知渲染组件* 2. 如果按照事件来说,这里确实有 setState 的调用,应该通知渲染组件,组件是否影响子组件可以用 Pure 子组件里控制*/// ↓ 浅比较一下判断是否需要更新 state,触发订阅for (const key in oldState) {if (oldState[key] !== newState[key]) {return newState}}return oldState// ↓ 不比较,只要有 setState 动作,就更新 state// return newState;})
如果严格执行状态的粒子性,也就是每个 model 的状态都是基础数据类型或者不可变数据,那么这个浅比较会避免非预期触发(对象引用非原地未修改)或者漏触发问题(对象引用原地修改)。
旧版本在逻辑中负责应用 hooks 的
useState
状态初始化用到的是用户自定义的函数,一开始计算出初始值就完成了他的任务,但是在后续的渲染中,因为函数组件的缘故,即使不需要了,这个计算也没有免除。let [state, setState] = useState<T>(mapStatetoHooks(novus.models, novus))
至于解决方法,只需要一个保持不变的引用,也就是
ref
即可。const initState = useRef<T | null>(null);if(initState.current === null) {initState.current = mapStatetoHooks(novus.models, novus);}let [state, setState] = useState<T>(initState.current);
能够减少一点不必要的计算,也算是性能优化的贡献了。
这个是非常大的痛点,因为旧版本每次
useNovus
的时候,都需要 deps 依赖列表,但是手动写的这个值应该和函数中调用到的相对应,这不应该是开发者需要小心翼翼自己腾挪拷贝命名空间的地方。const [isTitleChanged, isContentChanged, diffStyle] = useNovus(models => {return [models.noteModel.state.isTitleChanged,models.noteModel.state.isContentChanged,models.noteModel.state.diffStyle,]},["noteModel"] // <------------!!)
那么怎么知道函数中使用到那些 model 了呢?
// 提供一个获取 model 的方法,使用这个方法在初始化状态的时候收集依赖const mapStatetoProps = getModel => {return [getModel('noteModel').state.isTitleChanged,getModel('noteModel').state.isContentChanged,]}const deps = new Set([])const getModel = (namespace) => {deps.add(namespace) // 函数里面收集return novus.getModel(namespace)}initState = mapStatetoProps(getModel)
或者使用:
const mapStatetoProps = models => {return [models.noteModel.state.isTitleChanged,models.noteModel.state.isContentChanged,]}const deps = new Set([])// 使用 Proxy 代理的形式,拦截对 models 的访问const handler = {get: function(obj, prop) {deps.add(prop) // 在代理里面获取return prop in obj ? obj[prop] : undefined;}};const models = new Proxy(novus.models, handler);initState = mapStatetoProps(models)
两种方法调用形式不同,但是原理相似,前面的函数调用可能有点不灵活,所以代理 Proxy 的形式可能更好些。
let initState;const _deps = useRef<string[] | undefined>(deps);if (typeof deps === "undefined") {// 一个依赖列表const autoDeps = new Set([]);// 代理的处理函数const handler = {// 每次获取元素的时候代理到这里get(target: TModelsPool, key: string) {// 收集这次的 model 命名空间依赖if (target[key]) {autoDeps.add(key);}// 不破坏预期的处理逻辑return target[key];},};// 在原 novus.models 上添加这个代理const autoModels = new Proxy(novus.models, handler);// 第一次初始化的时候运行这个收集函数initState = mapStatetoHooks(autoModels, novus);// 代替未指定的依赖列表_deps.current = Array.from(autoDeps);} else {initState = mapStatetoHooks(novus.models, novus);}let [state, setState] = useState<T>(initState);
事实证明,这个机制还是非常有效果的,不必显式的手动声明,全部自然的收集。同时并没有很明显的违背用户部分代码的预期,没有创建新的语法什么的,我们只是悄悄的在后面多做了一点工作。
自此,用户只需要关心需要什么状态,在函数里面获取到这些状态,我们能够自动的跟踪变更,响应式触发渲染了。
const [isTitleChanged, isContentChanged] = useNovus(models => {const { state } = models.noteModelreturn [state.isTitleChanged,state.isContentChanged,]})
最后这个是一个不是很影响使用,但是需要考虑到的问题。事实上 React 本身的渲染机制 Fiber,已经被很多人熟知了。数据层的订阅通知是在 Fiber 机制之前,计算量本不会很大,不太会影响渲染的耗时,感觉没有必要考虑这些事情。但是万一如果用户的计算函数涉及到的计算比较多,同时还有非常多的组件订阅的情况下,这里还是会有一点点的问题的。
Fiber 解决的是视图层更新占用执行栈的问题,react 通过递归的方式进行渲染组件树,整个执行流程会列队在执行栈中,直到执行完毕才算完。Fiber 把这个递归变更成了链式调用,执行的顺序不是必须到底,可以在执行时间超过 16ms 后把剩下的流程转到下一个宏任务中,这样让浏览器检查微任务和其他的宏任务,比如响应用户操作和更新页面视图。
我们这里的订阅更新是用的迭代,同样会出现占用执行栈的问题。考虑实现方案,一方面数组如果元素没有发生变动的话,可以保存迭代的序号,不需要改成链表也能断点重来,但是这个无法保证,另一方面把迭代改成链表,只是为了保留进度引用的话工作量就有点大了。
看下旧版本代码,触发订阅函数用的是循环,即使用到了命名空间的过滤,如果
listeners
这个列表中保存的订阅函数过多(因为是全局的数据层,组件很多的时候这个有很大可能)、计算量很大、全都是同步 CPU 密集型的计算,那么这里会出现排队现象,耗时如果超过 16ms,会让 JS 进程无法正常进行事件循环,比如检查微任务中的回调列表,无法响应用户的操作,所以这里确实应该会被视为卡顿点。/*** 触发器,更新完数据仓之后必须触发才能通知 UI 变更** @param namespace model 的命名空间 id,只会触发订阅对应 model 的 mapStatetoProps 函数。留空表示触发全部*/notifyChangesListeners = (namespace?: string) => {// TODO: 顺序执行全部的事件通知,如果这个比较多,可能直接阻塞了 CPU,无法响应用户操作,造成卡顿this.listeners.forEach(v => {if (typeof namespace === "undefined" ||v[1].length === 0 ||v[1].includes(namespace)) {v[0]()}})}
这里应该适当的时间把宏任务的队列切分为多个宏任务,让微任务和其他宏任务能够穿插执行,让界面获得 “喘息” 机会。
考虑到这边的调用方案也不是树形的形式,所以我这边采取了不一样的解决方案:
notifyChangesListeners = (namespace?: string) => {this.listeners.forEach((v, key) => {if (typeof namespace === "undefined" || v[1].length === 0 || v[1].includes(namespace)) {// 将需要通知的函数放进队列等待通知this.needNotifiedListeners.add(key);}});// 先把需要的工作记录,可以被打断,然后再通知各个订阅this.startNotifies();};// 一个待渲染工作池needNotifiedListeners = new Set<string>([]);startNotiTime: number;// 被触发就会一直按照后面的逻辑执行,直到渲染工作池清空startNotifies = () => {// 如果之前的遗留还在,先取消,在这次渲染中继续if(idleHandler) _cancelIdleCallback(idleHandler);// 执行之前先标记下时间,在递归的过程中作为时间标记this.startNotiTime = new Date().getTime();// 去执行判断逻辑this.doTheNotifies();};// 确实去通知订阅的部分,根据不同的执行可能在不同的宏任务队列被执行doTheNotifies = () => {if (this.needNotifiedListeners.size > 0) {const nextId = this.needNotifiedListeners.values().next().value;// 先删除索引,在后面执行的过程中如果这个订阅又被调用了,有重新执行的机会this.needNotifiedListeners.delete(nextId);// 实际执行的部分,可能的耗时操作this.listeners.get(nextId)[0]();// 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇// 如果运行完用户的计算之后还有时间,继续递归,否则等待下一次宏任务if (new Date().getTime() - this.startNotiTime < 16) {this.doTheNotifies();} else {idleHandler = _requestIdleCallback(this.startNotifies);}// 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆}};
改进的思路是,从之前迭代傻傻的直接去执行订阅,变成迭代放到待通知列表中,让执行栈去迭代执行,在每次执行结束后检查从开始到现在是不是超时了,没有的话递归运行。但是如果超时了,那么把下个循环再处理这个待执行列表,因为全局维护的是同一个列表,所以如果多次触发通知列表的生成,也不会造成重复通知的情况。
整个结果上看,就是通知订阅的时候,用户如果组件比较多,其中一些组件获得了通知,完成渲染,计算超时了,那么组件等待下次空闲再更新,把整个队列切片成很多阶段。
总的来说,改进方案虽然执行的代码没有减少,但是从整个调度的角度来看,各个任务穿插进行,体验会好很多,尤其对于细粒度的组件的大型应用,体验的优化一定是正向的。
测试是保证健壮性的基础,对于这个基础库来说,整个数据层的机制影响整个应用的运行,所以订阅机制、性能问题、组件渲染触发等都是需要保证能走得通,各个边界条件都需要测试可行,所以一个完善的测试非常有必要,虽然使用灵活性太大可能无法覆盖到全部的场景。
同时测试也是保证重构代码正确的基础,比如这次改进流程,就是先把测试流程走通,添加新功能和改进原有逻辑的时候,这些测试一直在帮我验证逻辑的正确性,并保证我全部的需求都能覆盖并正确实现。
工具用的是
Jest
+ @testing-library/react
很好的实现全部的目标需求,渲染预期也都符合,这样的结果能够让我能够比较自信的直接在团队项目中引入,相信这个数据层能够可靠的支撑起项目。感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/novus-2)