背景
对于前端开发人员,一个具有复杂交互的页面,刷新页面然后调试代码往往意味着繁杂的工作量。因为待开发调试部分可能处于交互较深位置,一旦刷新页面,状态就会丢失,需要重复进行一系列交互,才能回到页面刷新前的待开发调试状态。
笔者负责的工作台业务就具有交互复杂的特点。如图是一个商品报价页面,需要分三步进行,待调试位置在第三步,需要完成前两步,才能到达第三步。如果刷新页面就需要重做。本文就致力于探索解决刷新页面状态丢失的问题。以下,将基于 React / Redux 技术栈,探索解决状态丢失的技术方案。
技术实现
React state 持久化及恢复 (persist & hydrate)
Persist & Hydrate 时机:
React state 的获取以及改变可以并且仅可以在 React 的生命周期内完成。那么,persist 以及 hydrate 必定是在某个(些)生命周期内完成。问题关键就在于确定生命周期。如图,是 React v16.4 的生命周期图解。
Persist:
我们要获取的 state 是刷新页面前的最后状态,那么就应该是 Did, 所以应该是 componentDidMount 或者 componentDidUpdate。但是在 componentDidMount 里面做是没有意义的,因为我们即使不做 hydrate,刷新页面后,仍然是恢复 componentDidMount 中的 state。所以,Persist 只能在 componentDidUpdate 中做。这里选取的技术方案是存储到浏览器的 WebStorage中。
componentDidUpdate() {
...
sessionStorage.setItem(id, JSON.stringify(this.state))
...
}
Hydrate:
如果要改变 state 只能通过 setState() 的方式,我们要从浏览器的 WebStorage 中恢复之前存储的 state,应该在 componentDidMount 中进行。
componentDidMount() {
...
this.setState(JSON.parse(sessionStorage.getItem(id)))
...
}
到现在,似乎我们解决问题的主要方法已经得到了,但是仍然存在一些很紧要而不能忽视的问题。
问:现在的解决方案,解决了单组件的状态 persist & hydrate,但是对于页面中多组件,兄弟、父子关系,在代码运行时,如何确定组件 state 保存的 key?
答:由于每次刷新页面都是可类比的(当然也不能排除不可类比的情况),因此即使存在着很复杂的组件兄弟、父子关系,组件挂载(mount) 的时序也都是相同的。那么就可以通过时序建立不同组件与 state 的映射关系。这里最简单的方法就是添加一个全局的累加器,在每个组件初始化的时候累加。
constructor(props, context) { super(props, context) ... ++window.id ... }Copy问:现在的解决方案和代码有着很强的耦合,如何解耦、实现逻辑复用?
答:可以用 HOC [3]解决。只要把我们的组件作为一个参数传递给一个方法,返回一个新的具有 Persist & Hydrate 能力的新组件即可。如下:
const Hoc = WrappedComponent => class extends WrappedComponent { constructor(props, context) { super(props, context) ... ++window.id ... } componentDidMount() { ... this.setState(JSON.parse(sessionStorage.getItem(id))) ... } componentDidUpdate() { ... sessionStorage.setItem(id, JSON.stringify(this.state)) ... } render() { return super.render() } }Copy
关于 React state 的 Persist & Hydrate 解决方案, 要感谢 @Jared Palmr[2] 的启发
Redux state 的持久化及恢复 (persist & hydrate)
Redux state 的 persist & hydrate 相较于 React 实现容易一些。首先,Redux 本身就是一款优秀的状态管理库,具有很多优势,比如它是单一数据源,不存在我们之前提到的 React state 分布在不同的组件中;其次,Redux 开放 middleware,enhancer API,可以很方便的对 Redux 功能进行扩展。这里借鉴了 Redux DevTools[4] 这款插件的 enhancer 方法实现了对 Redux state 的 persist & hydrate。 原理是增强 store,扩展 dispatch 方法,当有新的 dispatch 产生时就把 store 中唯一的 state 存储到 WebStorage; createStore 的时候再把之前存储在 WebStorage 中的 state 取出作为 initialState。enhancer 方法代码片段如下:
export default function rtmPersistState(session) {
const sessionInfo = window.location.href.match(new RegExp(`[?&]${session}=([^&#]+)`))
const sessionId = sessionInfo && sessionInfo[1]
if (!sessionId) {
return next => (...args) => next(...args)
}
function deserialize(state) {
return {
...state
}
}
return next => (reducer, initialState, enhancer) => {
const key = `__react_time_machine_redux_session-${sessionId}`
let finalInitialState
try {
const json = sessionStorage.getItem(key)
if (json) {
finalInitialState = deserialize(JSON.parse(json)) || initialState
next(reducer, initialState)
}
} catch (e) {
console.warn('Could not read debug session from sessionStorage:', e)
try {
sessionStorage.removeItem(key)
} finally {
finalInitialState = undefined
}
}
const store = next(reducer, finalInitialState, enhancer)
return {
...store,
dispatch(action) {
store.dispatch(action)
try {
sessionStorage.setItem(key, JSON.stringify(store.getState()))
} catch (e) {
console.warn('Could not write debug session to sessionStorage:', e)
}
return action
}
}
}
}
至此,已经介绍完毕 “React 时光机” 对于 React 以及 Redux 状态的持久化及恢复 (persist & hydrate) 技术方案。完整代码参见:npm.alibaba-inc.com/package/@al….
工程化方案
笔者所在零售通部门,工作台前端使用 JUST[5] 体系。以下内容适用于 JUST 体系的 saga[6] 项目。
目录结构
如图是一个saga应用的目录结构,高亮部分为工程的生成的中间目录。分别为
.timemachine/
前文所述的 React HOC 以及 Redux enhancer 都需要对代码进行侵入。为避免代码侵入,同时希望能够使其工具化,面向用户屏蔽这些技术细节。这里将 app/ 全量复制到新建的文件目录 .timemachine/ 下,再进行 React HOC 以及 Redux enhancer 的改造。
.entry
saga 项目使用 armor[7] 做构建工具,saga 給 armor 增加了临时构建入口 .entry/.entry.json, 在.entry中放置.timemachine/下的入口:
.timemachine/page/**/index.jsx .timemachine/page/**/index.scssCopy.build/@cbu/nice-product/.timemachine/
由于上面我们改变了构建入口,因此生成了.build/@cbu/nice-product/.timemachine/,而非.build/@cbu/nice-product/app/
动态过程
上面描述的是工程化的中间产物,这里描述用户 构建项目 -> 修改代码 -> 自动构建 这整个动态过程中的技术细节。下图描述了从使用扩展过的 just watch --timeMachine 命令开始,中间使用 node 借助于上面提到的中间产物,得到我们想要的最终产物 .build/**/app/
优化点
package.json 中 armor entry 使用通配符指定构建该应用中的所有页面,初始构建以及增量构建时间会较长,后面当用户修改源代码,会自动指定只构建修改页面,从而节省 watch 时增量构建的时间。
接入与使用
安装@alife/react-time-machine 依赖到项目:
tnpm install --save @alife/react-time-machineCopy生成@alife/react-time-machine 快照:
just snapshot @alife/react-time-machine --onlyCopy更新最新版本just-plugin-nice(大于等于1.18.4)
需要在.gitignore 文件中添加如下内容:
.timemachine .entryCopy对项目开启时光机模式:
just watch --timeMachineCopy访问待开发调试的页面,URL后面加参数“rtm=XXX”(可以切换XXX, 保存切换多个状态快照)
结语
“React时光机” 的实现依赖于开发者的工程,使用场景有限。后续会借助相关能力有更多场景的实践,例如页面草稿的实现,线上问题反馈等。同时也会尝试不同的技术路线解决状态恢复的问题,适用于测试等场景。



