基于 MobX 构建视图框架无关的数据层-与 Vue 的结合

2018/07/09 · JavaScript
· mobx

原文出处:
kuitos   

mobx-vue 目前已进入 mobxjs
官方组织,欢迎试用求 star!

几周前我写了一篇文章描述了 mobx 与 angularjs 结合使用的方式及目的
(老树发新芽—使用 mobx 加速你的 AngularJS
应用),这次介绍一下如何将
MobX 跟 Vue 结合起来。

分析vue是如何实现数据改变更新视图的.

安装

npm i mobx-vue -S

1
npm i mobx-vue -S

前记

使用

mobx-vue 的使用非常简单,只需要使用 connect 将你用 mobx 定义的 store 跟
vue component 连接起来即可:

<template> <section> <p v-text=”amount”></p>
<p v-for=”user in users” :key=”user.name”>{{user.name}}</p>
</section> </template> <script lang=”ts”> import {
Connect } from “mobx-vue”; import Vue from “vue”; import Component from
“vue-class-component”; class ViewModel { @observable users = [];
@computed get amount() { return this.users.length } <a
href=’;
fetchUsers() {} } @Connect(new ViewModel()) @Component() export default
class App extends Vue { mounted() { this.fetchUsers(); } }
</script>

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
<template>
    <section>
        <p v-text="amount"></p>
        <p v-for="user in users" :key="user.name">{{user.name}}</p>
    </section>
</template>
 
<script lang="ts">
    import { Connect } from "mobx-vue";
    import Vue from "vue";
    import Component from "vue-class-component";
    class ViewModel {
        @observable users = [];
        @computed get amount() { return this.users.length }
        <a href=’http://www.jobbole.com/members/Francesco246437′>@action</a> fetchUsers() {}
    }
 
    @Connect(new ViewModel())
    @Component()
    export default class App extends Vue {
        mounted() {
            this.fetchUsers();
        }
    }
</script>

三个月前看了vue源码来分析如何做到响应式数据的,
文章名字叫vue源码之响应式数据, 最后分析到,
数据变化后会调用Watcher的update()方法.
那么时隔三月让我们继续看看update()做了什么.
(这三个月用react-native做了个项目, 也无心总结了, 因为好像太简单了).

Why MobX/mobx-vue

我们知道,mobx 跟 vue 都是基于 数据劫持&依赖收集
的方式来实现响应式机制的。mobx 官方也多次提到
inspired by vue,那么我们为什么还要将两个几乎一样的东西结合起来呢?

Yes, it’s weird.

2016年我在构建公司级组件库的时候开始思考一个问题,我们如何在代码库基于某一框架的情况下,能以尽可能小的代价在未来将组件库迁移到其他
框架/库
下?总不能基于新的技术全部重写一遍吧,这也太浪费生命了。且不说对于基础控件而言,交互/行为
逻辑基本上是可确定的,最多也就是 UI
上的一些调整,而且单纯为了尝试新技术耗费公司人力物力将基础库推导重写也是非常不职业的做法。那么我们只能接受被框架绑架而只能深陷某一技术栈从此泥潭深陷吗?对于前端这种框架半衰期尤其短的领域而言显然是不可接受的,结果无非就是要么自己跑路坑后来人,要么招不到人来一起填坑…
简单来说我们无法享受新技术带来的种种红利。

在 MVVM 架构视角下,越是重型的应用其复杂度越是集中在 M(Model) 跟
VM(ViewModel) 这两层,尤其是 Model
层,理论上应该是能脱离上层视图独立运行独立发布独立测试的存在。而相应的不同视图框架只是使用了不同绑定语法的动态模板引擎而已,这个观点我在前面的几篇文章里都讲述过。所以只要我们将视图层做的很薄,我们迁移的成本自然会降到一个可接受的范畴,甚至有可能通过工具在编译期自动生成不同框架的视图层代码。

要做到 Model 甚至 ViewModel
独立可复用,我们需要的是一种可以帮助我们描述各数据模型间依赖关系图且框架中立的通用状态管理方案。这期间我尝试过
ES6 accessor、redux、rxjs 等方案,但都不尽如人意。accessor
过于底层且异步不友好、redux
开发体验太差(参考Redux数据流管理架构有什么致命缺陷,未来会如何改进?)、rxjs
过重等等。直到后来看到 MobX:MobX 语法足够简单、弱主张(unopinioned)、oop
向、框架中立等特性正好符合我的需求。

在过去的一年多里,我分别在 react、angularjs、angular 上尝试过基于 MobX
构建 VM/M
层,其中有两个上线项目,一个个人项目,实践效果基本上也达到了我的预期。在架构上,我们只需要使用对应的
connector,就能基于同一数据层,在不同框架下自如的切换。这样看来,这套思路现在就剩
Vue 没有被验证了。

在 mobx-vue 之前,社区已经有一些优秀的 connector 实现,如
movue
vue-modex 等,但基本都是基于 vue
的插件机制且 inspired by
vue-rx,除了使用起来相对繁琐的问题外,最大的问题是其实现基本都是借助
Vue.util.defineReactive 来做的,也就是说还是基于 Vue
自有的响应式机制,这在一定程度不仅浪费了 MobX 的reactive
能力,而且会为迁移到其他视图框架下埋下了不确定的种子(毕竟你无法确保是
Vue 还是 MobX 在响应状态变化)。

参考:why mobx-vue

理想状态下应该是由 mobx 管理数据的依赖关系,vue 针对 mobx 的响应做出
re render 动作即可,vue 只是一个单纯的动态模板渲染引擎,就像 react
一样。

在这样的一个背景下,mobx-vue
诞生了。

本文叙事方式为树藤摸瓜, 顺着看源码的逻辑走一遍, 查看的vue的版本为2.5.2.
我fork了一份源码用来记录注释.

mobx-vue 是如何运作的

既然我们的目的是将 vue 变成一个单纯的模板渲染引擎(vdom),并且使用 mobx
响应式机制取代 vue 的响应式,那么只要我们劫持到 Vue
的组件装载及更新方法,然后在组件装载的时候收集依赖,在依赖发生变更时更新组件即可。

以下内容与其叫做 mobx-vue 是如何运作的,不如叫 Vue 源码解析😂:

我们知道 Vue 通常是这样初始化的:

new Vue({ el: ‘#app’, render: h => h(App)});

1
new Vue({ el: ‘#app’, render: h => h(App)});

那么找到 Vue 的构造函数,

function Vue (options) { …… this._init(options) }

1
2
3
4
function Vue (options) {
  ……
  this._init(options)
}

跟进到_init方法,除了一系列组件初始化行为外,最关键是最后一部分的
$mount 逻辑:

if (vm.$options.el) { vm.$mount(vm.$options.el) }

1
2
3
if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

跟进 $mount 方法,以 web runtime 为例:

if (process.env.NODE_ENV !== ‘production’ && config.performance &&
mark) { updateComponent = () => { … } } else { updateComponent = ()
=> { vm._update(vm._render(), hydrating) } } vm._watcher = new
Watcher(vm, updateComponent, noop)

1
2
3
4
5
6
7
8
9
10
11
if (process.env.NODE_ENV !== ‘production’ && config.performance && mark) {
    updateComponent = () => {
        …
    }
} else {
    updateComponent = () => {
        vm._update(vm._render(), hydrating)
    }
}
 
vm._watcher = new Watcher(vm, updateComponent, noop)

从这里可以看到,updateComponent 方法将是组件更新的关键入口,跟进
Watcher 构造函数,看 Vue 怎么调用到这个方法的:

constructor ( vm: Component, expOrFn: string | Function, cb: Function,
options?: Object ) { … this.expression = process.env.NODE_ENV !==
‘production’ ? expOrFn.toString() : ” // parse expression for getter if
(typeof expOrFn === ‘function’) { this.getter = expOrFn } else {
this.getter = parsePath(expOrFn) … } this.value = this.lazy ?
undefined : this.get()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object
  ) {
    …
    this.expression = process.env.NODE_ENV !== ‘production’
      ? expOrFn.toString()
      : ”
    // parse expression for getter
    if (typeof expOrFn === ‘function’) {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
      …
    }
    this.value = this.lazy
      ? undefined
      : this.get()

get () { … try { value = this.getter.call(vm, vm) } catch (e) { … }

1
2
3
4
5
6
7
get () {
    …
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      …
  }

看到这里,我们能发现,组件 装载/更新 的发起者是:
value = this.getter.call(vm, vm) ,而我们只要通过 vm._watcher.getter
的方式就能获取相应的方法引用, 即
updateComponent := vm._watcher.getter。所以我们只要在 $mount 前将
MobX 管理下的数据植入组件上下文供组件直接使用,在$mount 时让 MobX
收集相应的依赖,在 MobX 检测到依赖更新时调用 updateComponent
即可。这样的话既能让 MobX 的响应式机制通过一种简单的方式 hack 进 Vue
体系,同时也能保证组件的原生行为不受到影响(生命周期钩子等)。

中心思想就是用 MobX 的响应式机制接管 Vue 的 Watcher,将 Vue
降级成一个纯粹的装载 vdom 的组件渲染引擎。

核心实现很简单:

const { $mount } = Component.prototype; Component.prototype.$mount =
function (this: any, …args: any[]) { let mounted = false; const
reactiveRender = () => { reaction.track(() => { if (!mounted) {
$mount.apply(this, args); mounted = true; } else {
this._watcher.getter.call(this, this); } }); return this; }; const
reaction = new Reaction(`${name}.render()`, reactiveRender); dispose =
reaction.getDisposer(); return reactiveRender(); };

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { $mount } = Component.prototype;
 
Component.prototype.$mount = function (this: any, …args: any[]) {
    let mounted = false;
    const reactiveRender = () => {
        reaction.track(() => {
            if (!mounted) {
                $mount.apply(this, args);
                mounted = true;
            } else {
                this._watcher.getter.call(this, this);
            }
        });
 
        return this;
    };
    const reaction = new Reaction(`${name}.render()`, reactiveRender);
    dispose = reaction.getDisposer();
    return reactiveRender();
};

完整代码在这里:

目的

最后

尤大大之前说过:mobx + react 是更繁琐的 Vue,本质上来看确实是这样的,mobx

  • react 组合提供的能力恰好是 Vue 与生俱来的。而 mobx-vue
    做的事情则刚好相反:将 Vue 降级成 react 然后再配合 MobX 升级成 Vue
    😂。这确实很怪异。但我想说的是,我们的初衷并不是说 Vue
    的响应式机制实现的不好从而要用 MobX 替换掉,而是希望借助 MobX
    这个相对中立的状态管理平台,面向不同视图层技术提供一种相对通用的数据层编程范式,从而尽量抹平不同框架间的语法及技术栈差异,以便为开发者提供更多的视图技术的决策权及可能性,而不至于被某一框架绑架裹挟。

PS: 这篇是系列文章的第一篇,后面将有更多关于
如何基于 MobX 构建视图框架无关的数据层
的架构范式及实践的内容,敬请期待!

1 赞 1 收藏
评论

银河国际平台官方网站 1

明确调查方向才能直至目标, 先说一下目标行为:
数据变化以后执行了什么方法来更新视图的.
那么准备开始以这个方向为目标从vue源码的入口开始找答案.

从之前的结论开始

先来复习一下之前的结论:

vue构造的时候会在data(和一些别的字段)上建立Observer对象,
getter和setter被做了拦截, getter触发依赖收集, setter触发notify.

另一个对象是Watcher, 注册watch的时候会调用一次watch的对象,
这样触发了watch对象的getter, 把依赖收集到当前Watcher的deps里,
当任何dep的setter被触发就会notify当前Watcher来调用Watcher的update()方法.

那么这里就从注册渲染相关的Watcher开始.

找到了文件在src/core/instance/lifecycle.js中.

new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)

mountComponent

渲染相关的Watcher是在mountComponent()这个方法中调用的,
那么我们搜一下这个方法是在哪里调用的. 只有2处,
分别是src/platforms/web/runtime/index.js和src/platforms/weex/runtime/index.js,
以web为例:

Vue.prototype.$mount = function (
 el?: string | Element,
 hydrating?: boolean
): Component {
 el = el && inBrowser ? query(el) : undefined
 return mountComponent(this, el, hydrating)
}

原来如此, 是$mount()方法调用了mountComponent(),
(或者在vue构造时指定el字段也会自动调用$mount()方法),
因为web和weex(什么是weex?之前别的文章介绍过)渲染的标的物不同,
所以在发布的时候应该引入了不同的文件最后发不成不同的dist(这个问题留给之后来研究vue的整个流程).

下面是mountComponent方法:

export function mountComponent (
 vm: Component,
 el: ?Element,
 hydrating?: boolean
): Component {
 vm.$el = el // 放一份el到自己的属性里
 if (!vm.$options.render) { // render应该经过处理了, 因为我们经常都是用template或者vue文件
 // 判断是否存在render函数, 如果没有就把render函数写成空VNode来避免红错, 并报出黄错
 vm.$options.render = createEmptyVNode
 if (process.env.NODE_ENV !== 'production') {
  /* istanbul ignore if */
  if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
  vm.$options.el || el) {
  warn(
   'You are using the runtime-only build of Vue where the template ' +
   'compiler is not available. Either pre-compile the templates into ' +
   'render functions, or use the compiler-included build.',
   vm
  )
  } else {
  warn(
   'Failed to mount component: template or render function not defined.',
   vm
  )
  }
 }
 }
 callHook(vm, 'beforeMount')

 let updateComponent
 /* istanbul ignore if */
 if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
 // 不看这里的代码了, 直接看else里的, 行为是一样的
 updateComponent = () => {
  const name = vm._name
  const id = vm._uid
  const startTag = `vue-perf-start:${id}`
  const endTag = `vue-perf-end:${id}`

  mark(startTag)
  const vnode = vm._render()
  mark(endTag)
  measure(`vue ${name} render`, startTag, endTag)

  mark(startTag)
  vm._update(vnode, hydrating)
  mark(endTag)
  measure(`vue ${name} patch`, startTag, endTag)
 }
 } else {
 updateComponent = () => {
  vm._update(vm._render(), hydrating)
 }
 }

 // we set this to vm._watcher inside the watcher's constructor
 // since the watcher's initial patch may call $forceUpdate (e.g. inside child
 // component's mounted hook), which relies on vm._watcher being already defined
 // 注册一个Watcher
 new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
 hydrating = false

 // manually mounted instance, call mounted on self
 // mounted is called for render-created child components in its inserted hook
 if (vm.$vnode == null) {
 vm._isMounted = true
 callHook(vm, 'mounted')
 }
 return vm
}

这段代码其实只做了3件事:

  • 调用beforeMount钩子
  • 建立Watcher
  • 调用mounted钩子

(哈哈哈)那么其实核心就是建立Watcher了.

看一下Watcher的参数: vm是this, updateComponent是一个函数, noop是空,
null是空, true代表是RenderWatcher.

在Watcher里看了isRenderWatcher:

if (isRenderWatcher) {
  vm._watcher = this
 }

是的,
只是复制了一份用来在watcher第一次patch的时候判断一些东西(从注释里看的,
我现在还不知道是干嘛的).

那么只有一个问题没解决就是updateComponent是个什么东西.

updateComponent

在Watcher的构造函数的第二个参数传了function,
那么这个函数就成了watcher的getter. 聪明的你应该已经猜到,
在这个updateComponent里一定调用了视图中所有的数据的getter,
才能在watcher中建立依赖从而让视图响应数据的变化.

updateComponent = () => {
  vm._update(vm._render(), hydrating)
 }

那么就去找vm._update()和vm._render().

在src/core/instance/render.js找到了._render()方法.

Vue.prototype._render = function (): VNode {
 const vm: Component = this
 const { render, _parentVnode } = vm.$options // todo: render和_parentVnode的由来

 // reset _rendered flag on slots for duplicate slot check
 if (process.env.NODE_ENV !== 'production') {
  for (const key in vm.$slots) {
  // $flow-disable-line
  vm.$slots[key]._rendered = false
  }
 }

 if (_parentVnode) {
  vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject
 }

 // set parent vnode. this allows render functions to have access
 // to the data on the placeholder node.
 vm.$vnode = _parentVnode
 // render self
 let vnode
 try {
  vnode = render.call(vm._renderProxy, vm.$createElement)
 } catch (e) {
  // catch其实不需要看了, 都是做异常处理, _vnode是在vm._update的时候保存的, 也就是上次的状态或是null(init的时候给的)
  handleError(e, vm, `render`)
  // return error render result,
  // or previous vnode to prevent render error causing blank component
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== 'production') {
  if (vm.$options.renderError) {
   try {
   vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
   } catch (e) {
   handleError(e, vm, `renderError`)
   vnode = vm._vnode
   }
  } else {
   vnode = vm._vnode
  }
  } else {
  vnode = vm._vnode
  }
 }
 // return empty vnode in case the render function errored out
 if (!(vnode instanceof VNode)) {
  if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
  warn(
   'Multiple root nodes returned from render function. Render function ' +
   'should return a single root node.',
   vm
  )
  }
  vnode = createEmptyVNode()
 }
 // set parent
 vnode.parent = _parentVnode
 return vnode
 }
}

这个方法做了:

  • 根据当前vm的render方法来生成VNode.
    (render方法可能是根据template或vue文件编译而来,
    所以推论直接写render方法效率最高)
  • 如果render方法有问题, 那么首先调用renderError方法,
    再不行就读取上次的vnode或是null.
  • 如果有父节点就放到自己的.parent属性里.
  • 最后返回VNode

所以核心是这句:

vnode = render.call(vm._renderProxy, vm.$createElement)

其中的render(), vm._renderProxy, vm.$createElement都不知道是什么.

先看vm._renderProxy: 是initMixin()的时候设置的, 在生产环境返回vm,
开发环境返回代理, 那么我们认为他是一个可以debug的vm(就是vm),
细节之后再看.

vm.$createElement的代码在vdom文件夹下, 看了下是一个方法,
返回值一个VNode.

render有点复杂, 能不能以后研究,
总之就是把template或者vue单文件和mount目标parse成render函数.

小总结: vm._render()的返回值是VNode, 根据当前vm的render函数

接下来看vm._update()

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
 const vm: Component = this
 if (vm._isMounted) {
  callHook(vm, 'beforeUpdate')
 }
 // 记录update之前的状态
 const prevEl = vm.$el
 const prevVnode = vm._vnode
 const prevActiveInstance = activeInstance
 activeInstance = vm
 vm._vnode = vnode
 // Vue.prototype.__patch__ is injected in entry points
 // based on the rendering backend used.
 if (!prevVnode) { // 初次加载, 只有_update方法更新vm._vnode, 初始化是null
  // initial render
  vm.$el = vm.__patch__( // patch创建新dom
  vm.$el, vnode, hydrating, false /* removeOnly */,
  vm.$options._parentElm,
  vm.$options._refElm
  )
  // no need for the ref nodes after initial patch
  // this prevents keeping a detached DOM tree in memory (#5851)
  vm.$options._parentElm = vm.$options._refElm = null
 } else {
  // updates
  vm.$el = vm.__patch__(prevVnode, vnode) // patch更新dom
 }
 activeInstance = prevActiveInstance
 // update __vue__ reference
 if (prevEl) {
  prevEl.__vue__ = null
 }
 if (vm.$el) {
  vm.$el.__vue__ = vm
 }
 // if parent is an HOC, update its $el as well
 if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
  vm.$parent.$el = vm.$el
 }
 // updated hook is called by the scheduler to ensure that children are
 // updated in a parent's updated hook.
 }

我们关心的部分其实就是__patch()的部分, __patch()做了对dom的操作,
在_update()里判断了是否是初次调用, 如果是的话创建新dom,
不是的话传入新旧node进行比较再操作.

结论

vue的视图渲染是一种特殊的Watcher, watch的内容是一个函数,
函数运行的过程调用了render函数,
render又是由template或者el的dom编译成的(template中含有一些被observe的数据).
所以template中被observe的数据有变化触发Watcher的update()方法就会重新渲染视图.

遗留

render函数是在哪里被编译的
vue源码发布时引入不同平台最后打成dist的流程是什么
__patch__和VNode的分析

您可能感兴趣的文章:

  • Vue2.0用户权限控制解决方案的示例
  • vue-router路由懒加载和权限控制详解
  • Vue通过URL传参如何控制全局console.log的开关详解
  • Vue-Access-Control
    前端用户权限控制解决方案
  • Vue2.0用户权限控制解决方案
  • 详解基于vue-router的动态权限控制实现方案
  • vue-router
    权限控制的示例代码
  • 基于Vue实现后台系统权限控制的示例代码
  • vue2.0结合Element实现select动态控制input禁用实例
  • 详解VUE的状态控制与延时加载刷新