Decorative image frame
ocean

梦翼坊

给梦想一双翅膀

梦翼坊

React Hooks 时代的状态管理库的选择

引言

React 的数据流是自上而下的,从组件外到组件内,从父组件到子组件,且传递下来的 props 是只读的,如果你想更改 props,只能父组件传入一个封装好的 setState 方法。虽然你可以通过一些方案来解决 React 组件间的通信问题,但随着项目业务的增长,组件通信的成本会越来越高!这时候你可能希望有一处专门负责数据状态管理的地方,而这就是我们今天要提到的数据状态管理库的概念。

在 React 项目中常用的数据状态管理主要有 Redux 和 Mobx。而在早期,React 引入 Redux 需要使用大量的“胶水代码”,且遵循 setState 原则。而 Mobx 主张干掉 setState 的机制,它简化了使用成本,但确增加了“依赖收集”的新概念。这两个状态管理库各有优劣,多年相争不下。

React Hooks 时代

在进入到 React Hooks 时代,Mobx 率先推出了 Mobx-react-lite,让隔壁的 Redux,完全没来得及反应。随着 React Hooks 日渐增长,Redux 也在后来推出了 React Redux Hooks大航海时代,由此开启! React Hooks 时代的状态管理之争,由此进入白热化状态。

我将使用 React Hooks 的 useContext 和 useReducer 实现简易的 TodoList,并使用 React Redux Hooks 和 Mobx-react-lite 实现相同的功能。

React Hooks 原生实现

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// reducer.js
export const initState = {
todoList: {},
}

export const reducer = (state, action) => {
const { payload } = action
switch (action.type) {
case 'ADD_TODOLIST': {
state.todoList[payload.todo] = false
return {
todoList: { ...state.todoList },
}
}
case 'TOGGLE_TODOLIST': {
state.todoList[payload.todo] = !state.todoList[payload.todo]
return {
todoList: { ...state.todoList },
}
}
default:
return state
}
}

export default {
reducer,
initState,
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// context.jsx
import React, { useReducer } from 'react'
import { reducer, initState } from './reducer'

const StoreContext = React.createContext(null)

export const useStore = () => {
const store = React.useContext(StoreContext)
if (!store) {
// this is especially useful in TypeScript so you don't need to be checking for null all the time
throw new Error('You have forgot to use StoreProvider, shame on you.')
}
return store
}

export function Provider({ children }) {
const [state, dispatch] = useReducer(reducer, initState)

return (
<StoreContext.Provider value={{ state, dispatch }}>
{children}
</StoreContext.Provider>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// main.jsx
import React, { useState, useMemo } from 'react'
import { useStore } from './context'

function Main() {
const { state, dispatch } = useStore()
const [todoText, setTodoText] = useState('')

const paddingTodos = useMemo(() => {
return Object.keys(state.todoList).filter(
todo => state.todoList[todo] === false
)
}, [state.todoList])
const doneTodos = useMemo(() => {
return Object.keys(state.todoList).filter(
todo => state.todoList[todo] === true
)
}, [state.todoList])

const addTodoList = () => {
dispatch({
type: 'ADD_TODOLIST',
payload: { todo: todoText },
})
setTodoText('')
}
const toggleTodoList = (todo) => {
dispatch({
type: 'TOGGLE_TODOLIST',
payload: { todo },
})
}

return (
<div>
<input value={todoText} onChange={ev => setTodoText(ev.target.value)} />
<button type="button" onClick={() => addTodoList(todoText)}>增加待办事项</button>
<ul>
{paddingTodos.map(todo => {
return <li key={todo} onClick={() => toggleTodoList(todo)}>{todo}</li>
})}
{doneTodos.map(todo => {
return <li key={todo} style={{ textDecoration: 'line-through' }} onClick={() => toggleTodoList(todo)}>{todo}</li>
})}
</ul>
</div>
)
}

export default Main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// index.jsx
import React from 'react'
import { Provider } from './context'
import Main from './main'

function TodoList () {
return (
<Provider>
<Main />
</Provider>
)
}

export default TodoList

优势

原生的 React Hooks 可以实现简单的数据状态管理,你需要引入额外的依赖。这在小型的项目中非常有优势。

劣势

虽然借助原生 React Hooks 可以实现简易的数据状态管理,但官方确只是把这种实现当成一种新的组件间通信的解决方案。主要原因在于数据在不同页面间如果需要同步的话,你需要在不同的页面里引入相同的 reducer.js,当你使用 React Router 之后,你会发现页面切换很容易造成数据的丢失,因此你不得不将 Context 移到组件的最上层,才能解决这类组件注销造成数据丢失的问题。


React Redux Hooks 实现

安装依赖

1
2
3
npm install redux react-redux
// or
yarn add redux react-redux

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// reducer.js
const initState = {
todoList: {},
}

const reducer = (state = initState, action) => {
const { payload } = action
switch (action.type) {
case 'ADD_TODOLIST': {
state.todoList[payload.todo] = false
return {
todoList: { ...state.todoList },
}
}
case 'TOGGLE_TODOLIST': {
state.todoList[payload.todo] = !state.todoList[payload.todo]
return {
todoList: { ...state.todoList },
}
}
default:
return state
}
}

export default reducer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// main.jsx
import React, { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'

function Main () {
const [todoText, setTodoText] = useState('')
const paddingTodos = useSelector(state => {
return Object.keys(state.todoList).filter(
todo => state.todoList[todo] === false
)
})
const doneTodos = useSelector(state => {
return Object.keys(state.todoList).filter(
todo => state.todoList[todo] === true
)
})

const dispatch = useDispatch()
const addTodoList = () => {
dispatch({
type: 'ADD_TODOLIST',
payload: { todo: todoText },
})
setTodoText('')
}
const toggleTodoList = (todo) => {
dispatch({
type: 'TOGGLE_TODOLIST',
payload: { todo },
})
}

return (
<div>
<input value={todoText} onChange={ev => setTodoText(ev.target.value)} />
<button type="button" onClick={() => addTodoList(todoText)}>增加待办事项</button>
<ul>
{paddingTodos.map(todo => {
return <li key={todo} onClick={() => toggleTodoList(todo)}>{todo}</li>
})}
{doneTodos.map(todo => {
return <li key={todo} style={{ textDecoration: 'line-through' }} onClick={() => toggleTodoList(todo)}>{todo}</li>
})}
</ul>
</div>
);
}

export default Main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// index.jsx
import React from 'react'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import Main from './main'
import reducer from './reducer'

const store = createStore(reducer)

function TodoList () {
return (
<Provider store={store}>
<Main />
</Provider>
)
}

export default TodoList

优势

引入 React Redux Hooks 之后,你会发现页面代码变得更加简洁了。相对于之前的 Redux 实现,React Redux Hooks 更接近于 React Hooks 的原生实现。

劣势

React Redux Hooks 虽然精简了大部分的代码,但依然采用 React Hooks reducer 的实现,你在修改数据状态时需要注意返回全新的 state,不然数据状态可能会不变。


Mobx-react-lite 实现

安装依赖

1
2
3
npm install mobx mobx-react-lite
// or
yarn add mobx mobx-react-lite

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// store.js
import { observable } from 'mobx'

const store = observable({})

export function createStore() {
return {
todoList: store,
get pendingTodos() {
return Object.keys(this.todoList).filter(
todo => this.todoList[todo] === false,
)
},
get doneTodos() {
return Object.keys(this.todoList).filter(
todo => this.todoList[todo] === true,
)
},
ADD_TODOLIST(todo) {
this.todoList[todo] = false
},
TOGGLE_TODOLIST(todo) {
this.todoList[todo] = !this.todoList[todo]
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// context.jsx
import React from 'react'
import { useLocalStore } from 'mobx-react-lite'
import { createStore } from './store'

const StoreContext = React.createContext(null)

export const useStore = () => {
const store = React.useContext(StoreContext)
if (!store) {
// this is especially useful in TypeScript so you don't need to be checking for null all the time
throw new Error('You have forgot to use StoreProvider, shame on you.')
}
return store
}

export function Provider({ children }) {
const store = useLocalStore(createStore)

return (
<StoreContext.Provider value={store}>
{children}
</StoreContext.Provider>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// main.jsx
import React, { useState } from 'react'
import { observer } from 'mobx-react-lite'
import { useStore } from './context'

const Main = observer(() => {
const store = useStore()
const [todoText, setTodoText] = useState('')

const addTodo = (todo) => {
store.ADD_TODOLIST(todo)
setTodoText('')
}
const toggleTodo = (todo) => {
store.TOGGLE_TODOLIST(todo)
}

return (
<div>
<input value={todoText} onChange={ev => setTodoText(ev.target.value)} />
<button type="button" onClick={() => addTodo(todoText)}>增加待办事项</button>
<ul>
{store.pendingTodos.map(todo => {
return <li key={todo} onClick={() => toggleTodo(todo)}>{todo}</li>
})}
{store.doneTodos.map(todo => {
return <li key={todo} style={{ textDecoration: 'line-through' }} onClick={() => toggleTodo(todo)}>{todo}</li>
})}
</ul>
</div>
)
})

export default Main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// index.jsx
import React from 'react'
import { Provider } from './context'
import Main from './main'

function TodoList () {
return (
<Provider>
<Main />
</Provider>
)
}

export default TodoList

优势

Mobx 中 observer 的实现非常优雅,你可以观察一个对象,然后在需要使用使用到 store 的组件中监听变化(observer)就可以了。这样的写法和 Vue 比较接近。

劣势

Mobx 的实现与 React 的数据不可变思想有些出入,以至于部分只用过 React 开发项目的人无法理解 Mobx 的实现机制。


总结

不管是 React Hooks 原生实现,还是借助 Redux 或 Mobx 来实现数据状态管理都是完全可行的。Redux 和 Mobx 的数据状态管理库一哥位置的争夺仍会持续很长一段时间,React Hooks 也可能会继续推出更强大的官方实现方案。但你需要根据实际的业务需求以及你个人对这类框架实现机制的理解来选择最合适的实现。

参考资料

Vue 组件库构建流程

1
2
3
4
5
6
Leader:我们最近好几个项目都有用到相似的业务逻辑,比如,手机验证码注册功能最好能有一个组件库来支持,这样就不需要要多次编写相似的代码了。
Me:我..
Leader:做一个 vue 的组件库应该没什么问题吧。
Me:额..
Leader:我记得你 OKR 里有写到这个。
Me:没问题!

以上内容纯属虚构

查找组件库

既然要开始构建一个 vue 的组件库,特别是针对移动端 App 的组件库,首先需要的做到就是去了解是否有成熟的组件可以直接用。虽然搜出来的 vue 的组件库并不少,但像 iView、element UI 和 Ant Design Vue 这类组件库都是 focus 在后台开发领域的,并不适用于移动端,而移动端目前比较完善的只有 Vant 这个有赞开发的 vue 组件库了。有赞,打钱!

Vant 这个库组件种类还是很丰富的,但..我接到的需求却是开发一个功能型的组件库,主要是实现一些公司内部常用业务的组件开发。做程序员实在是太南了。

既然没办法直接拿 vant 交差解决问题,那是不是可以借鉴 Vant 的方案来做一个 vue 组件库呢?

打包脚本

当看到这么多打包脚本就直接把我劝退了。既然Fork借鉴无望,那就只能撸起袖子自己干了。

如何开始构建工作?

如果之前没有相关经验,当然是 Google 一下咯!

不错有人已经实践过了,那就不需要自己再造轮子~~开发了,那就点开前两个链接看一下别人是怎么去实践的。掘金,打钱!

第一个链接中的文章写得很细致,是一个完整的从0到1的构建流程,不过,我并不打算完全采用该方案。主要理由是…该文章发布于 2018年 5月 17日,啊,一年半了呀,vue-cli 都从 2 进化到 3 了!文章的前半部分目前依然适用,但打包发布部分已经过时。文章采用的打包发布方案用的依然是裸写 webpack 配置的方案,该方案虽然可行,但过于繁琐,而且需要开发者了解 webpack 的配置写法,其次 vue-cli 3 默认隐藏了 webpack 的相关配置,官方都不建议用户直接接触 webpack 配置了,我们为什么还要去简就繁呢?用好webpack太南了其实 vue-cli 3 已经有官方的方案了…

那我们点开第二个链接再来看一下另一个方案。图文并茂,而且是非常完整的从构建到发布的整一个流程!而且文章里采用的打包方案是基于 vue-cli 3 的,更重要的是文章告诉我一个信息:vue-cli 3 已经自带了组件的打包脚本。

1
2
3
4
"scripts": {
// ...
"lib": "vue-cli-service build --target lib --name vcolorpicker --dest lib packages/index.js"
}

我根据文章的开发步骤,成功生成了一个组件,并通过编写多个 packages 可以生成一个组件库。
等等..好像有些不对劲?

为什么我编写了多个 packages 打包出来的依然是一个文件…虽然打包成单文件还是多文件对于组件库来说影响不大,但对使用者而言,如果为了使用组件库里一个组件,而需要引入一整个组件库…这还玩个球呀

如何实现多文件打包?

既然遇到了问题,那就想办法去解决一下吧!既然 vue-cli 3 自带了打包脚本,那就去官方文档上看一下,没准就有打包成多文件的方案呢。

Vue Cli 指南

很快我就找到了构建目标章节。里面有构建库的方案,也有构建应用和 Web Components 的方案。其中,构建库的方案看来之前那位作者是完全信仰了尤大大呀跟上文提到的完全一致。

官网构建方案

通过 --target lib 默认生成 umd、umd.min 和 common 格式的文件以及 css。通过 Web Components 可以生成单个或多个 Web Components 组件…哎?我怎么是多个 Web Components 组件的打包方案,我要到多文件 lib 方案呢?我又看了一遍文档,依然没有发现我希望的多文件组件库方案,难道是尤大大忘了写进文档了?感觉又走进了死胡同了…

不行?咱就再 Google 一下吧!

第一篇文章给你科普的是“什么叫按需加载”,完全不是我想要的内容。
第二篇文章虽然有提到组件库的打包,但实际上是教你如何使用 babel-plugin-component 实现组件库的按需加载。

那我换一个关键词 Google 一下试试?”vue组件库 构建 按需加载”

vue-cli 3,构建lib模式的时候,怎么才能做到把包按组件进行 …

搜索出来的文章大多跟我想要解决的问题无关,倒是其中一个 github/vue-cli 的 issue 引起了我的注意。原来也有人再苦恼我遇到的问题,而且还给官方提了 issue,心喜,难道我的问题有解了?可事实总是残酷的,这个 issue 里满满的负能量…总之,三个字“不支持”。

兜兜转转,还是回到了原地。既然 vue-cli 3 支持打包单文件组件,那我是不是可以魔改修改 vue-cli 的打包脚本实现一个打包多文件的方案呢?

那我就从 vue-cli 的源码里开始找吧…

经过漫长的查找定位…终于找到了我需要的打包 lib 的源码

1
2
// https://github.com/vuejs/vue-cli/blob/master/packages/@vue/cli-service/lib/commands/build/index.js
vue-cli/packages/@vue/cli-service/lib/commands/build/index.js

鸡汁如我,藏得这么深都能被我找到。

不看源码还不知道,原来 vue-cli-service 有着这么多的可选参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
options: {
'--mode': `specify env mode (default: production)`,
'--dest': `specify output directory (default: ${options.outputDir})`,
'--modern': `build app targeting modern browsers with auto fallback`,
'--no-unsafe-inline': `build app without introducing inline scripts`,
'--target': `app | lib | wc | wc-async (default: ${defaults.target})`,
'--inline-vue': 'include the Vue module in the final bundle of library or web component target',
'--formats': `list of output formats for library builds (default: ${defaults.formats})`,
'--name': `name for lib or web-component mode (default: "name" in package.json or entry filename)`,
'--filename': `file name for output, only usable for 'lib' target (default: value of --name)`,
'--no-clean': `do not remove the dist directory before building the project`,
'--report': `generate report.html to help analyze bundle content`,
'--report-json': 'generate report.json to help analyze bundle content',
'--skip-plugins': `comma-separated list of plugin names to skip for this run`,
'--watch': `watch for changes`
}

可…似乎并没有设置多文件的打包方案。原以为“柳暗花明”,结果依然“山穷水尽”。都走了这么远的路了,怎么可以就此放弃!

从头开始思考

既然没办法走阳关大道,那只要有路还是可以走一走的。

通过之前的思考,目前我可以掌握以下三个关键信息:

  • vue-cli 3 支持打包组件
  • 可以通过 npm 依赖实现组件的按需加载(使用时)
  • 可以通过自己编写脚本实现能够支持按需加载的组件库

通过以上信息,可以证明实现支持按需加载的组件库还是有可能的。啊,呸,我早就知道了,可惜臣妾还是做不到呀!

那么我们不妨来综合一下以上三点:通过脚本多次执行 vue-cli 的打包命令,生成可以直接按需加载的组件库结构。

看来有戏!

vue-cli 的脚本是通过命令行执行的,那么可以借助 shelljs 可以实现。但生成的 umd、umd.min 以及 demo.html 文件都不是我们需要的,当然我们可以借助 shelljs 在构建后将他们删除。那有没有更好的方式让他们一开始就不生成呢?

我们回过头去 vue-cli-service 的脚本配置,其中的 --formats 选项虽然在官方文档里没有提及,但实际上是可用的,通过设置 --formats commonjs 可以很容易的实现在构建时只生成 index.common.jsindex.css 这两个文件。

那有没有办法再把 index.common.js 变成 index.js 呢?关是猜,当时是没有用的,既然文档里都没提及 --formats,自然也不会有相应的使用说明,我们还得去看 vue-cli 的源码!

1
2
// https://github.com/vuejs/vue-cli/blob/master/packages/@vue/cli-service/lib/commands/build/resolveLibConfig.js
vue-cli/packages/@vue/cli-service/lib/commands/build/resolveLibConfig.js

通过 vue-cli-service 的依赖引用,我很快定位到了这个文件,但也很快确定了一点,没有参数可以移除 common 后缀,因为 common 文件的导出是固定带 common 后缀的!

既然此路不同..那我就绕道使用 shelljs 在构建后移除对应的 common 后缀吧。为什么不早这么做呢,因为我比较懒相信尤大大啊!

默认导出的目录结构显然不能满足按需加载插件的需求,所以很多成熟的组件库都会自己写脚本去生成对应目录结构。但我既然已经上了 vue-cli 的车了,总不该半路“跳车”吧。我真的懒得写那么复杂的构建脚本…解决方案便是再次借助万能的 shelljs 来实现~

打包脚本代码

完整的打包代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const shell = require('shelljs')
const version = require('../package.json').version

let scripts = []

shell.ls('packages').forEach(file => {
/**
* 由于 vue-cli-service 在 build 过程中会先删除导出的父级目录,
* 因此需要先执行主入口文件的打包命令
*/
if (file === 'index.js') {
scripts.unshift({
type: 'index',
script: 'vue-cli-service build --target lib --name index --formats commonjs --dest lib packages/index.js',
})
} else {
scripts.push({
type: 'package',
filename: file,
script: `vue-cli-service build --target lib --name index --formats commonjs --dest lib/${file} packages/${file}/index.js`,
})
}
})

scripts.forEach(config => {
if (config.type === 'index') {
shell.exec(config.script)
/**
* 目前构建项目利用的是 vue-cli 原生的 lib 构建方案,
* 但目前并不支持生成不带 .common 后缀的文件,
* 此处脚本就是用于 hack 该问题的代码
*/
shell.mv('lib/index.common.js', 'lib/vcomp.js')
shell.mv('lib/index.css', 'lib/vcomp.css')
// 更新组件库版本号
shell.sed('-i', '0.0.0', version, 'lib/vcomp.js')
} else if (config.type === 'package') {
shell.exec(config.script)
shell.mv(`lib/${config.filename}/index.common.js`, `lib/${config.filename}/index.js`)
shell.mkdir(`lib/${config.filename}/style`)
shell.echo(`require('../index.css')`).to(`lib/${config.filename}/style/index.js`)
}
})

至此,通过“曲线救国”的方式,我顺利的完成了 vue 组件库开发的任务。