Vue.js源码剖析-响应式原理会分为上中下三部分 —— (上)成员初始化及首次渲染
可以参考我 Fork 的源码,相较官网增加了一些额外的代码批注以及部分官方注释的翻译,文章未展示到的代码可以自己深入来看,另外用到案例也可以在其中获取
src
├─compiler 编译相关
├─core Vue 核心库(与平台无关的代码)
│ components 定义 vue 自带的 keep-alive 组件
│ global-api 定义 vue 静态方法
│ instance 创建 vue 实例(构造函数、初始化、生命周期函数)
│ observer 响应式机制实现(本章重点)
│ util 公共成员
│ vdom 虚拟dom
├─platforms 平台相关代码
│ web
│ weex 基于vue移动端框架
├─server SSR,服务端渲染
├─sfc 单文件组件(.vue 文件编译为 js 对象)
└─shared 公共的代码
我们可以看到,Vue 在开发的时候首先会按照功能把代码拆分到不同的文件夹,然后再拆分成小的模块,这样的代码结构清楚,可以提高其可读性和可维护性。
打包
安装依赖
npm i
设置 sourcemap
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:webfull-dev"
dist
目录删除,以便看得更清楚npm run dev
执行打包,用的是 rollup,-w 参数是监听文件的变化,文件变化自动重新打包;-c 是设置配置文;最后一个参数是配置环境变量,从而来打包生成不同版本的 vue
在这里,我初次从 github 将 vue 源代码 clone 下来,npm i
安装依赖之后执行 npm run dev
打包报出如上错误,而当我们查看文件我们发现并没有创建 dist 目录,这个错误大致的意思是找不到你的某个文件(文件不一定,之前遇到过core/index文件找不到)
我猜想这是因为 rollup 打包使用 rollup-plugin-alias 来处理一些常用的公共路径。但是在 win 环境下,这个别名的解析好像工作不正常,文件缺少了.js后缀导致识别不到文件,最简单的方式是下载 此版本的 rollup-plugin-alias 并覆盖原文件,具体操作过程如下:
npm i
安装依赖npm run build
编译src/index.js
与 dist/rollup-plugin-alias.es2015.js
与 dist/rollup-plugin-alias.js
文件替换你从 vue 官网克隆下来的代码里 node_modules/rollup-plugin-alias
中的src
和 dist
内的内容nom run dev
打包vue源码,如下图所示就证明打包成功了
此时 dist 目录重新生成内容如下,新增了 vue.js 和 vue.js.map
至于其他版本的js文件我们后续可以通过调用 npm run build
得到
调试
npm run build
重新打包所有文件UMD | CommonJS | ES Module | |
---|---|---|---|
Full | vue.js | vue.common.js | vue.esm.js |
Runtime-only | vue.runtime.js | vue.runtime.common.js | vue.runtime.esm.js |
Full (production) | vue.min.js | ||
Runtime-only (production) | vue.runtime.min.js |
术语
Runtime + Compiler vs. Runtime-only
//
// Compiler
// 需要编译器,把 template 转换成 render 函数
const vm = new Vue({
el: '#app',
template: '{
{ msg }}
',
data: {
msg: 'Hello Vue'
}
})
//
// Runtime
// 不需要编译器
const vm = new Vue({
el: '#app',
// template: '{
{ msg }}
',
render (h) {
return h('h1', this.msg)
},
data: {
msg: 'Hello Vue'
}
})
vue.runtime.esm.js
// src/main.js
import Vue from 'vue'
vue inspect > output.js
// output.js
resolve: {
alias: {
...
vue$: 'vue/dist/vue.runtime.esm.js'
}
}
*.vue
文件中的模板是在构建时预编译的,最终打包后的结果不需要编译器,只需要运行时版本即可执行构建
npm run dev
# "dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
# --environment TARGET:web-full-dev 设置环境变量 TARGET
script/config.js
的执行过程(文件末尾)
// 判断环境变量是否有 TARGET
// 如果有的话 使用 genConfig() 生成 rollup 配置文件
if (process.env.TARGET) {
module.exports = genConfig(process.env.TARGET)
} else {
// 否则获取全部配置
exports.getBuild = genConfig
exports.getAllBuilds = () => Object.keys(builds).map(genConfig)
}
const opts = builds[name]
const builds = {
...
// Runtime+compiler development build (Browser)
'web-full-dev': {
// 入口
entry: resolve('web/entry-runtime-with-compiler.js'),
dest: resolve('dist/vue.js'),
format: 'umd',
env: 'development',
alias: {
he: './entity-decoder' },
banner
},
}
const aliases = require('./alias')
const resolve = p => {
// 根据路径中的前半部分去alias中找别名
const base = p.split('/')[0]
if (aliases[base]) {
return path.resolve(aliases[base], p.slice(base.length + 1))
} else {
return path.resolve(__dirname, '../', p)
}
}
// scripts/alias
const path = require('path')
const resolve = p => path.resolve(__dirname, '../', p)
module.exports = {
...
web: resolve('src/platforms/web'),
}
结果
观察以下代码,通过阅读源码,回答在页面上输出的结果
const vm = new Vue({
el: '#app',
template: 'Hello Template
',
render(h) {
return h('h1', 'Hello Render')
}
})
// 保留 Vue 实例的 $mount 方法
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
// 非ssr情况下为 false,ssr 时候为true
hydrating?: boolean
): Component {
// 获取 el 对象
el = el && query(el)
/* istanbul ignore if */
// el 不能是 body 或者 html
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to or - mount to normal elements instead.`
)
return this
}
const options = this.$options
// resolve template/el and convert to render function
// 把 template/el 转换成 render 函数
if (!options.render) {
...
}
// 调用 mount 方法,渲染 DOM
return mount.call(this, el, hydrating)
}
抛出问题:$mount 是谁调用的? 又是在什么位置?
注意:如果你最后执行了 npm run build
操作,disy/vue.js 中的最后一行的 sourceMap 映射 //# sourceMappingURL=vue.js.map
会被清除,所以如果想在调试过程看到 src 源码,需要重新 npm run dev
开启代码地图。
examples/02-debug/index.html
在调用堆栈位置,我们可以看到方法调用的过程,当前执行的是 Vue.$mount
方法,再往下可以看到 Vue._init
从而我们得知:$mount
是 _init()
调用的
同时也验证了开始的答案:如果 new Vue
同时设置了 template
和 render()
,此时只会执行 render()
Vue 的构造函数在哪?
Vue 实例的成员 / Vue 的静态成员 从哪里来的?
Vue 的构造函数在哪里
import Vue from './runtime/index'
import config from 'core/config'
...
// install platform runtime directives & components
// 设置平台相关的指令和组件(运行时)
// extend() 将第二个参数对象成员 拷贝到 第一个参数对象中去
// 指令 v-model、v-show
extend(Vue.options.directives, platformDirectives)
// 组件 transition、transition-group
extend(Vue.options.components, platformComponents)
// install platform patch function
// 设置平台相关的 __patch__ 方法 (虚拟DOM 转换成 真实DOM)
// 判断是否是浏览器环境(是 - 直接返回, 非 - 空函数 noop
Vue.prototype.__patch__ = inBrowser ? patch : noop
// public mount method
// 设置 $mount 方法,挂载 DOM
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
import Vue from 'core/index'
initGlobalAPI(Vue)
import Vue from './instance/index'
// 此处不用 class 的原因是因为方便后续给 Vue 实例混入实例成员
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')
}
// 调用 _init() 方法
this._init(options)
}
// 注册 vm 的 _init() 方法,初始化 vm
initMixin(Vue)
// 注册 vm 的 $data/$props/$set/$delete/$watch
stateMixin(Vue)
// 初始化事件相关方法
// $on/$once/$off/$emit
eventsMixin(Vue)
// 初始化生命周期相关的混入方法
// _update/$forceUpdate/$destroy
lifecycleMixin(Vue)
// 混入 render
// $nextTick/_render
renderMixin(Vue)
export default Vue
通过 src/core/index.js 的 initGlobalAPI(Vue)
来到 初始化 Vue 的静态方法 所在文件
import {
initGlobalAPI } from './global-api/index'
...
// 注册 Vue 的静态属性/方法
initGlobalAPI(Vue)
export function initGlobalAPI (Vue: GlobalAPI) {
...
// 初始化 Vue.config 对象
Object.defineProperty(Vue, 'config', configDef)
// exposed util methods.
// NOTE: these are not considered part of the public API - avoid relying on
// them unless you are aware of the risk.
// 这些工具方法不视作全局API的一部分,除非你已经意识到某些风险,否则不要去依赖他们
Vue.util = {
warn,
extend,
mergeOptions,
defineReactive
}
// 静态方法 set/delete/nextTick
Vue.set = set
Vue.delete = del
Vue.nextTick = nextTick
// 2.6 explicit observable API
// 让一个对象可响应
Vue.observable = <T>(obj: T): T => {
observe(obj)
return obj
}
// 初始化 Vue.options 对象,并给其扩展
// components/directives/filters
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})
// this is used to identify the "base" constructor to extend all plain-object
// components with in Weex's multi-instance scenarios.
// 这是用来标识 "base "构造函数,在Weex的多实例方案中,用它来扩展所有普通对象组件
Vue.options._base = Vue
// 设置 keep-alive 组件
extend(Vue.options.components, builtInComponents)
// 注册 Vue.use() 用来注册插件
initUse(Vue)
// 注册 Vue.mixin() 实现混入
initMixin(Vue)
// 注册 Vue.extend() 基于传入的options返回一个组件的构造函数
initExtend(Vue)
// 注册 Vue.directive()、Vue.component()、Vue.filter()
initAssetRegisters(Vue)
}
// 此处不用 class 的原因是因为方便后续给 Vue 实例混入实例成员
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')
}
// 调用 _init() 方法
this._init(options)
}
// 注册 vm 的 _init() 方法,初始化 vm
initMixin(Vue)
// 注册 vm 的 $data/$props/$set/$delete/$watch
stateMixin(Vue)
// 初始化事件相关方法
// $on/$once/$off/$emit
eventsMixin(Vue)
// 初始化生命周期相关的混入方法
// _update/$forceUpdate/$destroy
lifecycleMixin(Vue)
// 混入 render
// $nextTick/_render
renderMixin(Vue)
export function initMixin (Vue: Class<Component>) {
// 给 Vue 实例增加 _init() 方法
// 合并 options / 初始化操作
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++
let startTag, endTag
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
startTag = `vue-perf-start:${
vm._uid}`
endTag = `vue-perf-end:${
vm._uid}`
mark(startTag)
}
// a flag to avoid this being observed
// 如果是 Vue 实例不需要被 observe
vm._isVue = true
// merge options
// 合并 options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
// 优化内部组件实例化,因为动态选项合并非常慢,而且内部组件选项都不需要特殊处理。
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {
},
vm
)
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
// expose real self
vm._self = vm
// vm 的生命周期相关变量初始化
// $children/$parent/$root/$refs
initLifecycle(vm)
// vm 的事件监听初始化, 父组件绑定在当前组件上的事件
initEvents(vm)
// vm 的编译render初始化
// $slots/$scopedSlots/_c/$createElement/$attrs/$listeners
initRender(vm)
// beforeCreate 生命钩子的回调
callHook(vm, 'beforeCreate')
// 把 inject 的成员注入到 vm 上
initInjections(vm) // resolve injections before data/props
// 初始化 vm 的 _props/methods/_data/computed/watch
initState(vm)
// 初始化 provide
initProvide(vm) // resolve provide after data/props
// created 生命钩子的回调
callHook(vm, 'created')
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
vm._name = formatComponentName(vm, false)
mark(endTag)
measure(`vue ${
vm._name} init`, startTag, endTag)
}
// 调用 $mount() 挂载
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
// 将props成员转换成响应式数据,并注入到vue实例
if (opts.props) initProps(vm, opts.props)
// 初始化选项中的方法(methods)
if (opts.methods) initMethods(vm, opts.methods)
// 数据的初始化
if (opts.data) {
// 把data中的成员注入到Vue实例 并转换为响应式对象
initData(vm)
} else {
// observe数据的响应式处理
observe(vm._data = {
}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
设置断点
开始调试
core/instance/index.js
,它是与平台无关的,在这里调用了Mixin的一些函数,这些函数里面给Vue的原型上增加了一些实例成员core/index.js
,这个文件中执行了 initGlobalAPI(),给Vue的构造函数初始化了静态成员../config
中导入的configplatforms/web/runtime/index.js
,此时我们看到的代码都是与平台相关的,它首先给Vue.config中注册了一些与平台相关的一些公共的方法,当它执行完过后 又注册了几个与平台相关的指令和组件platforms/web/runtime/entry-runtime-with-compiler.js
的断点,这个文件重写了$mount,新增了把模板编译成render函数的功能开始调试
F11进入_init(),来到initMixin(),F10来到合并options的位置判断是否为组件,当前为创建Vue实例,并不是组件,进入else
F11 进入initProxy,函数中先判断当前浏览器是否支持Proxy代理对象,如果支持,通过Proxy代理Vue实例,如果不支持代理对象,直接将Vue实例设置给_renderProxy
F10执行完毕,继续往下执行一些init,给Vue实例挂载一些成员,先不去调试,我们将断点设置到后面$mount处,F8执行到断点处
F11进入函数getOuterHTML(),函数内部判断是否有outerHTML属性,有的话直接作为模板返回,没有的话说明此时的el不是DOM元素(文本节点/注释节点),此时会创建一个div,把el克隆一份添加到container中,最终将innerHTML返回作为模板
F10最终执行mount方法,此处的mount()是在platforms/web/runtime/index.js
中定义的$mount,我们在入口文件重写了$mount
F11进入,此文件会重新获取el(当执行带编译器的Vue,已经获取过el,但是如果此时执行的是运行版本的Vue就不会执行)
F10首先判断选项中是否有render函数,如果有且此时为运行时版本则会警告运行时版本不支持编译器
,此时为带编译器版本,未手动传入render,但是编译器已经帮我们编译过了
下面调用一个生命周期的beforeMount钩子函数,F10接下来挂载(更新组件),updateComponent将虚拟DOM传递给_update()转化为真实DOM
F10此函数执行完,我们就可以看到将模板渲染到界面上,但是此时只是定义,并未执行。接下来创建Watcher对象,并传递进来updateComponent,其执行是在Watcher()中调用的,我们在此处设置一个断点
F11进入断点,看Watcher具体做了哪些事情,构造函数内传入了几个参数:第一个参数是Vue的实例,第二个参数是updateComponent(可以是字符串/函数,此处传入的为函数),Vue中的Watcher有三种:1.渲染Watcher(当前),2.计算属性Watcher,3.侦听器Watcher,最后一个参数isRenderWatcher是否是渲染Watcher,此处为true
构造函数内部还定义了许多属性,此处的this.lazy = !!options.lazy
为延时执行的意思,因为Watcher要更新视图,lazy意思是是否延迟更新视图,而我们当前是首次渲染,我们要立即更新,所以此处指为false,而如果此处为计算属性Wather,它会延迟执行,因为在计算属性中,当数据变化之后才去更新视图
F10接下来要判断expOrFn,也就是钩子函数中的第二个参数,它是function或者string,如果是function直接把变量赋给getter,如果是string需要进一步处理(创建侦听器再说)。当前getter中存储的是updateComponent也就是首次渲染时的值
F10接下来要给this.value赋值,它会首先判断this.lazy,如果当前lazy的值是false也就是不延迟执行的话,会立即执行this.get方法
F11来查看get做了哪些事情,首先pushTarget把当前的Watcher对象存入栈中(每一个组件都会对应一个Watcher,Watcher会去渲染视图,如果组件有嵌套会先渲染内部的组件,所以要把父组件对应的Watcher保存)
F10接下里调用了刚刚存储的getter(也就是updateComponent),因此在get内部调用了updateComponent,并且改变了函数内部this的指向,指向Vue的实例并传入vm。最终我们找到了调用updateComponent的位置
以上就是首次渲染的一个过程