vue源代码学习-vuex

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储(单一状态树)管理应用的所有组件的状态,并且严格的规定了状态的变更方式,使得这些变化可追踪、可调试。

准备工作

和之前一毛一样,我们先拷贝最新的v3.0.0 tag版本工程到本地吧~

1
2
3
4
git clone --branch v3.0.0 https://github.com/vuejs/vuex.git
cd vuex
npm i
npm run dev

一切准备就绪, go~

核心概念[1]

在Vue的开发模式中, 数据是单向传递的(这里不是指父子组件), 包含以下几个部分:

  • state,驱动应用的数据源;
  • view,以声明方式将 state 映射到视图;
  • actions,响应在 view 上的用户输入导致的状态变化。

示意图如下:

但是以下一些情况就很难处理了, 或者说不能优雅的处理:

  • 多个视图依赖同一个状态
  • 不同视图需要变更同一个状态

所以,为了解决这个问题,Vuex应运而生。借鉴了如Flux、Redux的思想,Vuex把共享的状态抽取出来,以一个全局的单例模式管理,通过定义和隔离状态管理中的各种概念并且强制遵循一定的规则,使得代码更结构化和易于维护。

Vuex的核心概念如下:

  • Store 每一个Vuex对象就是一个store(仓库), 可以理解为一个容器,里面包含了一切数据状态、改变数据的API、子仓库等
  • State 顾名思义, State就是状态,它是一个Vuex仓库的状态数据源,储存的数据是响应式的
  • Getter Getter可以理解为一个vuex仓库的计算属性, 它同vue的computed一样,只有依赖的值改变时才会重新计算值
  • Mutation 修改state状态的唯一方法就是提交mutation,这样看起来mutation很像一个事件。这样的好处是代码更结构化易于维护,并且可以配合插件(devtool)进行调试,保存各个状态下的状态快照。Mutation必须是同步的
  • Action Action通过提交Mutation来改变state,同mutation的区别是它可以包含任意的异步操作,但是记住Action不能直接改变state。
  • Module Vuex允许将store切割为模块,自上而下的进行切割,可以嵌套子模块、模块复用等。

使用Mutation来同步改变state状态,action中包裹异步情况再调用mutation,在我看来,Vuex这样分层设计是为了代码逻辑更为清晰和易于维护,同时也兼顾了强大的调试功能。

在官网的图示上,做了一点修改,主要是增加了一块从vue对象到mutations的调用连线。下面的内容,我们将逐步学习这个图中每一个节点和节点之间是如何工作的。

初始化Store

Store其实就是一个vuex的实例, 它的初始化主要做如下一些工作:

  1. 注册当前module(若首次则为root module, 可能为空对象{})
  2. 生成这个模块的局部上下文context(包含自身模块的state等)
  3. 为当前module注册mutation、action、getter到store中, 回调对应的参数就包含了局部context和全局store或它们的属性
  4. 递归的将子模块依次重新完成1-3步骤
  5. 创建内部的vue对象,以此将state转化为响应式对象,并且将getters转化为计算属性computed
  6. 根据配置,决定是否启用严格模式、加载自定义插件、开启vue Devtools

经过以上步骤,一个vuex对象就初始化完毕了,看起来很简单是不是。确实如此,依托了vue自身的功能,vuex很轻松优雅的就实现了响应式的数据仓库,并且建立了一套有效的调试机制。

局部上下文

为什么要有局部上下文?其实这是vuex为了实现模块划分引入的,局部上下文和全局store的区别其实也就是局部上下文方法或属性在调用时,将会在vuex框架内部添加namespace path,然后再调用store上对应的方法或属性。所以此时的调用路径等效于namespace path + relative path

Getters

getters是store计算属性,其实现也是利用了vue的计算属性,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
store.getters = {}
const wrappedGetters = store._wrappedGetters
const computed = {}
forEachValue(wrappedGetters, (fn, key) => {
// use computed to leverage its lazy-caching mechanism
computed[key] = () => fn(store)
Object.defineProperty(store.getters, key, {
get: () => store._vm[key], // 返回Vue的计算属性值
enumerable: true // for local getters
})
})
...
store._vm = new Vue({
data: {
$$state: state
},
computed // 把getters变为计算属性的性质,也就是依赖值改变了getters才会重新计算
})

传入的computed其实也就是应用传入的key:handler结构,只是key还加入了模块化的路径,并且定义了store.getters[key]指向_vm[key]以此来访问计算属性。

我们知道, Vue的计算属性的值,只有在其依赖的值改变时才会重新计算。记得之前在写vue相关博客时还没写到这是如何实现的,这里再啰嗦一段写一下。

Vue的计算属性

既然是要实现依赖触发改变,那么就像render watcher一样,每一个计算属性其实也对应了一个独立的watcherwatcher在初始化时会执行自身的getter,并且将自身push到依赖收集的栈顶,然后若在getter执行过程中遇到了响应式数据,就将watcher加入到响应式数据的dep中去。这样来看,我们其实已经保证到了当依赖数据改变时,watcher会自动更改值, 看起来就已经解决了问题。但是计算属性是属于可直接访问的值,假设有如下情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
data: {
a: 0
},
computed: {
b () {
return this.a
}
}
...

vue.a = 1
vue.a = 2
console.log(vue.b)

由于在vue中,响应式属性更新时,其执行watcher getter是放在异步queue中执行,所以在如上代码中,这样就有问题了。若我们将计算属性的watcher设置为同步执行getter,那么在上述连续修改依赖值的情况下,就重复的执行了计算属性对应的函数。综上,vue将计算属性设置成了懒加载模式,也就是说:

  • 当依赖值改变时, 将计算属性的watcher标记为dirty
  • 若访问计算属性, 则判断是否为dirty: 若为dirty, 立即重新计算值; 若否, 使用旧值
  • 若访问计算属性, 将计算属性依赖的dep加入到当前的栈顶watcher中(多半是render watcher)

计算属性的getter源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) { // 计算属性依赖的值改变过了,需要重新计算值(所以当依赖值没改变的时候,计算属性会使用旧值)
watcher.evaluate() // 立即计算新的值, 储存到watcher.value
}
if (Dep.target) { // 将计算属性的watcher所依赖dep加入到当前栈顶watcher(多半是render watcher),这样可以在依赖更改时去让计算属性的watcher dirty
watcher.depend()
}
return watcher.value
}
}
}

严格模式

vuex规定了,状态的改变必须由mutation来引起,所以这里提供了严格模式。当应用不遵循这个规则时,vuex会抛出异常信息。vuex在内部实现一个封装,使用封装函数来修改state的值就不会触发警告,本质也是设置一个标志位,再修改后重置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
_withCommit (fn) { // 严格模式的封装
const committing = this._committing
this._committing = true
fn()
this._committing = committing
}

...

function enableStrictMode (store) { // 严格模式实现
// 任何修改state值的行为, 必须是被store._withCommit方法包裹的(内部使用, 如commit方法)
// _withCommit设置了store._committing为true, 在执行完方法后再变回false
// 应用自行修改state值的时候store._committing为false触发警告
store._vm.$watch(function () { return this._data.$$state }, () => {
if (process.env.NODE_ENV !== 'production') {
assert(store._committing, `Do not mutate vuex store state outside mutation handlers.`)
}
}, { deep: true, sync: true }) // sync来保证watcher立即执行回调,而不是放入queue中异步执行(参看vue实现), 否则watcher的回调是异步执行的
}

热重载 & 动态注册模块

依托webpack的Hot Module Replacement API, vuex支持热重载。其实热重载与动态注册模块是类似的,基本等同于把store初始化的流程重新的跑了一次。但是为了保存状态,又要传递修改的内容给注册依赖的watcher,vuex主要会在以下几点进行特殊处理:

  • 热重载会保留之前的state状态,换言之不会再去调用Vue.set子模块state到父模块state下; 动态注册模块则根据preserveState来决定是否保留原state
  • 重新生成内部的vue对象, 虽然state其实可以不用变(已经是响应式属性), 但是getters可能改变,需要重新收集依赖、重新获取新值
  • 热重载将旧的vue对象中的$$state设置为null, 以此触发重新计算getters。虽然此时其他依赖于普通状态的watcher也会重新计算,但实际上计算出的值与旧值一样,这里的关键就是getters可能改变,导致计算属性结果值可能变更,所以要需要这样做
  • 销毁旧vue对象

调试功能

Vue Devtoolsvuex提供了非常方便的功能,称为time-travel debugging,确实非常方便。其实实现很简单,vuex只是在mutation时将当前状态快照传递给devtools, 这样devtools就可以保留全周期的状态快照。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

export default function devtoolPlugin (store) {
if (!devtoolHook) return

store._devtoolHook = devtoolHook

devtoolHook.emit('vuex:init', store)

devtoolHook.on('vuex:travel-to-state', targetState => { // 跳转到某个状态快照
store.replaceState(targetState)
})

store.subscribe((mutation, state) => { // 将mutation修改后对应的状态快照传递给devtools
devtoolHook.emit('vuex:mutation', mutation, state)
})
}

// store.js

replaceState (state) { // 直接替换整个state, devtool调试时用到
this._withCommit(() => {
this._vm._data.$$state = state
})
}

怎么样,一目了然吧?这样以来,我们就可以利用devtools提供的功能,非常方便的在各个状态快照中穿梭:

  • time travel: vuex实现中,将响应式对象$$state重新替换为devtools传递过来的即可
  • commit: 将选中的state作为base state
  • revert: 回到之前的状态,并且将这之间的状态快照移除

总结

看了源码,更理解了vuex的核心概念和实现。尤其是mutationaction的区别,从设计架构层面,两者各司其职,一个负责同步更改状态,一个负责异步,功能划分更为细致;从使用角度说,这一个设计则让vuex的调试和状态追踪变得简单方便。

最后,附上添加了注释的源代码,水平有限难免理解有误。

点击下载


  1. 摘自vuex官网: https://vuex.vuejs.org/zh/