【译文】在这个人为例子下,MobX——闪耀,Redux——不适合

原文: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进行状态管理,而不需要任何状态容器库进行管理。

pixel画板.gif

每一个像素点通过<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可以使用。
以下为演示结果:

mobx-profiler.png

通过统计的饼状图,我们可以看到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版。让我们一起看下它的图形结果:

redux1-result

大量的时间损耗在了执行JS上。接近50%的比例.这是不好的,为什么会执行这么长时间呢?t
我们一起来剖析一下:


redux-analysis.png

这个展示了Redux的订阅模式是怎么工作的。

在以上的例子中,每一个pixel都connectstore,也意味着它对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}
    />
  }
}

这里是结果

redux-single-subscriber.png

从这个版本可以看出我们上次的尝试是有多么的糟糕。
让我们看看发生了什么。

redux-single-subscriber-analysis.png

问题好像出在了我们的绘图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分为一下四个象限:


quadrants_1.png

当我们需要去改变一个pixel

quadrants_2.png

我们只需更新这个区块,不用去考虑其他区块。

quadrants_3.png

与重新渲染所有的16384 pixels,我们只需重新渲染 64×64=4096个 pixels。我们提升了75%的效率。
但是4096仍然是一个较大的数量。所以我们可以继续递归地分割的我们的渲染区块直到最后的1×1 pixel。

quadrants_4.png

为了通过这种当时来更新组件,我们需要通过同样的方式来构造我们的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>
    }
  }
}

这里是结果

quardants_result.png

哇哦,我们终于恢复了速度!它同之前的MobX版本一样快,而且拥有热加载和时间追踪功能。
我们的DOM树看起来也更像是树了:


quadrants_dom_tree.png

对比所有之前的方法:


quadrants_previous.png

终极优化方案

因为它实用性不高,我没有进行编码。
怎么做呢:针对每一个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?”却没有给出特定的场景,我也不会给出确定答案的原因。这也是我写这篇文章的原因。

谢谢阅读!

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

推荐阅读更多精彩内容