Vue.js最独特的特性之一是看起来并不显眼的响应式系统。数据模型仅仅是普通的javascript对象。而当你修改它时,视图会进行更新。这使得状态管理非常简单、直接。不过理解其工作原理同样重要,这样你可以回避一些常见的问题。 -----官方文档
vue.js无疑是前端目前最火的MVVM框架之一,就像曾经的jQuery,已经成为前端工程师必备的技能。而要在工作中用好它,如官方文档所言,理解其工作原理很重要。笔者于清明小长假期间认真阅读了一下刘博文老师的深入剖析Vue.js源码,在这里与大家一起学习分享一下vue(2.0版本)全局API的实现原理。
进入正文之前,我们先来看下vue.js内部的一段代码:
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
renderMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
export default Vue
其中定义了Vue构造函数,然后分别调用了initMixin、stateMixin、eventsMixin、lifecycleMixin和renderMixin这5个函数,并将Vue构造函数当作参数传给了这5个函数。
这5个函数的作用就是向Vue的原型中挂载方法。以函数initMixin为例,它的实现方式是这样的:
export function initMixin (Vue) {
Vue.prototype._init = function (options) {
// 初始化操作--生命周期的流程、响应式系统流程的启动
}
}
其他4个函数也是如此,只是它们在Vue构造函数的prototype属性上挂载不同的方法而已
下面进入这次的正题,一起来学习Vue全局API的实现源码:
1. 数据相关的实例方法
与数据相关的实例方法有3个,分别是vm.set、vm.$delete,它们是在stateMixin中挂载到Vue的原型上的。
import {
set,
del
} from '../observer/index'
/**
* target不能是Vue.js实例或Vue.js实例的根数据对象
*
* @param {object | array} target
* @param {String | Number} key
* @param {*} value
*/
export function stateMixin (Vue) {
Vue.prototype.$set = set
Vue.prototype.$delete = del
Vue.prototype.$watch = function (expOrFn, cb, options) {}
}
(1)vm.$set
如果了解vue.js的变化侦测原理,我们就知道vue只能追踪到已经存在的属性的变化,新增的属性无法被追踪到。因为在ES6之前,Javascript并没有提供元编程的能力,无法侦测Object什么时候被添加了一个新属性。而用vm.$set可以解决这个问题,使用它可以为Object新增属性,然后vue可以将这个新增属性转换成响应式的。
set实现代码:
// oberver/index
function set (target, key, val) {
// target是数组且key是一个有效的索引值 先设置length属性
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
// 当我们使用splice方法把val设置到target中的时候
// 数组拦截器会侦测到target发生了变化
// 并且会自动把这个新增的val转换成响应式的
target.splice(key, 1, val)
return val
}
// 如果key已经存在于target中,这种情况属于修改数据
// 修改数据的动作会被Vue.js侦测到
// 数据发生变化后 会自动向依赖发送通知
if (key in target && (!key in Object.prototype)) {
target[key] = val
return val
}
// 处理在target上新增的key
const ob = target.__ob__
// target._isVue判断target是否为Vue实例
// ob.vmCount判断target是否为根数据(this.$data)
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
// 如果target没有__ob__属性 说明他不是响应式的 不需要做特殊处理
// 只需要通过key和val在target上设置
if (!ob) {
target[key] = val
return val
}
// target是响应式的 使用defineReactive将新增属性转换成getter/setter的形式
defineReactive(ob.value, key, val)
// 向target的依赖触发变化通知
ob.dep.notify()
return val
}
export.set = set
(2) vm.$delete
从字面意思我们可以知道这个API的作用就是用来删除数据的某个属性的。我们知道vue的变化侦测是使用Object.defineProperty实现的,如果数据是使用delete关键字删除的,那么无法发现数据发生了变化。vm.$delete就是用来解决这个问题的。
del实现代码:
// observer/index
function del (target, key) {
// target为数组
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.splice(key, 1)
return
}
const ob = target.__ob__
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid deleting reactive properties to a Vue instance or its root $data ' +
'- just set it to null.'
)
return
}
// 如果key不是target自身属性 直接return
if (!hasOwn(target, key)) {
return
}
// 从target中删除key属性
delete target[key]
// 如果target不是响应式的 直接return
if (!ob) {
return
}
// 向依赖发送消息
ob.dep.notify()
}
(3)vm.$watch
vm.$watch其实是对Watcher的一种封装,Watcher的原理实现这里就不多多了,如果想了解有兴趣的话,可以去查找下相关资料。
(推荐:blog.csdn.net/wangweiange…)
实现代码:
Vue.prototype.$watch = function (expOrFn, cb, options) {
const vm = this
options = options || {}
// watch依赖收集的Watcher
const watcher = new Watcher(vm, expOrFn, cb, options)
// immediate=true时 会调用一次 watcher.run 方法,因此会调用一次watch中相关key的函数
if (options.immediate) {
cb.call(vm, watcher.value)
}
// 返回一个取消监听的函数
return function unwatchFn () {
watcher.teardown()
}
}
2. 与事件相关的实例方法
与事件相关的实例方法有4个,分别是:vm.once、vm.
emit。这4个方法是在eventsMixin中挂载到vue构造函数的prototype属性中的。
export function eventsMixin (Vue) {
// 监听当前实例上的自定义事件 事件由vm.$emit触发
Vue.prototype.$on = function (event, fn) {
const vm = this
// event是一个数组时 遍历数组 递归调用vm.$on
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
this.$on(event[i], fn)
}
} else {
// vm._events是一个对象 用来存储事件
(vm._events[event] || vm._events[event] = []).push(fn)
}
return vm
}
// 移除自定义事件监听器
// 如果没有提供参数 则移除所有的事件监听器
// 如果只提供了事件 则移除该事件所有的监听器
// 如果同时提供了事件与回调 则只移除这个回调的监听器
Vue.prototype.$off = function (event, fn) {
const vm = this
// 移除所有事件的监听器
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
// event是数组
if (Array.isArray(event)) {
for (let i = 0, l = event.length;i < l; i++) {
this.$off(event[i], fn)
}
return vm
}
const cbs = vm._events[event]
// 如果这个事件没有被监听 直接return
if (!cbs) {
return vm
}
// 移除该事件的所有监听器
if (arguments.length === 1) {
vm_events[event] = null
return vm
}
// 如果同时提供了事件与回调 只移除这个回调的监听器
if (fn) {
const cbs = vm._events[event]
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
}
}
}
return vm
}
// 监听一个自定义事件 但是只触发一次 第一次触发之后移除监听器
Vue.prototype.$once = function (event, fn) {
const vm = this
function on () {
vm.$off(event, fn)
fn.apply(vm, arguments)
}
on.fn = fn
vm.$on(event, on)
}
// 触发当前实例上的事件
Vue.prototype.$emit = function (event) {
const vm = this
let cbs = vm._events[event]
if (cbs) {
const args = toArray(arguments, 1)
for (let i = 0, l = cbs.length; i < l; i++) {
try {
cbs[i].apply(vm, args)
} catch (e) {
handleError(e, vm, `event handler for "${event}"`)
}
}
}
return vm
}
}
3. 与生命周期相关的实例方法
与生命周期相关的实例方法有4个:vm.forceUpdate, vm.
destory
vm.mount是在跨平台的代码中挂载到Vue构造函数的prototype属性上的
vm.destory是从lifecycleMixin中挂载的
vm.$nextTick是从renderMixin中挂载的
(1)vm.$forceUpdate
vm.$forceUpdate的作用是迫使vue.js实例重新渲染,它只影响实例本身以及插入插槽内容的子组件,而不是所有子组件。
代码实现:
Vue.prototype.$forceUpdate = function () {
const vm = this
if (vm._watcher) {
vm._watcher.update()
}
}
(2)vm.$destory
vm.$destory的作用是完全销毁一个实例,它会清理该实例与其他实例的连接,并解绑其全部指令及监听器,同时会触发 beforeDestory和destoryed的钩子函数
代码实现:
Vue.prototype.$destory = function () {
const vm = this
// 对属性_isBeingDestory进行判断 如果为 true vuejs实例正在被销毁 直接return 防止反复销毁
if (vm._isBeingDestory) {
return
}
callHook(vm, beforeDestory)
vm._isBeingDestoryed = true
// 删除自己与父级之间的连接
const parent = vm.$parent
// 如果当前实例有父级 同时父级没有被销毁且不是抽象组件
if (parent && !parent._isBeingDestoryed && vm.$options.abstract) {
remove(parent.$children, vm)
}
// 销毁实例上的所有watcher
// 从watcher监听的所有状态
if (vm._watcher) {
vm._watcher.teardown()
}
// 每当创建watchers实例时 都会将watcher实例添加到 vm._watchers中
let i = vm._watchers.length
while (i--) {
vm._watchers[i].teardown()
}
vm._isDestoryed = true
// 在 vnode树上触发destory钩子函数解绑指令
vm.__patch__(vm._vnode, null)
// 触发destoryed钩子函数
callHook(vm, 'destoryed')
// 移除所有的事件监听器
vm.$off()
}
(3) vm.$nextTick()
nextTick接收一个回调函数作为参数 它的作用是将回调延迟到下次DOM更新周期之后执行。
我们在开发项目时会遇到一种场景:当更新了状态(数据)后,需要对新DOM做一些操作,但是这是我们其实获取不到更新后的DOM,因为还没有重新渲染。这个时候我们需要使用nextTick方法
示例如下:
new Vue({
// ......
methods: {
// ......
example: function () {
// 修改数据
this.message = 'changed'
// DOM还没有更新
this.$nextTick(function () {
// DOM 现在更新了
// this绑定到当前实例
this.doSomethindElse()
})
}
}
})
要理解nextTick,我们需要知道,在Vue.js中,当状态发生变化时,watcher会得到通知,然后触发虚拟DOM的渲染流程。而watcher触发渲染是异步的。Vue.js中有一个队列,每当需要渲染时,会将watcher推送到这个队列中,在下一次事件循环中再让watcher触发渲染的流程。
为什么vue.js使用异步更新队列
我们知道vue.js的变化侦测的通知只发送到组件,组件内用到的所有状态的变化都会通知到同一个watcher,所以为了避免多个状态变化,多次渲染,虚拟DOM会等所有状态都修改完毕之后,一次性将整个组件的DOM渲染到最新。
Vue.js实现的方式是将收到通知的watcher实例添加到队列中缓存起来,并且在添加到队列之前检查其中是否已经存在相同的watcher,只有不存在时,才将 watcher实例添加到队列中。然后在下一次事件循环中,Vue.js会让队列中的 watcher触发渲染流程并清空队列。
事件循环
我们都知道Javascript是一门单线程且非阻塞的脚本语言,这意味着Javascript代码在执行的任何时候只有一个主线程来处理所有任务。而非阻塞是指当代码需要处理异步任务时,主线程会挂起这个任务,当异步任务处理完毕后,主线程再根据一定规则去执行相应回调。
其实,当任务处理完毕后,Javascript会将这个事件加入一个队列中,即事件队列。被放入事件队列中的事件不会立刻执行其回调,而是等待当前执行栈中的所有任务执行完毕后,主线程会去查找事件队列中是否有任务。
异步任务有两种类型:微任务(microtask)和宏任务(macrotask)。不同类型的任务被分配到不同的任务队列中。
当执行栈中的所有任务都执行完毕后,会去检查微任务队列中是否有事件存在,如果存在,则会依次执行微任务队列中事件对应的回调,直到为空。然后去宏任务队列中取出一个事件,把对应的回调加入当前执行栈,当执行栈中的所有任务都执行完毕后,检查微任务队列中是否有事件存在。无限重复此过程,就形成了一个循环,即事件循环。
常见微任务的事件
Promise.then MutationObserver Object.observe process.nextTick
常见宏任务的事件
setTimeout setInterval setImmediate MessageChannel requestAnimationFrame
讲了这么多相关知识,我们回到nextTick的实现原理上来。vm.nextTick方法中,而是抽象成了nextTick方法供两个方法共用。
代码如下:
import { nextTick } from '../util/index'
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
}
由于vm.$nextTick会将回调添加到任务队列中延迟执行,所以在回调执行前,如果反复调用nextTick,Vue.js并不会反复将回调添加到任务队列中,只会向任务队列中添加一个任务,多次使用nextTick只会将回调添加到回调列表中缓存起来。当任务触发时,依次执行列表中的所有回调并清空列表。
const callbacks = []
let pending = false
// 注册的任务
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
let microTimerFunc
const p = Promise.resolve()
// 将flushCallbacks添加到微任务队列中
microTimerFunc = () => {
p.then(flushCallbacks)
}
export function nextTick (cb, ctx) {
callbacks.push(() => {
if (cb) {
cb.call(ctx)
}
})
// 通过pending判断是否需要向任务队列中添加任务
if (!pending) {
pending = true
microTimerFunc()
}
}
下图给出nextTic的内部注册流程和执行流程:

// 在上面代码基础上新增
// ......
let useMacroTask = false
// ......
export function withMacroTask (fn) {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return false
})
}
export function nextTick(cb, ctx) {
// ......
if (!pending) {
pending = true
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
}
withMacroTask函数的作用是给回调函数做一层包装,保证在整个回调函数执行过程中,如果修改可状态(数据),那么更新DOM的操作会被推到宏任务队列中。
下面我们来看看macroTimerFunc是如何将回调添加到宏任务队列中的。
前面我们介绍过几种属于宏任务的事件。Vue.js优先使用setImmediate,但是它存在兼容问题,只能在IE中使用,所以使用MessageChannel作为备选方案,如果浏览器也不支持MessageChannel,那么最好会使用setTimeout将回调添加到宏任务队列中。
macroTimerFunc实现代码:
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MeaasgeChannel) ||
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channle.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
前面提到microTimerFunc的实现原理是使用Promise.then,但并不是所有浏览器都支持Poemise,当不支持时,会降级成macroTimerFunc,其实现方式如下:
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
// 将flushCallbacks添加到微任务队列中
microTimerFunc = () => {
p.then(flushCallbacks)
}
} else {
microTimerFunc = macroTimerFunc
}
官方文档中有这样一句话:如果没有提供回调且在支持Promise的环境中,则返回一个Promise。也就是说可以这样使用vm.$nextTick:
this.$nextTick().then(function () {
// DOM更新了
})
要实现这个功能,我们只需要在nextTick中进行判断,如果没有提供回调且当前环境支持Promise,那么返回Promise,并且在callbacks中添加一个函数,当这个函数执行时,执行Promise的resolve即可,代码如下:
export function nextTick (cb, ctx) {
// ......
let _resolve
callbacks.push(() => {
if (cb) {
cb.call(ctx)
} else if (_resolve) {
_resolve(ctx)
}
})
// ......
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
(4) vm.$mount()
一个Vue.js实例在实例化时如果没有收到el选项,则它处于"未挂载"状态,没有关联的DOM元素。我们可以使用vm.$mount手动挂载一个未挂载的实例。如果没有提供参数,模板会被渲染为文档之外的元素,必须使用原生DOM的API把它插入到文档中。这个方法返回实例自身,因而可以链式调用其他实例方法。
4. 常见全局API的实现原理
全局API和实例方法不同,后者是在 Vue的原型上挂载方法,前者是直接在Vue上挂载方法
(1)Vue.extend
Vue.extend用来创建一个子类,让它继承Vue身上的一些功能,实现代码如下:
let cid = 1
Vue.extend = function (extendOptions) {
extendOptions = extendOptions || {}
const Super = this
const SuperId = Super.cid
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
return cachedCtors[SuperId]
}
const name = extendOptions.name || Super.options.name
if (process.env.NODE_ENV !== 'production') {
// 校验name
if (!/^[a-zA-Z][\w-]*$)/.test(name)) {
warn(
'Invalid component name: "' + name + '". Component names ' +
'can only contain alphanumberic characters and the hyphen, ' + 'and must start with a letter.'
)
}
}
const Sub = function VueComponent (options) {
this._init(options)
}
// 将父类的原型继承到子类中
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
Sub.cid = cid++
// 将父类的options选项继承到子类中
Sub.options = mergeOptions(
Super.options,
extendOptions
)
Sub['super'] = Super
// 如果选项中存在props属性 则初始化
if (Sub.options.props) {
initProps(Sub)
}
// 如果选项中存在computed 则对它进行初始化
if (Sub.options.computed) {
initComputed(Sub)
}
// 将父类中存在的属性依次复制到子类中
Sub.extend = Super.extend
Sub.mixin = Super.mixin
Sub.use = Super.use
// ASSET_TYPES = ['component', 'directive', 'filter']
ASSET_TYPES.forEach(function (type) {
Sub[type] = Super[type]
})
if (name) {
Sub.options.components[name] = Sub
}
// 缓存构造函数
cachedCtors[SuperId] = Sub
return Sub
}
初始化props是将key代理到_props中。例如vm.name实际上访问的是Sub.prototype._props.name。实现原理如下:
function initProps (Comp) {
const props = Comp.options.props
for (const key in props) {
proxy(Comp.prototype, `_props`, key)
}
}
const sharedPropertyDefinition = {
enumberable: true,
configurable: true,
get: noop,
set: noop
}
function proxy (target, sourceKey, key) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
}
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
初始化computed只是将computed对象遍历一遍,并将里面的每一项都定义一遍
function initComputed (Comp) {
const computed = Comp.options.computed
for (const key in computed) {
defineComputed(Comp.prototype, key, computed[key])
}
}
(2). Vue.nextTick
Vue.nextTick的实现原理与我们前面介绍的vm.$nextTick一样,代码如下:
import { nextTick } from '../util/index'
Vue.nextTick = nextTick
(3). Vue.set
Vue.set与vm.$set的实现原理相同,代码如下:
import { set } from '../observer/index'
Vue.set = set
(4). Vue.delete
Vue.delete与vm.$delete的实现原理相同,代码如下:
import { del } from '../observer/index'
Vue.delete = del
(5). Vue.directive
注册或获取全局指令,代码如下:
// 用于保存指令的位置
Vue.options = Object.create(null)
Vue.options['directives'] = Object.create(null)
Vue.directive = function (id, definition) {
// 如果definition参数不存在 则使用id从this.options['direactives']中读出指令并返回
if (!definition) {
return this.options['directives'][id]
} else {// definition参数存在
// 如果definition是函数 则默认监听bind和update两个方法
// 将definition分别赋值给对象中的bind和uodate两个方法
if (typeof definition === 'function') {
definition = { bind: definition, update: definition }
}
this.options['directives'][id] = definition
return definition
}
}
(6) Vue.filter
注册或获取全局过滤器,代码如下:
Vue.options['filter'] = Object.create(null)
Vue.filter = function (id, definition) {
// 如果definition参数不存在 则使用id从this.options['filter']中读出指令并返回
if (!definition) {
return this.options['filter'][id]
} else {
this.options['filter'][id] = definition
return definition
}
}
(7) Vue.component
注册或获取全局组件。注册组件时,会自动使用给定的id设置组件的名称,使用方法如下:
// 注册组件 传入一个扩展过的构造器
Vue.component('my-component', Vue.extend({/* ... */}))
// 注册组件 传入一个选项对象(自动调用Vue.extend)
Vue.component('my-component', {/* ... */})
// 获取注册的组件(返回构造器)
let MyComponent = Vue.component('my-component')
Vue.component实现代码:
Vue.options['components'] = Object.create(null)
Vue.component = function (id, definition) {
if (!definition) {
return this.options['components'][id]
} else {
if (isPlainObject(definition)) {
definition.name = definition.name || id
definition = Vue.extend(definition)
}
this.options['component'][id] = definition
return definition
}
}
(7) Vue.use
Vue.use是用来安装Vue.js插件的。如果插件是一个对象,必须提供install方法。如果插件是一个函数,它会被作为install方法。调用install方法时,会将Vue作为参数传入。install方法被同一个插件多次调用时,插件也只会被安装一次。
实现代码如下:
Vue.use = function (plugin) {
const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
// 判断插件是否被注册过
if (installedPlugins.indexOf(plugin) > -1) {
return this
}
// 其他参数
const args = toArray(arguments, 1)
args.unshift(this)
if (typeof plugin.install === 'function') {
plugin.install.apply(plugin, args)
} else if (typeof plugin === 'function') {
plugin.apply(null, plugin)
}
installedPlugins.push(plugin)
return this
}
(8) Vue.mixin
全局注册一个混入(mixin),影响注册之后创建的每个Vue.js实例。可以使用混入向组件注入自定义行为(例如:监听声明周期钩子)。实现代码如下:
import { mergeOptions } from '../util/index'
export function initMixin (Vue) {
Vue.mixin = function (mixin) {
// 将传入的对象与Vue.js自身的options属性合并在一起
this.options = mergeOptions(this.options, mixin)
return this
}
}
好了,清明小长假的学习分享就先到这了