阅读 147

React时光机-一种基于React/Redux技术栈的解决状态恢复(bootstrapping/state hydration)的工程化解决方案

背景

对于前端开发人员,一个具有复杂交互的页面,刷新页面然后调试代码往往意味着繁杂的工作量。因为待开发调试部分可能处于交互较深位置,一旦刷新页面,状态就会丢失,需要重复进行一系列交互,才能回到页面刷新前的待开发调试状态。

笔者负责的工作台业务就具有交互复杂的特点。如图是一个商品报价页面,需要分三步进行,待调试位置在第三步,需要完成前两步,才能到达第三步。如果刷新页面就需要重做。本文就致力于探索解决刷新页面状态丢失的问题。以下,将基于 React / Redux 技术栈,探索解决状态丢失的技术方案。

image.png

技术实现

React state 持久化及恢复 (persist & hydrate)

Persist & Hydrate 时机:

React state 的获取以及改变可以并且仅可以在 React 的生命周期内完成。那么,persist 以及 hydrate 必定是在某个(些)生命周期内完成。问题关键就在于确定生命周期。如图,是 React v16.4 的生命周期图解。

image.png


React v16.4 生命周期图解
[1]

Persist:

我们要获取的 state 是刷新页面前的最后状态,那么就应该是 Did, 所以应该是 componentDidMount 或者 componentDidUpdate。但是在 componentDidMount 里面做是没有意义的,因为我们即使不做 hydrate,刷新页面后,仍然是恢复 componentDidMount 中的 state。所以,Persist 只能在 componentDidUpdate 中做。这里选取的技术方案是存储到浏览器的 WebStorage中。

componentDidUpdate() {
    ...
    sessionStorage.setItem(id, JSON.stringify(this.state))
    ...
}
复制代码
Copy

Hydrate:

如果要改变 state 只能通过 setState() 的方式,我们要从浏览器的 WebStorage 中恢复之前存储的 state,应该在 componentDidMount 中进行。

componentDidMount() {
    ...
    this.setState(JSON.parse(sessionStorage.getItem(id)))
    ...
}
复制代码
Copy

到现在,似乎我们解决问题的主要方法已经得到了,但是仍然存在一些很紧要而不能忽视的问题。

  1. 问:现在的解决方案,解决了单组件的状态 persist & hydrate,但是对于页面中多组件,兄弟、父子关系,在代码运行时,如何确定组件 state 保存的 key?

    答:由于每次刷新页面都是可类比的(当然也不能排除不可类比的情况),因此即使存在着很复杂的组件兄弟、父子关系,组件挂载(mount) 的时序也都是相同的。那么就可以通过时序建立不同组件与 state 的映射关系。这里最简单的方法就是添加一个全局的累加器,在每个组件初始化的时候累加。

    constructor(props, context) {
      super(props, context)
      ...
      ++window.id
      ...
    }
    复制代码
    Copy
  2. 问:现在的解决方案和代码有着很强的耦合,如何解耦、实现逻辑复用?

    答:可以用 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
      }
    }
  }
}
复制代码
Copy

至此,已经介绍完毕 “React 时光机” 对于 React 以及 Redux 状态的持久化及恢复 (persist & hydrate) 技术方案。完整代码参见:npm.alibaba-inc.com/package/@al….

工程化方案

笔者所在零售通部门,工作台前端使用 JUST[5] 体系。以下内容适用于 JUST 体系的 saga[6] 项目。

目录结构

image.png

如图是一个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.scss
    复制代码
    Copy
  • .build/@cbu/nice-product/.timemachine/

    由于上面我们改变了构建入口,因此生成了.build/@cbu/nice-product/.timemachine/,而非.build/@cbu/nice-product/app/

动态过程

上面描述的是工程化的中间产物,这里描述用户 构建项目 -> 修改代码 -> 自动构建 这整个动态过程中的技术细节。下图描述了从使用扩展过的 just watch --timeMachine 命令开始,中间使用 node 借助于上面提到的中间产物,得到我们想要的最终产物 .build/**/app/

image.png

优化点

package.json 中 armor entry 使用通配符指定构建该应用中的所有页面,初始构建以及增量构建时间会较长,后面当用户修改源代码,会自动指定只构建修改页面,从而节省 watch 时增量构建的时间。

接入与使用

  1. 安装@alife/react-time-machine 依赖到项目:

    tnpm install --save @alife/react-time-machine
    复制代码
    Copy
  2. 生成@alife/react-time-machine 快照:

    just snapshot @alife/react-time-machine --only
    复制代码
    Copy
  3. 更新最新版本just-plugin-nice(大于等于1.18.4)

  4. 需要在.gitignore 文件中添加如下内容:

    .timemachine
    .entry
    复制代码
    Copy
  5. 对项目开启时光机模式:

    just watch --timeMachine
    复制代码
    Copy
  6. 访问待开发调试的页面,URL后面加参数“rtm=XXX”(可以切换XXX, 保存切换多个状态快照)

结语

“React时光机” 的实现依赖于开发者的工程,使用场景有限。后续会借助相关能力有更多场景的实践,例如页面草稿的实现,线上问题反馈等。同时也会尝试不同的技术路线解决状态恢复的问题,适用于测试等场景。

参考

[1] github.com/wojtekmaj/r…

[2] github.com/jaredpalmer…

[3] reactjs.org/docs/higher…

[4] github.com/zalmoxisus/…