如何在非 React 项目中使用 Redux

  • 转载请注明出处,保留原文链接和作者信息。

目录

  • 1、前言
  • 2、单纯使用 Redux 的问题
    • 2.1、问题 1:代码冗余
    • 2.2、问题2:不必要的渲染
  • 3、React-redux 都干了什么
  • 4、构建自己项目中的 “Provider” 和 “connect”
    • 4.1、包装渲染函数
    • 4.2、避免没有必要的渲染
  • 5、总结
  • 6、练习

1、前言

最近在知乎上看到这么一个问题: 请教 redux 与 eventEmitter? - 知乎

最近一个小项目中(没有使用 react),因为事件、状态变化稍多,想用 redux 管理,可是并没有发现很方便。..

说起 Redux,我们一般都说 React。似乎 Redux 和 React 已经是天经地义理所当然地应该捆绑在一起。而实际上,Redux 官方给自己的定位却是:

Redux is a predictable state container for JavaScript apps.

Redux 绝口不提 React,它给自己的定义是 “给 JavaScript 应用程序提供可预测的状态容器”。也就是说,你可以在任何需要进行应用状态管理的 JavaScript 应用程序中使用 Redux。

但是一旦脱离了 React 的环境,Redux 似乎就脱缰了,用起来桀骜不驯,难以上手。本文就带你分析一下问题的原因,并且提供一种在非 React 项目中使用 Redux 的思路和方案。这不仅仅对在非 React 的项目中使用 Redux 很有帮助,而且对理解 React-redux 也大有裨益。

本文假设读者已经熟练掌握 React、Redux、React-redux 的使用以及 ES6 的基本语法。

2、单纯使用 Redux 的问题

我们用一个非常简单的例子来讲解一下在非 React 项目中使用 Redux 会遇到什么问题。假设页面上有三个部分,header、body、footer,分别由不同模块进行渲染和控制:

<div id='header'></div>
<div id='body'></div>
<div id='footer'></div>

这个三个部分的元素因为有可能会共享和发生数据变化,我们把它存放在 Redux 的 store 里面,简单地构建一个 store:

const appReducer = (state, action) => {
  switch (action.type) {
    case 'UPDATE_HEADER':
      return Object.assign(state, { header: action.header })
    case 'UPDATE_BODY':
      return Object.assign(state, { body: action.body })
    case 'UPDATE_FOOTER':
      return Object.assign(state, { footer: action.footer })
    default:
      return state
  }
}

const store = Redux.createStore(appReducer, {
  header: 'Header',
  body: 'Body',
  footer: 'Footer'
})

很简单,上面定义了一个 reducer,可以通过三个不同的 action:UPDATE_HEADERUPDATE_BODYUPDATE_FOOTER 来分别进行对页面数据进行修改。

有了 store 以后,页面其实还是空白的,因为没有把 store 里面的数据取出来渲染到页面。接下来构建三个渲染函数,这里使用了 jQuery:

/* 渲染 Header */
const renderHeader = () => {
  console.log('render header')
  $('#header').html(store.getState().header)
}
renderHeader()

/* 渲染 Body */
const renderBody = () => {
  console.log('render body')
  $('#body').html(store.getState().body)
}
renderBody()

/* 渲染 Footer */
const renderFooter = () => {
  console.log('render footer')
  $('#footer').html(store.getState().footer)
}
renderFooter()

现在页面就可以看到三个 div 元素里面的内容分别为:HeaderBodyFooter。我们打算 1s 以后通过 store.dispatch 更新页面的数据,模拟 app 数据发生了变化的情况:

/* 数据发生变化 */
setTimeout(() => {
  store.dispatch({ type: 'UPDATE_HEADER', header: 'New Header' })
  store.dispatch({ type: 'UPDATE_BODY', body: 'New Body' })
  store.dispatch({ type: 'UPDATE_FOOTER', footer: 'New Footer' })
}, 1000)

然而 1s 以后页面没有发生变化,这是为什么呢?那是因为数据变化的时候并没有重新渲染页面(调用 render 方法),所以需要通过 store.subscribe 订阅数据发生变化的事件,然后重新渲染不同的部分:

store.subscribe(renderHeder)
store.subscribe(renderBody)
store.subscribe(renderFooter)

好了,现在终于把 jQuery 和 Redux 结合起来了。成功了用 Redux 管理了这个简单例子里面可能会发生改变的状态。但这里有几个问题:

2.1、问题 1:代码冗余

编写完一个渲染的函数以后,需要手动进行第一次渲染初始化;然后手动通过 store.subscribe 监听 store 的数据变化,在数据变化的时候进行重新调用渲染函数。这都是重复的代码和没有必要的工作,而且还可能提供了忘了subscribe 的可能。

2.2、问题2:不必要的渲染

上面的例子中,程序进行一次初始化渲染,然后数据更新的渲染。3 个渲染函数里面都有一个 log。两次渲染最佳的情况应该只有 6 个 log。

但是你可以看到出现了 12 个log,那是因为后续修改 UPDATE_XXX ,除了会导致该数据进行渲染,还会导致其余两个数据重新渲染(即使它们其实并没有变化)。store.subscribe 一股脑的调用了全部监听函数,但其实数据没有变化就没有必要重新渲染。

以上的两个缺点在功能较为复杂的时候会越来越凸显。

3、React-redux 都干了什么

可以看到,单纯地使用 Redux 和 jQuery 目测没有给我们带来什么好处和便利。是不是就可以否了 Redux 在非 React 项目中的用处呢?

回头想一下,为什么 Redux 和 React 结合的时候并没有出现上面所提到的问题?你会发现,其实 React 和 Redux 并没有像上面这样如此暴力地结合在一起。在 React 和 Redux 这两个库中间其实隔着第三个库:React-redux。

在 React + Redux 项目当中,我们不需要自己手动进行 subscribe,也不需要手动进行过多的性能优化,恰恰就是因为这些脏活累活都由 React-redux 来做了,对外只提供了一个 Providerconnect 的方法,隐藏了关于 store 操作的很多细节。

所以,在把 Redux 和普通项目结合起来的时候,也可以参考 React-redux,构建一个工具库来隐藏细节、简化工作。

这就是接下来需要做的事情。但在构建这个简单的库之前,我们需要了解一下 React-redux 干了什么工作。 React-redux 给我们提供了什么功能?在 React-redux 项目中我们一般这样使用:

import { connect, Provider } from 'react-redux'

/* Header 组件 */
class Header extends Component {
  render () {
    return (<div>{this.props.header}</div>)
  }
}

const mapStateToProps = (state) => {
  return { header: state.header }
}
Header = connect(mapStateToProps)(Header)

/* App 组件 */
class App extends Component {
  render () {
    return (
      <Provider store={store}>
        <Header />
      </Provider>
    )
  }
}

我们把 store 传给了 Provider,然后其他组件就可以使用 connect 进行取数据的操作。connect 的时候传入了 mapStateToPropsmapStateToProps 作用很关键,它起到了提取数据的作用,可以把这个组件需要的数据按需从 store 中提取出来。

实际上,在 React-redux 的内部:Provider 接受 store 作为参数,并且通过 context 把 store 传给所有的子组件;子组件通过 connect 包裹了一层高阶组件,高阶组件会通过 context 结合 mapStateToPropsstore 然后把里面数据传给被包裹的组件。

如果你看不懂上面这段话,可以参考 动手实现 React-redux。说白了就是 connect 函数其实是在 Provider 的基础上构建的,没有 Provider 那么 connect 也没有效果。

React 的组件负责渲染工作,相当于我们例子当中的 render 函数。类似 React-redux 围绕组件,我们围绕着渲染函数,可以给它们提供不同于、但是功能类似的 Providerconnect

4、构建自己项目中的 Providerconnect

4.1、包装渲染函数

参考 React-redux,下面假想出一种类似的 providerconnect 可以应用在上面的 jQuery 例子当中:

/* 通过 provider 生成这个 store 对应的 connect 函数 */
const connect = provider(store)

/* 普通的 render 方法 */
let renderHeader = (props) => {
  console.log('render header')
  $('#header').html(props.header)
}

/* 用 connect 取数据传给 render 方法 */
const mapStateToProps = (state) => {
  return { header: state.header }
}
renderHeader = connect(mapStateToProps)(renderHeader)

你会看到,其实我们就是把组件换成了 render 方法而已。用起来和 React-redux 一样。那么如何构建 providerconnect 方法呢?这里先搭个骨架:

const provider = (store) => {
  return (mapStateToProps) => { // connect 函数
    return (render) => {
      /* TODO */
    }
  }
}

provider 接受 store 作为参数,返回一个 connect 函数;connect 函数接受 mapStateToProps 作为参数返回一个新的函数;这个返回的函数类似于 React-redux 那样接受一个组件(渲染函数)作为参数,它的内容就是要接下来要实现的代码。当然也可以用多个箭头的表示方法:

const provider = (store) => (mapStateToProps) => (render) => {
  /* TODO */
}

storemapStateToPropsrender 都有了,剩下就是把 store 里面的数据取出来传给 mapStateToProps 来获得 props;然后再把 props 传给 render 函数。

const provider = (store) => (mapStateToProps) => (render) => {
  /* 返回新的渲染函数,就像 React-redux 的 connect 返回新组件 */
  const renderWrapper = () => {
    const props = mapStateToProps(store.getState())
    render(props)
  }
  return renderWrapper
}

这时候通过本节一开始假想的代码已经可以正常渲染了,同样的方式改写其他部分的代码:

/* body */
let renderBody = (props) => {
  console.log('render body')
  $('#body').html(props.body)
}
mapStateToProps = (state) => {
  return { body: state.body }
}
renderBody = connect(mapStateToProps)(renderBody)

/* footer */
let renderFooter = (props) => {
  console.log('render footer')
  $('#footer').html(props.footer)
}
mapStateToProps = (state) => {
  return { footer: state.footer }
}
renderFooter = connect(mapStateToProps)(renderFooter)

虽然页面已经可以渲染了。但是这时候调用 store.dispatch 是不会导致重新渲染的,我们可以顺带在 connect 里面进行 subscribe:

const provider = (store) => (mapStateToProps) => (render) => {
  /* 返回新的渲染函数,就像 React-redux 返回新组件 */
  const renderWrapper = () => {
    const props = mapStateToProps(store.getState())
    render(props)
  }
  /* 监听数据变化重新渲染 */
  store.subscribe(renderWrapper)
  return renderWrapper
}

赞。现在 store.dispatch 可以导致页面重新渲染了,已经原来的功能一样了。但是,看看控制台还是打印了 12 个 log,还是没有解决无关数据变化导致的重新渲染问题。

4.2、避免没有必要的渲染

在上面的代码中,每次 store.dispatch 都会导致 renderWrapper 函数执行, 它会把 store.getState() 传给 mapStateToProps 来计算新的 props 然后传给 render

实际上可以在这里做手脚:缓存上次的计算的 props,然后用新的 props 和旧的 props 进行对比,如果两者相同,就不调用 render

const provider = (store) => (mapStateToProps) => (render) => {
  /* 缓存 props */
  let props
  const renderWrapper = () => {
    const newProps = mapStateToProps(store.getState())
    /* 如果新的结果和原来的一样,就不要重新渲染了 */
    if (shallowEqual(props, newProps)) return
    props = newProps
    render(props)
  }
  /* 监听数据变化重新渲染 */
  store.subscribe(renderWrapper)
  return renderWrapper
}

这里的关键点在于 shallowEqual。因为 mapStateToProps 每次都会返回不一样的对象,所以并不能直接用 === 来判断数据是否发生了变化。这里可以判断两个对象的第一层的数据是否全相同,如果相同的话就不需要重新渲染了。例如:

const a = { name: 'jerry' }
const b = { name: 'jerry' }

a === b // false
shallowEqual(a, b) // true

这时候看看控制台,只有 6 个 log 了。成功地达到了性能优化的目的。这里 shallowEqual 的实现留给读者自己做练习。

到这里,已经完成了类似于 React-redux 的一个 Binding,可以愉快地使用在非 React 项目当中使用了。完整的代码可以看这个 gist

5、总结

通过本文可以知道,在非 React 项目结合 Redux 不能简单粗暴地将两个使用起来。要根据项目需要构建这个场景下需要的工具库来简化关于 store 的操作,当然可以直接参照 React-redux 的实现来进行对应的绑定。

也可以总结出,其实 React-redux 的 connect 帮助我们隐藏了很多关于store 的操作,包括 store 的数据变化的监听重新渲染、数据对比和性能优化等。

6、练习

对本文所讲内容有兴趣的朋友可以做一下本文配套的练习:

  1. 实现一个 shallowEqual
  2. 给 provider 加入 mapDispatchToProps
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容