概览
课程目标
- Vue.js的静态成员和实例成员的初始化过程
- 首次渲染过程
- 数据响应式原理
Vue源码文件结构
Vue按照功能将代码拆分到了不同的文件夹,再拆分成小的模块,提高了可读性和可维护性。Vue的虚拟DOM重写了Snabbdom,增加了组件相关机制。
- compiler:编译相关代码,将Vue中的模板转换成render函数,创建虚拟DOM。
- core:Vue核心库,定义了一些组件(例如keep-alive)、静态方法、Vue构造、初始化、声明周期函数、响应式机制函数、虚拟DOM函数。
- platforms:平台相关代码,web、weex,里面的entry开头文件都是打包时的入口文件。
- server:ssr、服务端渲染相关代码
- sfc:single file component单文件组件,.vue文件编译为js对象
- shared:公共的代码
Vue2.0的类型检查是使用的Flow,3.0版本已经使用TS,我们不必太去研究Flow,它们都是JS超集,最终都会编译成JS。
准备工作
调试设置
打包
Vue的打包工具是Rollup:
- Vue.js 源码的打包工具使用的是 Rollup,比 Webpack 轻量。
- Webpack 把所有文件当做模块,Rollup 只处理 js 文件,更适合在 Vue.js 这样的库中使用。
- Rollup 打包不会生成冗余的代码,Webpack打包时会生成浏览器端支持的模块化代码。
这两打包器有各自的使用场景。
调试
在rollup的打包命令中添加 sourcemap 参数,方便断点调试:
"scripts": {
"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"
}
Vue不同的构建版本
可以使用npm run build命令重新打包所有文件。可通过文档查看具体的版本差异。
完整版
同时包含编译器和运行时的版本。
编译器
用来将模板字符串编译成为 JavaScript 渲染函数的代码。
运行时
用来创建 Vue 实例、渲染并处理虚拟 DOM 等的代码。基本上就是除去编译器的其它一切。runtime版本是不包含编译器的,所以不能通过Template方式去写html,而要自己以render函数形式去写html结构。
正式开始
寻找入口文件
顺着脚本命令能找到对应执行的脚本文件,从脚本文件分析就能看到 Vue 的 Rollup 配置文件是如何动态化生成的,并且依此能找到入口文件对应位置。
从入口开始
先通过查看源码来解决一个问题,当同时设置template和render时,会执行谁?渲染谁?
mount源码:
// $mount是把生成的DOM挂载到页面上来
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
/* istanbul ignore if */
// 判断el是否是body或html
if (el === document.body || el === document.documentElement) {
// 非生产环境则会进行警告,Vue只能挂载到普通元素上
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
// 并直接返回当前实例
return this
}
// 获取创建Vue实例时,传入的所有属性
const options = this.$options
// resolve template/el and convert to render function
// 判断是否有render选项
if (!options.render) {
// 如果未传递render,则会执行相关逻辑,后面详细看。整个过程是将template转换成render函数
let template = options.template
// 如果模板存在
if (template) {
// 如果模板是字符串
if (typeof template === 'string') {
// 如果模板是id选择器
if (template.charAt(0) === '#') {
// 获取对应DOM对象的 innerHTML
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile')
}
// 生成render函数
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
}
// 如果传了render函数,则直接执行mount
return mount.call(this, el, hydrating)
}
query函数:
/**
* Query an element selector if it's not an element already.
*/
export function query (el: string | Element): Element {
if (typeof el === 'string') {
const selected = document.querySelector(el)
if (!selected) {
// 如果找不到根元素,在非生产环境下会报出警告
process.env.NODE_ENV !== 'production' && warn(
'Cannot find element: ' + el
)
// 默认创建一个div标签返回
return document.createElement('div')
}
return selected
} else {
return el
}
}
通过查看源码,我们发现:
- el 不能是 body 或 html 标签。
- 如果没有 render,则会把 template 转换成 render 函数。
- 如果有 render 方法,直接调用 mount 挂载 DOM。
在调用栈中能看到 $mount 方法是在哪里被调用的,Vue的构造函数是在哪被调用的。
Vue初始化的过程
## entry-runtime-with-compiler.js
该入口文件对mount方法进行了增强,判断是否传入render方法
该入口文件增加了实例方法,Vue.compile = compileToFunctions。
它用于把html字符串编译成render函数。
该入口文件的核心是增加了将 template 转换成 render 函数的代码。
静态成员的初始化
直接上源码解析:
/* @flow */
import config from '../config'
import { initUse } from './use'
import { initMixin } from './mixin'
import { initExtend } from './extend'
import { initAssetRegisters } from './assets'
import { set, del } from '../observer/index'
import { ASSET_TYPES } from 'shared/constants'
import builtInComponents from '../components/index'
import { observe } from 'core/observer/index'
import {
warn,
extend,
nextTick,
mergeOptions,
defineReactive
} from '../util/index'
export function initGlobalAPI (Vue: GlobalAPI) {
// config
const configDef = {}
configDef.get = () => config
if (process.env.NODE_ENV !== 'production') {
// 如果是非生产环境,则会添加一个set方法,警告开发人员,不要去给config对象重新赋值
configDef.set = () => {
warn(
'Do not replace the Vue.config object, set individual fields instead.'
)
}
}
// 初始化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
}
// 定义静态方法
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.
// 记录当前的Vue构造函数
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)
}
同时在看设置Vue构造函数上的config时,在思考为啥没设置set方法,却依然能给它的属性做修改,并自己写了个demo:
testConfigDef.get = () => {
return config
}
testConfigDef.get = () => {
return {a: 1}
}
最后发现了原因,直接设置对象就不能修改,得用内存地址引用的方式,才能进行修改。
实例成员的初始化
在instance/index.js中能看到初始化实例成员的过程:
// 以下函数的作用都是给Vue的原型上,混入一些成员方法
// 注册 vm 的 _init() 方法,初始化 vm,相当于整个Vue的入口
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)
接下来就分析initMixin函数的执行过程:
initMixin
export function initMixin (Vue: Class<Component>) {
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
// 标识vm对象为Vue实例,避免被observe,响应处理
vm._isVue = true
// merge options
// 合并options,将用户传入的options和Vue构造函数中的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 */
// 初始化renderProxy
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初始化,主要初始化h函数
// $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)
// 初始化 Vue实例的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)
}
}
}
调试Vue初始化过程
下面以一张图来描述Vue首次渲染的过程:
对于有编译器的Vue版本编译,这里会发现有两个mount包装一下,加入编译器,用于对模板生成render函数。
然后再调用保存的$mount方法,在这个方法里触发了一些钩子函数,并且定义updateComponent,它内部是用于将虚拟DOM转换成真实DOM。
并且这里会创建一个渲染Watcher,在这里将其lazy属性设置为了false,所以它会立刻执行get方法(),这个get方法中会运行updateComponent函数,此时就完成了页面的刷新,将虚拟DOM挂载到了页面上。然后会执行mounted钩子函数。
经过调试发现,这个get函数在渲染过程中执行后,返回的value是个undefined。那是不是说这个watcher不会再执行了?
然后首次的渲染过程到这就结束了,this._init执行完毕。
数据响应式原理
数据响应式和双向绑定机制,是数据驱动开发的基石。这里要通过看源码来解答以下问题:
- vm.msg = { count: 0 } ,重新给属性赋值,是否是响应式的?
- vm.arr[0] = 4 ,给数组元素赋值,视图是否会更新
- vm.arr.length = 0 ,修改数组的 length,视图是否会更新
- vm.arr.push(4) ,视图是否会更新
响应式处理的入口
首先看src\core\instance\init.js,在Vue构造函数的init方法中,调用了initState方法,它的作用是初始化Vue实例的状态。 还初始化了 _data、_props、methods 等。
在initState中又调用了initData方法,它的作用是将data中的属性注入到Vue实例,并将其转换为响应式对象。
// src\core\instance\state.js
if (opts.data) {
// 将data里的成员注入到Vue实例上,并把data对象转换成一个响应式对象
initData(vm)
} else {
// 没有data属性,则初始化一个空对象,并转换成响应式对象
observe(vm._data = {}, true /* asRootData */)
}
其中observe方法就是响应式的入口,它的地址为src/core/observer/index.js。observe方法会递归的为每一个对象转换为响应式对象,每一个响应式对象都会创建一个对应的 Observer 对象,记录到该对象的 __ob__ 属性中,并且每一个 Observer 对象都会有一个 dep 对象,负责为该对象收集依赖,当该对象发生变化时发送通知。
数据响应式原理-依赖收集
这个dep和watcher和我们之前实现的有些区别,在Vue的watcher中也存储了dep,它是为了处理一个细节,视频中没深入。
数据响应式原理-数组
数组的响应式处理过程的核心代码在Observer类的构造函数中。判断value是否是数组,是数组的话就给它添加提前定义好的arrayMethods原型。
核心代码如下:
// 是否支持__proto__属性,处理兼容性
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
// 直接将方法定义在数组对象上
copyAugment(value, arrayMethods, arrayKeys)
}
// 为数组中的每一个对象创建一个 observer 实例,方法内部会判断成员是否是对象
this.observeArray(value)
如果是数组,则会改写它的原型对象上的方法,这里还会进行__proto__兼容性处理。它将会改变数组的方法进行了重写:
-
- 先调用原方法获取结果。
- 针对push、unshift、splice方法,获取新增的元素,调用observe方法进行观察。
- 重写的方法中,加入了调用notify方法的逻辑,每次修改数组,发起通知。
- 最后调用
observeArray方法,为数组中的每一个对象创建一个 observer 实例。
被改写后的数组,它的属性如下,最下面的__proto__才是真正的原生数组原型:
从源码可看出,Vue未处理改变索引的值、改变数组长度时的响应式处理。Vue只处理了每一个元素,未处理每一个属性。可以用splice方法替代上述操作。
数据响应式原理-Watcher
Watcher分为三种:Computed Watcher、用户Watcher(侦听器)、渲染Watcher。
渲染Watcher初始化
Watcher的初始化在src/core/observer/watcher.js中进行了注释
Watcher的执行
和之前自己实现的观察者模式不同,Watcher实例中还有一个deps属性,它是用来干嘛的呢?首先明确,1个Dep可以被多个Watcher订阅,但是1个Watcher也可以订阅多个Dep,watcher使用它来记录所有订阅的Dep。
watcher执行过程,有一个queue的概念,它会根据watcher的id进行排序,它的目的如下:
- 组件更新顺序保证为父组件到子组件,因为先创建父组件再创建子组件
- 组件的用户watcher要在渲染watcher之前进行,因为用户watcher是在渲染watcher之前被创建的。用户watcher、计算属性watcher是在initState中创建的,在mountComponent中创建的渲染watcher。
- 如果一个组件在父组件运行期间被销毁,则跳过该watcher
渲染watcher的执行过程是:数据发生变化后,调用Dep的notify方法,根据watcher的id进行从小到大排序,再调用每个watcher的Update方法。在queueWatcher方法里,watcher会被放入队列中,然后依次调用它们的run方法,在run方法中调用了get方法,最终调用了updateComponent函数。
而对于渲染watcher,它不需要被notify,它会直接执行get方法。
调试-响应式数据执行过程
数组响应式处理的核心过程
constructor (value: any) {
this.value = value
this.dep = new Dep()
// 初始化实例的vmCount为0
this.vmCount = 0
// 将实例挂载到对象的__ob__属性
def(value, '__ob__', this)
// !!!!对数组做额外的响应式处理!!!!
if (Array.isArray(value)) {
// 判断当前浏览器是否支持proto,处理兼容性问题
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
// 为数组中的每一个对象创建一个observer实例
this.observeArray(value)
} else {
// 遍历对象中的每一个属性,转换成setter/getter
this.walk(value)
}
}
数组收集依赖的过程
// 下面部分为收集依赖
// 如果存在当前依赖目标,即 watcher 对象,则建立依赖
if (Dep.target) {
// 为该属性收集依赖
dep.depend()
if (childOb) {
// 这里的dep是为当前的这个子对象收集依赖,当子对象发生添加或删除操作时,就可以发送通知
childOb.dep.depend()
if (Array.isArray(value)) {
// 如果该属性为数组,则会寻找数组中为“对象”的元素,判断其有没有__ob__(observer对象)
// 如果有的话,则将当前的watcher添加到其dep中去,当数据变化时,通知它改变
dependArray(value)
}
}
}
数组的数据改变时,watcher执行的过程
根据源码可以发现Vue只对数组的key和数组对象本身收集了依赖,所以当数组的属性变化时,并不会触发notify更新。
经过调试发现,在Vue中对于渲染watcher的更新函数就是updateComponent,也就是patch函数,用于寻找新旧VNode之间的差异,并更新至真实DOM。用户watcher的更新函数就是用户所传入的了。
疑问:为什么要清空上一次的依赖呢?也就是watcher为什么要定义一个deps属性,来收集这个watcher所依赖的dep呢?
回答:
/**
* Depend on all deps collected by this watcher.
*/
// 这里存在多对多的关系,watcher也可以订阅多个dep,在这里会遍历deps中所有dep,
// 将本watcher实例通过Dep.target记录到,对应响应式数据的 dep 的 subs 数组中
depend () {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
所以当deps中,某个数据变化时,就会通知这个watcher。根据源码显示,貌似只有定义计算属性的时候,才会执行这个watcher.depend(),其实可以理解,只有计算属性的监听,才会同时监听多个dep吧。
根据实际测试,发现渲染watcher,也会监听多个watcher,表现为,页面直接依赖多少个数据,deps就会存多少个dep。
新的认知: 首次渲染时,传给 watcher 的 cb 就是 updateComponent,而Vue初始化时,给每个state(model)数据所绑定的watcher,回调函数都是 updateComponent,也就是在之后每次数据变化时,都会执行这个更新虚拟DOM->更新真实视图的方法。
响应式处理过程总结
此处数组响应式处理,是对会更改原数组的方法,做了处理,添加了notify方法
$set 源码
// global-api/index.js
// 构造函数中的放法,静态方法
Vue.set()
// instance/index.js
// 实例方法
vm.$set()
/**
* Set a property on an object. Adds the new property and
* triggers change notification if the property doesn't
* already exist.
*/
export function set (target: Array<any> | Object, key: any, val: any): any {
// 判断target是否未定义,是否为原始值
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}
// 如果target是数组,则判断索引是否为有效的索引
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
// 通过splice方法对key位置的元素进行替换
// splice 在 array.js 进行了响应化处理,有更新能通知视图变化
// 这里最终会调用target上ob对象的dep对象的notify方法
target.splice(key, 1, val)
return val
}
// 如果key在对象上已经存在,且不在原型上(防止用户赋值原型上的属性),则直接进行赋值
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
// 如果key在target上不存在,则继续执行,获取ob对象
const ob = (target: any).__ob__
// 如果 target 是Vue实例,或者为$data,则直接返回。这里$data的vmCount为1,其它的都为0,可在源码中观察到这点
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
}
// 如果不存在ob对象,则target不是响应式对象,则直接赋值。有什么情况会不存在ob对象呀
if (!ob) {
target[key] = val
return val
}
// 如果有ob对象,则将key设置为响应式属性
defineReactive(ob.value, key, val)
// 并且发送通知
ob.dep.notify()
return val
}
$delete源码
- 功能
删除对象的属性,如果对象是响应式的,确保删除能触发更新视图。这个方法主要用于避开Vue不能检测到属性被删除的限制,但是你应该很少会使用到它。
目标对象不能是一个 Vue 实例或 Vue 实例的根数据对象。
Vue.delete( target, propertyName/index )
如果删除对象是响应式的,则会调用dep.notify方法更新视图。
$ watch源码
- 功能
观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。
vm.$watch( expOrFn, callback, [options] )
- 参数:
-
- {string | Function} expOrFn
- {Function | Object} callback
- {Object} [options]
-
-
- {boolean} deep
- {boolean} immediate
-
- 返回值:{Function} unwatch
三种类型的Watcher对象
- 没有静态方法,因为$watch方法中要使用Vue的实例
- Watcher分三种:计算属性Watcher、用户Watcher(侦听器)、渲染Watcher
-
- 创建顺序:计算属性Watcher、用户Watcher(侦听器)、渲染Watcher
- vm.$wtach()
-
- src\core\instance\state.js
疑问:计算属性computed的源码
答:computed也是watcher的一种,传入了computedWatcherOptions配置,标记lazy为true。计算属性为lazy,是因为计算属性是在模板中被调用的,它是在render过程中,调用对应计算属性的方法的。
watcher如上顺序所调用,在flushScheduleQueue方法中,会对watcher进行排序。
源码部分
疑问:watcher.teardown()源码学习
异步更新队列-nextTick方法
- Vue更新DOM是异步执行的,批量的
-
- 在下次更新DOM更新循环结束之后,执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的DOM。
nextTick源码
该方法既有静态方法也有实例方法。它的核心在于timerFunc方法的处理。所有watcher的更新,是放在nextTick中去执行的。
它的调用可分为:
- 手动调用vm.$nextTick()
- 在Watcher的queueWatcher中执行nextTick()
Vue 会根据当前浏览器环境优先使用原生的 Promise.then 和 MutationObserver,如果都不支持,就会采用 setTimeout 代替,目的是延迟函数到 DOM 更新后再使用。
如果浏览器支持Promise,那么就用Promise.then的方式来延迟函数调用,Promise.then方法可以将函数延迟到当前函数调用栈最末端,也就是函数调用栈最后调用该函数。从而做到延迟。
我认为这个方法不仅仅是等DOM渲染完后,获取最新的视图数据,还有一个点是做缓冲,在数据更新完毕后,拿到最终的数据,只进行一次DOM更新。不然一个数据被循环改变100次,就要执行100次重绘,性能开销很大的。
同步任务执行完之后会执行异步任务,在异步任务中先执行一个宏任务,然后清空微任务队列,然后进行GUI渲染视图
也就是我们通过将timerFunc包装成异步任务,然后我们的nexttick中传入的回调会在flushSchedulerQueue执行之后执行,所以我们在回调中是可以获取到最新的DOM的,不同在于如果timerFunc如果是微任务的话,浏览器把DOM更新的操作放在Tick执行microTask的阶段来完成,相比使用宏任务生成的一个macroTask会少一次UI的渲染。
相关解析: