Vue源码阅读笔记之项目结构和Vue对象(一)
vue
是我接触到的第一个MVVM框架,在工作中使用也比较频繁。早前曾尝试过阅读源码,奈何功力不够,草草了事,收获的东西有限,现在决定重新阅读Vue及相关技术栈(vue-router
,vuex
,axios
等)源码,并整理相关知识。除了加深对于Vue的理解之外,还希望能够提升阅读源码的能力~
此次阅读的是vue最新的版本是2.5.9
,后续版本变更可能会导致摘取的相关代码不一致~
参考:
预备知识
平台差异
由于Vue有多个运行平台,包括浏览器、node环境(SSR渲染)、Weex等,因此在源码中有一些平台差异性的代码。这次阅读源码的首要目的是浏览器端的代码,然后了解SSR渲染的原理和实现。
开发环境搭建
运行开发者模式
为了进行调试我们需要将项目跑起来,整个环境的搭建十分简单
- 把项目fork并克隆到本地,
npm i
然后npm run dev
进入开发者模式,会监听文件的变化然后修改/dist/
目录下的输出- 新建一个
index.html
文件然后引入源码文件/dist/vue.js
- 修改
/src/core/index.js
代码,添加一个console.log("hello vue")
- 即可在浏览器控制台发现对应的输出,OK,现在可以愉快的进行阅读和调试了
打开package.json
文件,可以发现还有一些其他的开发指令,针对不同的开发者模式,诸如dev:weex
等,这里暂时先不研究了。
PS:由于加载的是编译后的文件,因此无法在源文件中使用webstrom与chrome的断点工具进行调试,我采用的是手动添加debugger
关键字断点。还希望有其他好的调试方法的朋友指点一下。
flow
flow官网上的介绍是
Flow is a static type checker for your JavaScript code. It does a lot of work to make you more productive. Making you code faster, smarter, more confidently, and to a bigger scale.
由于源码中均使用flow作为类型检测,因此需要掌握一些基本语法,避免阅读障碍。
知乎上尤大亲自回答了这个问题。貌似vue最近添加了对于TypeScript
的支持~
rollup
rollup是一个JS模块打包工具,与webpack类似。
这里倒是不需要深入其工作原理,这里是rollup.js教程传送门。
babel和eslint
在修改源码时记得遵循eslint规范,或者直接修改其配置~
项目结构
记得大佬跟我说过,阅读源码时先理解作者的设计思想,了解整体结构,切勿钻进某个细节实现。
从npm run dev
这个命令入手,我们一步一步追踪代码的编译过程
"dev": "rollup -w -c build/config.js --environment TARGET:web-full-dev",
找到build/config.js
,这是rollup的配置文件,对应的环境变量为TARGET:web-full-dev
'web-runtime-dev': {
entry: resolve('web/entry-runtime.js'),
dest: resolve('dist/vue.runtime.js'),
format: 'umd',
env: 'development',
banner
},
这里resolve
函数为路径设置了别名,找到了项目的入口文件web/entry-runtime-with-compiler.js
,实际上是/src/platforms/web/entry-runtime-with-compiler.js
import Vue from './runtime/index'
一步一步追踪
import Vue from 'core/index'
同理,这里的路径别名是/src/core/index.js
,距离真相越来越近了
import Vue from './instance/index'
最后,我们终于找到了Vue构造函数
function Vue (options) {
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
下面几个方法的作用是为Vue.prototype
上添加原型属性和方法,最后导出了Vue构造函数,在构造函数内部调用了Vue.prototype._init
方法。
现在回头看看上面几个文件的作用
/src/core/instance/index.js
,向Vue.prototype
添加属性和方法/src/core/index.js
,通过initGlobalAPI(Vue)
向Vue添加静态属性和方法/src/platforms/web/runtime/index.js
,添加了一些平台的特性方法及Vue.config
/src/platforms/web/entry-runtime-with-compiler.js
,覆盖了Vue.prototype.$mount
和Vue.compile
至此,我们了解了项目的大致结构,通过模块文件组织整个项目,Vue的源码是十分整洁的。接下来再逐步深入每个模块的细节,首先来看看Vue实例对象的属性和方法是如何挂载。
Vue原型及对象
有趣的是Vue是通过将构造函数作为参数传入不同模块函数,从而实现在原型上挂载属性和方法。
对着前面的路径图,发掘Vue原型上的属性和方法
// /src/core/instance/index.js
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)
// Vue.prototype._init
stateMixin(Vue)
// Object.defineProperty(Vue.prototype, '$data', dataDef)
// Object.defineProperty(Vue.prototype, '$props', propsDef)
// Vue.prototype.$set
// Vue.prototype.$delete
// Vue.prototype.$watch
eventsMixin(Vue)
// Vue.prototype.$on
// Vue.prototype.$once
// Vue.prototype.$off
// Vue.prototype.$emit
lifecycleMixin(Vue)
// Vue.prototype._update
// Vue.prototype.$forceUpdate
// Vue.prototype.$destroy
renderMixin(Vue)
// Vue.prototype.$nextTick
// Vue.prototype._render
然后是Vue构造函数的全局属性和方法,这是通过initGlobalAPI(Vue)
注册的
// /src/core/global-api/index.js
Object.defineProperty(Vue, 'config', configDef)
Vue.util
Vue.set
Vue.delete
Vue.nextTick
Vue.options
// ASSET_TYPES = [
// 'component',
// 'directive',
// 'filter'
// ]
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})
Vue.options._base
extend(Vue.options.components, builtInComponents)
initUse(Vue)
// Vue.use
initMixin(Vue)
// Vue.mixin
initExtend(Vue)
// Vue.extend
initAssetRegisters(Vue)
// Vue[ASSET_TYPES[i]] 对应Vue.component, Vue.directive, Vue.filter
然后根据不同的运行环境,添加一些具有平台特性的属性的方法
// /src/platforms/web/runtime/index.js
Vue.config.mustUseProp
Vue.config.isReservedTag
Vue.config.isReservedAttr
Vue.config.getTagNamespace
Vue.config.isUnknownElement
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)
Vue.prototype.__patch__
Vue.prototype.$mount
快到终点啦
// /src/platform/web/entry-runtime-with-compiler.js
// 对mount方法进一步扩展
Vue.prototype.$mount
自此,我们顺着各个模块,从构造函数开始,基本理清了Vue及其原型相关的属性和方法。这里强烈建议先去官网API文档了解相关的属性方法的含义。
Vue实例
大致了解了Vue原型之后,接下来看看vue实例的属性和方法。
在构造函数中,我们发现调用了this._init(options)
方法,即Vue.prototype._init
方法,我们现在开始深入这个方法。
合并配置参数
首先遇见的第一个问题就是合并配置参数
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor), // 实际上返回Vue.options
options || {}, // 我们传入的配置参数
vm
)
这个mergeOptions
的工具函数就是用来合并配置参数并将结果挂载到vm.$options
上的,非常重要。参看文档,Vue允许我们自定义合并策略的选项。所谓自定义策略就是允许我们自己决定如何合并Vue.options和传入的这两个配置参数。
先来看看源码
// /src/core/util/options.js
const strats = config.optionMergeStrategies
// strats的每个属性都是配置对象上相关键值的合并策略
strats.el = strats.propsData = function (parent, child, vm, key) {}
strats.data = function (parentVal: any, childVal: any, vm?: Component): ?Function {}
// 合并相关的声明周期钩子函数
LIFECYCLE_HOOKS.forEach(hook => {
strats[hook] = mergeHook
})
// 合并component、directive和filter
ASSET_TYPES.forEach(function (type) {
strats[type + 's'] = mergeAssets
})
// 合并watch,
// 这里注释提到Watchers hashes should not overwrite one,so we merge them as arrays.
strats.watch = function (parent, child, vm, key) {}
strats.props = strats.methods = strats.inject = strats.computed = function (parent, child, vm, key) {}
strats.provide = mergeDataOrFn
// 默认的合并策略,如果传入的配置参数存在,则直接返回,否则返回vue.options
const defaultStrat = function (parentVal: any, childVal: any): any {
return childVal === undefined
? parentVal
: childVal
}
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object { const options = {}
// ....
for (key in parent) {
mergeField(key)
}
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
// 这里就是调用相关的合并策略,然后合并字段
function mergeField (key) {
const strat = strats[key] || defaultStrat
options[key] = strat(parent[key], child[key], vm, key)
}
return options
}
策略对象内置了基本属性的合并策略,此外我们也可以通过Vue.config.optionMergeStrategies
实现自定义属性的合并策略。上面策略对象的相关属性想必大家都不陌生,不论是在组件还是在实例的构建中,上面的参数都或多或少有使用过。
配置参数的合并结果最终会以vm.$options = options
的形式关联到vm实例上,至此我们对于配置参数是如何绑定到vm实例上的应该有了一个大致的印象。
那么,为什么要通过这么绕的方式来合并配置参数呢?往常用过的插件中,往往只是提供一个默认的配置参数,然后通过传入的配置参数简单覆盖即可?
我的理解是:由于Vue允许使用自定义属性,这样可以方便扩展~对应文档中的使用方法,可以精准到为每个字段设置不同的合并策略,下面是文档中的一个demo
Vue.config.optionMergeStrategies._my_option = function (parent, child, vm) {
return child + 1
}
const Profile = Vue.extend({
_my_option: 1
})
// Profile.options._my_option = 2
实例属性和方法
现在我们重新回到Vue.prototype._init
这个方法中,实际上整个构造函数就是为实例添加相关的属性,把代码简化之后是下面的形式,追踪到每个初始化工具函数中
// /src/core/instance/init.js
const vm = this
vm._uid = uid++
vm._isVue = true
// merge options
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
initProxy(vm)
// vm._renderProxy = new Proxy(vm, handlers)
vm._self = vm
initLifecycle(vm)
// vm.$parent = parent
// vm.$root = parent ? parent.$root : vm
//
// vm.$children = []
// vm.$refs = {}
//
// vm._watcher = null
// vm._inactive = null
// vm._directInactive = false
// vm._isMounted = false
// vm._isDestroyed = false
// vm._isBeingDestroyed = false
initEvents(vm)
// vm._events = Object.create(null)
// vm._hasHookEvent = false
initRender(vm)
// vm._vnode = null
// vm._staticTrees
// vm.$slots
// vm.$scopedSlots
// vm._c
// vm.$createElement
// defineReactive
// 调用beforeCreate钩子函数
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
// defineReactive
initState(vm)
// vm._watchers
// initProps
// initMethods
// initData
// initComputed
// initWatch
initProvide(vm) // resolve provide after data/props
// vm._provided
// 调用created钩子函数
callHook(vm, 'created')
// 将vm实例挂载到el上,进行渲染
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
至此,我们了解到了vm实例上面相关属性的来源。
其中,在initState
方法中,会根据vm.$options
的相关属性,执行下面 的方法
// /src/core/instance/state.js
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
事实上,在最简单的Hello World
Demo中,上面的不少工作都会被跳过
<div id="app">
{{ msg }}
</div>
<script>
let vm = new Vue({
el: "#app",
data: {
msg: "Hello World"
},
})
</script>
只需要简单的改变vm.$data.msg
的值,就可以观察到Vue的响应式工作了,查看框架的工作流程可以发现这里调用的是initData(vm)
方法,相关的分析在下一章进行。
小结
虽然在工作中一直在使用Vue,却对于内部的运行机制不是很了解,这加上Vue的入门比较简单,让我有种难以言喻的危机感。
网上现在有不少关于Vue源码分析的教程,有的写得挺不错的,但终归是别人总结的,如果没有真正阅读代码,肯定会漏掉一些东西,加上Vue的版本迭代也比较快,因此决定自己尝试进行源码分析,水平有限,慢慢来折腾吧~
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。