原文:https://hackernoon.com/an-artificial-example-where-mobx-really-shines-and-redux-is-not-really-suited-for-it-1a58313c0c70
译者:SunnyEver0
首先,我要申明一下,这个标题并不是引战的,也不是说MobX是比Redux更好的状态管理库。
Redux和MobX我都在使用,这两个我都很喜欢,如果有需要,我会再次使用它们。
这个文章下的例子,我们可以看到如果想要MobX和Redux都在同一个呈现水平的话,我们使用MobX可以愉悦轻松地开发但使用Redux时我们却步步维艰。如果我不清楚,这是一个不公平的比较。
因为这篇文章也并不是将这两个进行综合的比较也不没有完全考虑在一个真实的境况下。所以请不要将这个作为一个基准说:”让我们使用它吧,它更加快速。”
我不相信这个世界上存在最好的工具。我相信任何工具都有其用武之地的地方,也有其不适合的地方。
好,一切是时候了。我做这个实验是为可更好地理解这两个工具,当我们需要使用这个工具的时候,我能够选择正确的工具做正确的事。
好,我们现在继续。
使用版本
为什么要列出版本?因为在JavaScript这个世界里面,随着不断的优化,我相信这篇文章会很快过时。
-
react@15.4.1
,react-dom@15.4.1
-
redux@3.6.0
,react-redux@5.0.0-rc.1
-
mobx@2.6.4
,mobx-react@4.0.3
课题:pixel paint
我通过React构建了一个"pixel paint"的应用,它通过canvas
渲染了一个可绘制像素的128*128格子。
你可以通过鼠标漂浮在上面绘制任意的像素。
我们将会并排渲染两个canvas画板,而且这两个画板共享一样的图片。否则,我们可以自己通过component自身state进行状态管理,而不需要任何状态容器库进行管理。
每一个像素点通过<div>
进行展示,所以一共有1281282=32768个 DOM节点去渲染和更新。
这个实验在进行的过程中非常慢。
注意:所以的测试均是通过发布包进行。
MobX 版
这是容器store(我避免使用了装饰器语法,因为在写这篇文章的时候,该语法还为待定提案)。
const store = observable({
pixels: asMap({ }),
isActive (i, j) {
return !!store.pixels.get(i + ',' + j)
},
toggle: action(function toggle (i, j) {
store.pixels.set(i + ',' + j, !store.isActive(i, j))
})
})
渲染每一个像素点的canvas
function MobXCanvas () {
const items = [ ]
for (let i = 0; i < 128; i++) {
for (let j = 0; j < 128; j++) {
items.push(<PixelContainer i={i} j={j} key={i + ',' + j} />)
}
}
return <div>{items}</div>
}
管理每一个像素state的状态容器
const PixelContainer = observer(function PixelContainer ({ i, j }) {
return <Pixel
i={I}
j={j}
active={store.isActive(i, j)}
onToggle={() => store.toggle(i, j)}
/>
})
这里是演示结果。在屏幕的右上角,有mobx-react-devtools可以使用。
以下为演示结果:
通过统计的饼状图,我们可以看到scripting只占到一小部分百分比。大多数时间是花在了rendering和painting上面。
所以表明MobX做得很棒!
Redux 版(初次)
这是reducer:
const store = createStore((state = Immutable.Map(), action) => {
if (action.type === 'TOGGLE') {
const key = action.i + ',' + action.j
return state.set(key, !state.get(key))
}
return state
})
selector:
const selectActive = (state, i, j) => state.get(i + ',' + j)
action creator:
const toggle = (i, j) => ({ type: 'TOGGLE', i, j })
Redux store已经准备就绪。
给每一个 pixel提供的canvas store
:
function ReduxCanvas () {
const items = [ ]
for (let i = 0; i < 128; i++) {
for (let j = 0; j < 128; j++) {
items.push(<PixelContainer i={i} j={j} key={i + ',' + j} />)
}
}
return <Provider store={store}>
<div>
{items}
</div>
</Provider>
}
每一个pixel connect
store
:
const PixelContainer = connect(
(state, ownProps) => ({
active: selectActive(state, ownProps.i, ownProps.j)
}),
(dispatch, ownProps) => ({
onToggle: () => dispatch(toggle(ownProps.i, ownProps.j))
})
)(Pixel)
这是演示结果
你可以使用 redux-devtools-extension
来检查store state,actions
和一直耗时追踪。可以看出这种方式下性能大大低于了MobX版。让我们一起看下它的图形结果:
大量的时间损耗在了执行JS上。接近50%的比例.这是不好的,为什么会执行这么长时间呢?t
我们一起来剖析一下:
这个展示了Redux的订阅模式是怎么工作的。
在以上的例子中,每一个pixel都connect
了store
,也意味着它对store
的数据的数据进行了subscribe
。只要store
发生了变化,每一个订阅者都会进行检查是否需要进行更新渲染。
这个也意味着,我们只改变一个pixel,则Redux会通知所有的32768个观察者
这个同Angular 1
的脏检测类似。通过这个也建议在使用Redux时:不要在屏幕上渲染太多视图。
通过Redux,你只能对整个store的状态进行订阅,因为它的subtree是一个普通而陈旧的JavaScript对象,我们不能够订阅它。
但是使用MobX,每一块的state
都是有自己内部进行观察。在我们的MobX
版本中,每一个pixel都订阅了自己本身的state subtree
。这也是它效率高的原因。
第二次尝试:单一subscriber
所以,太多的观察者是一个问题。这一次,我将保证这里只有一个subscriber。
这里,我们创建了一个Canvas组件,它可以订阅整个store并渲染所有的pixels。
function ReduxCanvas () {
return <Provider store={store}><Canvas /></Provider>
}
const Canvas = connect(
(state) => ({ state }),
(dispatch) => ({ onToggle: (i, j) => dispatch(toggle(i, j)) })
)(function Canvas ({ state, onToggle }) {
const items = [ ]
for (let i = 0; i < 128; i++) {
for (let j = 0; j < 128; j++) {
items.push(<PixelContainer
i={I}
j={j}
active={selectActive(state, i, j)}
onToggle={onToggle}
key={i + ',' + j}
/>)
}
}
return <div>{items}</div>
})
PixelContainer
组件将其从Canvas
组件中获取的props传递给Pixel
class PixelContainer extends React.PureComponent {
constructor (props) {
super(props)
this.handleToggle = this.handleToggle.bind(this)
}
handleToggle () {
this.props.onToggle(this.props.i, this.props.j)
}
render () {
return <Pixel
i={this.props.i}
j={this.props.j}
active={this.props.active}
onToggle={this.handleToggle}
/>
}
}
从这个版本可以看出我们上次的尝试是有多么的糟糕。
让我们看看发生了什么。
问题好像出在了我们的绘图
Canvas
上。它是唯一一个订阅store的观察者,并对16384个pixels的状态进行管理。每次store进行dispatch action,它需要传递正确的props给16384个
pixels
进行渲染。这个意味着在每个canvas中,React会对
React.createElement
会对16384次调用,并尝试去调和16384个子组件。这不是一件好事。我们可以做得更好。
第三次尝试:平衡的状态树
Redux的一个主要优势在于它不可变的state tree(它开启可一些酷的功能,比如无痛热加载和time-traveling)
它证明了我们构建数据的方式和我们的观点并不是一成不变的。
一个不可变的状态树储存于一个平衡的树中
,这是最好的。我在这篇文章中讨论了这个观点:
immutable-js-persistent-data-structures-and-structural-sharing
译者注:这是文章的主题是 Why use Immutable.js instead of normal Javascript object? 有兴趣的朋友可以了解一下。
所以,我们在这里也这样做吧!
我们可以把我们的canvas分为一下四个象限:
当我们需要去改变一个pixel
我们只需更新这个区块,不用去考虑其他区块。
与重新渲染所有的16384 pixels,我们只需重新渲染 64×64=4096个 pixels。我们提升了75%的效率。
但是4096仍然是一个较大的数量。所以我们可以继续递归地分割的我们的渲染区块直到最后的1×1 pixel。
为了通过这种当时来更新组件,我们需要通过同样的方式来构造我们的state,当state改变时,我们可以直接用===
来判断区块的state是否已经改变
下面是初始化state的代码(recursively)
const generateInitialState = (size) => (size === 1
? false
: Immutable.List([
generateInitialState(size / 2),
generateInitialState(size / 2),
generateInitialState(size / 2),
generateInitialState(size / 2)
])
)
现在我们的state是一个递归嵌套的树,每个pixel也不是由这样的坐标----(58, 52)进行标识,而是使用这样的路径---- (1, 3, 3, 2, 0, 2, 1) 去标识。
但要在屏幕上展示,我们需要通过这个path能找到它的坐标。
function keyPathToCoordinate (keyPath) {
let i = 0
let j = 0
for (const quadrant of keyPath) {
i <<= 1
j <<= 1
switch (quadrant) {
case 0: j |= 1; break
case 2: i |= 1; break
case 3: i |= 1; j |= 1; break
default:
}
}
return [ i, j ]
}
// 译者注:如果这种二进制构造不好理解,可以用下面这种迭代还原法
function keyPathToCoordinate(keyPath) {
let i = 0, j = 0;
for (let index = 0; index < keyPath.length; index++) {
let path = keyPath[index];
let power = keyPath.length - index - 1;
if (path === 0) {
j += Math.pow(2, power);
} else if (path === 2) {
i += Math.pow(2, power);
} else if (path === 3) {
i += Math.pow(2, power);
j += Math.pow(2, power);
}
}
return [i ,j];
}
我们同样需要能够通过坐标获得path:
function coordinateToKeyPath (i, j) {
const keyPath = [ ]
for (let threshold = 64; threshold > 0; threshold >>= 1) {
keyPath.push(i < threshold
? j < threshold ? 1 : 0
: j < threshold ? 2 : 3
)
i %= threshold
j %= threshold
}
return keyPath
}
现在我们需要更新我们的reducer变成这样:
const store = createStore(
function reducer (state = generateInitialState(128), action) {
if (action.type === 'TOGGLE') {
const keyPath = coordinateToKeyPath(action.i, action.j)
return state.updateIn(keyPath, (active) => !active)
// |
// This is why I use Immutable.js:
// So that I can use this method.
}
return state
}
)
然后我们构造一个组件来遍历这个树并将所有的内容放置在这里。GridContainer
与store相连接并对最外层的Grid
进行渲染。
function ReduxCanvas () {
return <Provider store={store}><GridContainer /></Provider>
}
const GridContainer = connect(
(state, ownProps) => ({ state }),
(dispatch) => ({ onToggle: (i, j) => dispatch(toggle(i, j)) })
)(function GridContainer ({ state, onToggle }) {
return <Grid keyPath={[ ]} state={state} onToggle={onToggle} />
})
然后每个Grid
递归渲染一个较小的版本,直到它到达一个叶子(白色/黑色1x1像素画布)
class Grid extends React.PureComponent {
constructor (props) {
super(props)
this.handleToggle = this.handleToggle.bind(this)
}
shouldComponentUpdate (nextProps) {
// Required since we construct a new `keyPath` every render
// but we know that each grid instance will be rendered with
// a constant `keyPath`. Otherwise we need to memoize the
// `keyPath` for each children we render to remove this
// "escape hatch."
return this.props.state !== nextProps.state
}
handleToggle () {
const [ i, j ] = keyPathToCoordinate(this.props.keyPath)
this.props.onToggle(i, j)
}
render () {
const { keyPath, state } = this.props
if (typeof state === 'boolean') {
const [ i, j ] = keyPathToCoordinate(keyPath)
return <Pixel
i={I}
j={j}
active={state}
onToggle={this.handleToggle}
/>
} else {
return <div>
<Grid onToggle={this.props.onToggle} keyPath={[ ...keyPath, 0 ]} state={state.get(0)} />
<Grid onToggle={this.props.onToggle} keyPath={[ ...keyPath, 1 ]} state={state.get(1)} />
<Grid onToggle={this.props.onToggle} keyPath={[ ...keyPath, 2 ]} state={state.get(2)} />
<Grid onToggle={this.props.onToggle} keyPath={[ ...keyPath, 3 ]} state={state.get(3)} />
</div>
}
}
}
哇哦,我们终于恢复了速度!它同之前的MobX版本一样快,而且拥有热加载和时间追踪功能。
我们的DOM树看起来也更像是树了:
对比所有之前的方法:
终极优化方案
因为它实用性不高,我没有进行编码。
怎么做呢:针对每一个pixel创建一个Redux Store。我没有进行测试因为我确信这种方法是Redux最快的方式。
但你使用这种方式时,Redux的很多优点都会被丢弃。举个例子,Redux DevTools 可能闪退。而且时间追踪对于么一个pixel不是那么有用,不是吗?
更好的方式?
以上即为所有我所考虑到的地方。
如果你有更好的优雅解决方案,还请联系我。
Updates:
- Dan Abramov 提交了他的版本,它的性能比V1的要好但低于V3的性能而且比较简单易懂。
译者注:Dan Abramov为Redux的作者
结论
这是一个有趣的实验。
我们大多数在优化命令式算法方面拥有扎实的知识,但对于在不可变数据上的应用层面,如果我们不懂性能影响,优化它会变成一个挑战。
一旦我们优化了Redux版本,我们可以看到性能优化后导致了代码的可读性降低。上面写的代码真的有点糟糕!
就像Dan Abramov说的一样,Redux提供了一种折中的方案(MobX也是)。所以你会在不失去热加载和时间追踪功能的前提下,用代码的清晰度和可读性去交换性能吗?
在我的midi-instrument项目下,因为它会在MobileSafari下运行,所以性能是很重要的,特别是当一个instrument包含了几百个buttons的时候。
我同样也希望能够在使用不可变数据时快速创建新的原型,而不用去担心性能影响。
我也发现hot-reloading 和 time-traveling在这个项目中并不是那么适用。大多数state只持续了短暂的几秒钟,我的项目也足够的小我可以自己手动刷新界面。
所以我最后很开心地使用了MobX。
在我正在做的这个旋律游戏--Bemuse中,我能感觉到使用不可变数据能够帮助我写一些简单易用的代码。
我并不担心不确定的state突然变更,因为这里并不会有。
这里不会有大量的数据需要渲染,所以我可能不需要像上面的例子一样去优化它。
使用Redux DevTools,让所有state更新都集中显示中一个固定地方,也让我受益匪浅,这里,Redux尽情发挥了它的价值。
所以我很开心地在这个项目中使用了Redux。
一个不公平的性能比较
这个比较一开始就是不公平的,当我使用函数式方法(Redux)同命令式方法(MobX)进行比较时。
在1996年,Chris Okasaki在它140页的论文中得出了结论--“纯函数数据结构”,如下:
无论编译器怎么进步,只要命令式的算法优于函数式的算法,函数式程序绝不会比它的同行--命令式更快。
In that thesis (now available as a book), he tried to make data structures in functional programming as efficient its imperative counterpart.
在那篇论文中(现在已经发版出书),他尝试通过构造数据结构让函数式编程比命令式编程更有效。
本文提供了大量的函数式数据结构,它可以渐近地同命令式编程一样有效。
我不会停止函数式编程因为它不会像命令式算法那么快。
这完全取决于利弊的权衡。这是为什么我不会说"让我们使用Redux/MobX做所有事情吧!"这也是为什么当人们问我“2017年了,我应该使用MobX还是Redux?”却没有给出特定的场景,我也不会给出确定答案的原因。这也是我写这篇文章的原因。
谢谢阅读!