Vue2 源码解析
一、简介
Vue.js 是一个渐进式 MVVM 框架,目前被广泛使用,也成为目前前端技术中颇具代表性的一个框架。
按 Vue 作者的说法,Vue(及其生态)是一个渐进式 MVVM 框架,可以按照实际需要逐步进阶使用更多特性
1、核心机制
- 依赖收集的原理和实现
- 数据监听的实现
- 模板编译原理
- render 方法的原理
- render 方法的生成—— codegen
- Vue 实例挂载和渲染
- 组件机制
二、为什么要阅读源码
- 前端技术的发展非常快,仅仅掌握 Vue 的使用是远远无法跟上前端的发展脚步的
- Vue 的源码中有不少经典的解决问题的方法,掌握这些才算是真的学到了一些前端精髓。例如时下流行的 JSX、虚拟 DOM、数据变更的监听检测、观察者模式的使用等
- 在碰到复杂的项目场景时,仍然需要大量的前端基础知识技能,而 Vue 的源码中有很多问题的解决方案
- 针对一些复杂的项目场景需要了解底层实现方案(例如nextTick、render)才好分析出合适的解决方法,以及评估是否可应用到项目中
- 面试时很多大厂必问(比如腾讯)
三、Vue 实例及入口
1、源码目录
-
compiler 包括模板编译相关的代码,包括创建编译器(
create-compiler.js
)、模板解析(parser
目录)、AST 优化(optimizer.js
)、render()
方法生成(codegen
目录)以及一些其它的辅助代码(比如内置指令相关等)
├── codeframe.js 用于出错后定位错误位置
├── codegen 生成编译后的代码
│ ├── events.js
│ └── index.js
├── create-compiler.js 创建编译器
├── directives 生成内置指令代码
│ ├── bind.js
│ ├── index.js
│ ├── model.js
│ └── on.js
├── error-detector.js 用于检查AST是否有错误
├── helpers.js 辅助方法
├── index.js 编译器入口
├── optimizer.js 优化AST,生成静态子树
├── parser 模板解析,将模板解析成AST
│ ├── entity-decoder.js
│ ├── filter-parser.js
│ ├── html-parser.js
│ ├── index.js
│ └── text-parser.js
└── to-function.js 将解析器生成的代码转成函数
- core Vue 类、实例及全局 API 定义。其中包含了几个部分:
-
Vue 类,位于
instance
目录,用于创建 Vue 实例 -
响应式数据实现,位于
observer
目录 -
虚拟 DOM 实现,位于
vdom
目录 -
Vue 全局 API,位于
global-api
目录 -
内置组件,位于
components
目录
├── components 内置组件
│ ├── index.js
│ └── keep-alive.js
├── config.js
├── global-api 全局API,用于mixin
│ ├── assets.js
│ ├── extend.js
│ ├── index.js
│ ├── mixin.js
│ └── use.js
├── index.js 入口
├── instance 实例上的成员
│ ├── events.js 事件
│ ├── index.js
│ ├── init.js 初始化方法
│ ├── inject.js 依赖注入
│ ├── lifecycle.js 生命周期函数
│ ├── proxy.js
│ ├── render-helpers 函数辅助函数
│ │ ├── bind-dynamic-keys.js
│ │ ├── bind-object-listeners.js
│ │ ├── bind-object-props.js
│ │ ├── check-keycodes.js
│ │ ├── index.js
│ │ ├── render-list.js
│ │ ├── render-slot.js
│ │ ├── render-static.js
│ │ ├── resolve-filter.js
│ │ ├── resolve-scoped-slots.js
│ │ └── resolve-slots.js
│ ├── render.js render方法
│ └── state.js 应用状态相关
├── observer 响应式数据实现
│ ├── array.js 数组相关实现
│ ├── dep.js 一个观察者
│ ├── index.js
│ ├── scheduler.js 渲染时的调度器
│ ├── traverse.js 遍历
│ └── watcher.js Watcher实现
├── util 一些工具
│ ├── debug.js
│ ├── env.js
│ ├── error.js
│ ├── index.js
│ ├── lang.js
│ ├── next-tick.js
│ ├── options.js
│ ├── perf.js
│ └── props.js
└── vdom 虚拟DOM实现
├── create-component.js
├── create-element.js
├── create-functional-component.js
├── helpers
│ ├── extract-props.js
│ ├── get-first-component-child.js
│ ├── index.js
│ ├── is-async-placeholder.js
│ ├── merge-hook.js
│ ├── normalize-children.js
│ ├── normalize-scoped-slots.js
│ ├── resolve-async-component.js
│ └── update-listeners.js
├── modules
│ ├── directives.js
│ ├── index.js
│ └── ref.js
├── patch.js
└── vnode.js
- -platforms
包含 Vue 与具体平台相关的代码,针对浏览器平台(
web
目录)和 weex 平台分别对一些部分进行不同的实现。主要包括:
-
编译器中平台相关的部分,位于
compiler
目录 - 入口处理
-
运行时平台相关的部分,位于
runtime
目录
├── web web平台
│ ├── compiler
│ │ ├── directives
│ │ │ ├── html.js
│ │ │ ├── index.js
│ │ │ ├── model.js
│ │ │ └── text.js
│ │ ├── index.js
│ │ ├── modules
│ │ │ ├── class.js
│ │ │ ├── index.js
│ │ │ ├── model.js
│ │ │ └── style.js
│ │ ├── options.js
│ │ └── util.js
│ ├── entry-compiler.js
│ ├── entry-runtime-with-compiler.js
│ ├── entry-runtime.js
│ ├── entry-server-basic-renderer.js
│ ├── entry-server-renderer.js
│ ├── runtime
│ │ ├── class-util.js
│ │ ├── components 内置组件
│ │ │ ├── index.js
│ │ │ ├── transition-group.js
│ │ │ └── transition.js
│ │ ├── directives 内置指令
│ │ │ ├── index.js
│ │ │ ├── model.js
│ │ │ └── show.js
│ │ ├── index.js
│ │ ├── modules
│ │ │ ├── attrs.js
│ │ │ ├── class.js
│ │ │ ├── dom-props.js
│ │ │ ├── events.js
│ │ │ ├── index.js
│ │ │ ├── style.js
│ │ │ └── transition.js
│ │ ├── node-ops.js
│ │ ├── patch.js
│ │ └── transition-util.js
│ ├── server
│ │ ├── compiler.js
│ │ ├── directives
│ │ │ ├── index.js
│ │ │ ├── model.js
│ │ │ └── show.js
│ │ ├── modules
│ │ │ ├── attrs.js
│ │ │ ├── class.js
│ │ │ ├── dom-props.js
│ │ │ ├── index.js
│ │ │ └── style.js
│ │ └── util.js
│ └── util
│ ├── attrs.js
│ ├── class.js
│ ├── compat.js
│ ├── element.js
│ ├── index.js
│ └── style.js
└── weex Weex平台相关,不看
├── compiler
│ ├── directives
│ │ ├── index.js
│ │ └── model.js
│ ├── index.js
│ └── modules
│ ├── append.js
│ ├── class.js
│ ├── index.js
│ ├── props.js
│ ├── recycle-list
│ │ ├── component-root.js
│ │ ├── component.js
│ │ ├── index.js
│ │ ├── recycle-list.js
│ │ ├── text.js
│ │ ├── v-bind.js
│ │ ├── v-for.js
│ │ ├── v-if.js
│ │ ├── v-on.js
│ │ └── v-once.js
│ └── style.js
├── entry-compiler.js
├── entry-framework.js
├── entry-runtime-factory.js
├── runtime
│ ├── components
│ │ ├── index.js
│ │ ├── richtext.js
│ │ ├── transition-group.js
│ │ └── transition.js
│ ├── directives
│ │ └── index.js
│ ├── index.js
│ ├── modules
│ │ ├── attrs.js
│ │ ├── class.js
│ │ ├── events.js
│ │ ├── index.js
│ │ ├── style.js
│ │ └── transition.js
│ ├── node-ops.js
│ ├── patch.js
│ ├── recycle-list
│ │ ├── render-component-template.js
│ │ └── virtual-component.js
│ └── text-node.js
└── util
├── element.js
├── index.js
└── parser.js
- server
服务端渲染的代码。包括创建服务端渲染包、服务端针对渲染的特殊处理等等
├── bundle-renderer
│ ├── create-bundle-renderer.js
│ ├── create-bundle-runner.js
│ └── source-map-support.js
├── create-basic-renderer.js
├── create-renderer.js
├── optimizing-compiler
│ ├── codegen.js
│ ├── index.js
│ ├── modules.js
│ ├── optimizer.js
│ └── runtime-helpers.js
├── render-context.js
├── render-stream.js
├── render.js
├── template-renderer
│ ├── create-async-file-mapper.js
│ ├── index.js
│ ├── parse-template.js
│ └── template-stream.js
├── util.js
├── webpack-plugin
│ ├── client.js
│ ├── server.js
│ └── util.js
└── write.js
2、初始化过程
- uid 是一个全局共享的变量,并不在构造函数中,因此每次调用都会 +1,这样可以确保每个组件的_uid 都不一样。
- _isVue 标记对象为 Vue 实例,如果这个组件在后续被当作普通对象来监听变化时,能识别出来这是一个 Vue 实例。
- 如果选项_isComponent 为 true,则说明组件是一个自定义组件。会调用 initInternalComponent 方法,会写入 parent/_parentVnode/propsData/_parentListeners/_renderChildren/_componentTag 属性。如果 render 选项存在,还会写入 render/staticRenderFns。
- 如果不是内部组件,则会合并组件的 constructor(即 Vue 构造函数)的调用参数、options 对象,还有 vm 实例本身的属性(也就是说包括上面的_uid 之类的都会合并进去)。(Vue.options 是一个预先定义好的配置对象。)
- _self 把 vm 自己挂上去。
- 初始化
初始化生命周期
初始化事件绑定
初始化 Render
调用钩子 beforeCreate
初始化依赖注入 Injections
初始化状态 State
初始化依赖注入 Provide
调用钩子 created
-
如果声明了
.el
属性,则调用$mount(el)
四、核心机制
1、依赖收集的原理和实现
Vue 使用 getter/setter 机制实现了数据变更的自动监测。再深入思考一下这个问题,为什么需要数据变更的监测?是因为我们不希望手工去更新 DOM 元素,而是希望数据的任何变更都能自动反映到 DOM 的变更中,而这个过程中,依赖收集是一个必不可少的过程
- 推送与拉取
假设有
a
、
b
、
c
3 个值,它们之间存在依赖关系,即
a
的变化会导致
b
的变化,而
b
的变化又会导致
c
的变化。
当
a
发生变化时,
c
应该如何处理,通常来讲,此时有两种策略:推送(push)、拉取(pull)。
拉取(pull)的含义是指,当
c
被访问的时候,会去寻找
b
的最新值,而
b
被访问时又会去寻找
a
的最新值。因此当
a
发生变化时,它只需要管理自己的变更即可,其他依赖它的值在被访问到的时候都会自动拉取一遍最新值,从而完成数据依赖的更新。
推送(push)则是指,当
a
发生变化时,需要主动通知
b
进行更新,而
b
更新时又需要通知
c
更新。从而完成数据依赖的更新。
// 推送(push) a变化 ----> b变化 ----> c变化
// 拉取(pull) c被访问 ----> b被访问 ----> a被访问
- Vue 中的依赖关系
Vue 中与数据有关的概念大致有这样几类:
data、props、computed
watch
methods 以及生命周期方法(created、mounted 等)
对第一类数据,它们都可以直接在 Vue 的实例
vm
上进行访问,也可以直接在模板中进行访问。以模板为例,当模板中引用了一个这样的数据时,如果数据发生变更,需要直接反映到对应的 DOM 元素中。而由于原理限制(DOM 元素会一直显示,并且不会主动重新渲染),数据无法被 DOM 主动重新访问,因此此类数据的依赖更新只能采用 “推送(push)”。
对
watch
而言,与第一类数据类似,不同的是它的使用方式是一个回调函数,例如
watch: {
// 当foo的值发生变化时,调用callback()函数
foo: function callback(){
// 访问foo的值,做些其它事情
}
在这种情况下,
callback()
也无法主动运行,因此不能采用 “拉取(pull)” 的策略,只能采用 “推送(push)” 策略,即
foo
的值发生变化时,主动调用
callback()
函数。
其他的像
methods
中的方法以及生命周期方法,都可以直接通过
this.xxx
的方式读取数据,因此可以直接采用 “拉取(pull)” 的依赖更新策略。
因为 “拉取(pull)” 策略实际上就是正常的变量访问,如果有依赖关系都会顺着依赖定义的地方自动进行计算,因此不需要 Vue 进行重点关注。而 “推送(push)” 策略则不同,它需要关注每一个变量变更的时候,有哪些地方依赖这个变量,并一一通知这些地方进行更新。所以 Vue 中的依赖收集主要关注的就是采用 “推送(push)” 策略进行依赖更新的地方,即
data、props、computed
watch
- 依赖收集的实现
如前所述,当数据变化以后,需要更新的地方主要包括
computed
定义的变量,以及
watch
。Vue 组件被挂载(
mount
)时,会针对每个这样的地方,初始化一个
Watcher
。
Watcher
会记住这个表达式或者函数(Vue 允许开发者
watch
一个函数),并暴露一个名为
update()
的方法,用来给外界调用。一旦这个方法被调用,就表示 “你这个 Watcher 所依赖的数据有更新,麻烦对对应的模板进行更新 / 麻烦调用回调函数”
模板中的表达式也需要更新,但这里 Vue 采用的策略是不精准地对应依赖关系,而是在需要的时候将模板全部重新渲染一遍(使用虚拟 DOM 减少真实的渲染工作量),因此模板中的表达式不需要收集依赖
那数据变更的时候是如何知道应该要调用哪些
Watcher
呢,又是在什么时候调用
Watcher
进行更新的呢?
这就要回到我们在前文中反复提到的
getter
/
setter
机制,我们知道 Vue 使用这一机制来进行依赖收集,但前文中并未说明具体是如何处理的,接下来我们就来揭开这一机制的神秘面纱。
在 Vue 实例初始化的时候,会将我们传递给组件的
data
(确切地说,是
data()
方法的返回值)进行转换,由纯对象转换成
getter/setter
的定义,这一过程主要靠
defineReactive()
方法。在这个方法中,Vue 会对传入对象的每一个属性定义一个
getter
和一个
setter
。这样,每当这个属性被访问的时候,
getter
就会被调用。而每当这个属性被更新的时候,
setter
就会被调用。
每个数据属性除了有
getter
之外,还有一个对应的
Dep
类的实例
dep
(它是一个观察者模式的实现,可以先简单理解为一个依赖列表),当
getter
调用时,会判断当前是否有
Watcher
正在进行依赖收集,如果是的话,就记录
Dep
实例与
Watcher
的依赖关系,从而完成依赖收集。这个判断的详细过程如下:
-
当组件被挂载的时候,Vue 会为表达式建立一个
Watcher
,Watcher
会将自己挂到Dep.target
(静态成员)上,表示当前正在进行收集依赖的正是刚刚建立的Watcher
; -
接下来 Vue 会调用
Watcher
的表达式,进行一次求值运算。因为这次求值运算是主动调用的,因此它所有的依赖都会被一一进行取值运算(依赖更新的 “拉取(pull)” 策略); -
取值时
getter
会被调用,如果发现Dep.target
存在,则表示当前有Watcher
正在进行依赖收集,此时getter
会调用dep
实例对象的depend
方法,建立当前属性值与Dep.target
的关联,从而完成整个收集依赖的工作。
整个依赖收集过程最关键的入口在于
core/observer/index.js
第 135 行
defineReactive()
方法,这个方法接受两个参数,分别是
obj
和
key
,表示将实例上数据
obj[key]
转换为 getter/setter,以便可以响应数据变化。这个方法简化后大体结构如下:
export function defineReactive (
obj: Object,
key: string,
// 针对这个obj和key定义一个Dep实例
const dep = new Dep()
// 一些其他的判断,省略
// ...
// 在obj上定义key的getter/setter
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
// getter定义,当访问obj[key]时被调用
get: function reactiveGetter () {
// 先取值
const value = getter ? getter.call(obj) : val
// 判断是否有Watcher正在进行依赖收集
// 如果有的话,调用dep.depend(),表示“我被调用了,它依赖我,请记录”
// 因为组件挂载时会新建一个Watcher,Watcher会调用求值,因此在挂载的时候,Dep.target是存在的
if (Dep.target) {
// Dep.target依赖了dep
dep.depend()
// 嵌套处理和数组处理,省略
// ...
// 返回值
return value
set: function reactiveSetter (newVal) {
// 取原值
const value = getter ? getter.call(obj) : val
// 如果新值原值一样,不处理
if (newVal === value || (newVal !== newVal && value !== value)) {
return
// 一些判断,省略
// ...
// 赋新值
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
// 告诉所有依赖我的Watcher,我变化了,请更新
dep.notify()
结合代码上的注释,能很明显看到,
getter
进行依赖收集、
setter
在值发生变动后通知依赖进行更新的过程
2、数据监听的实现
- Proxy
在
core/instance/state.js
中,首先定义了一个名为
proxy()
的方法,它的作用是用来代理
vm
上属性名的访问。
例如当我们访问
vm.propKey
这个属性时,实际上是访问
vm._props.propKey
,这个代理的过程就是
proxy()
方法干的事情
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
export function proxy (target: Object, sourceKey: string, key: string) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val
Object.defineProperty(target, key, sharedPropertyDefinition)
还是举上面的例子,访问
vm.propKey
属性需要代理到
vm._props.propKey
,那么调用时就是这样
proxy(vm, '_props', 'propKey')
。
在下面,我们将看到,我们声明的
data
、
props
、
computed
等成员中的属性,都不是直接挂在
vm
对象上的,但我们却可以直接用
vm.xxx
这样的方式(在代码中更多写作
this.xxx
)来访问,就是因为这个
proxy()
将访问过程进行了代理
- Watcher
Vue 专门写了一个类
Watcher
来处理数据监听,它一般会跟随一些属性一起出现,这些属性可能是
computed
或者
data
或者
props
等等,当这些属性依赖的别的属性发生变化时,由
Watcher
实例来执行需要变更的具体逻辑
- 初始化
initState 定义
vm._watchers
,接下来一一初始化所有状态相关的属性,包括
props
/
methods
/
data
/
computed
/
watch
-
属性初始化
initProps()
在
initProps()
中,正是调用的
defineReactive(props, key, value)
方法来讲属性转换为响应式对象的。现在我们可以将整个逻辑串起来了:
-
如果一个组件不是根组件,则调用
toggleObserver(false)
,此后observe()
不会将传入的对象转换为响应式对象 - 将属性的每一个 key 和对应的值转换成 getter/setter
-
在执行 2 之前,会首先尝试调用
observe()
将值转换成响应式对象,但是因为第 1 步操作,这个转换不会进行 -
在整个属性转换执行完成后,调用
toggleObserver(true)
,恢复observe()
将对象转换为响应式对象的逻辑
总结下来,上面做的事情就是一句话:不要将属性值做 “对象转响应式对象” 的转换
-
初始化数据
initData()
看完属性的处理之后,数据的处理逻辑就显得特别简单直接了:
-
调用
data()
方法获取数据值(Vue 推荐data
写成一个函数来返回值,但源码中也处理了data
不是函数的情况) -
针对数据值的每个 key,检查有没有和
methods
、props
重名 -
将每个 key 进行代理,使
vm.xxx
的访问代理到vm._data.xxx
-
最后再
observe(data, true)
,使整个数据对象变成响应式对象
-
计算属性
computed
-
为传入的计算属性创建一个
Watcher
,设置属性lazy
为true
-
在计算属性被读取的时候,如果正在进行依赖收集,则将计算属性对应的
Watcher
加入依赖列表中 -
当依赖的数据产生变化时,
update()
方法将dirty
置为true
-
计算属性被读取的时候,因
dirty
为true
,调用get()
方法获取新值,并将dirty
设置为false
,完成整个读取值和缓存的过程
-
监听器
watch
$watch() 方法被定义在 Vue 的原型上,它的逻辑简单直接:
针对传入的表达式或函数创建一个 Watcher
如果 immediate 为 true,则立马调用回调函数 cb 一次
返回取消监听的方法 unwatchFn
总结:我们详细了解了 Vue 数据监听的实现原理。以上一节介绍的依赖收集为核心机制,Vue 将它运用到了计算属性、数据、监听器等各种属性的处理上,为了实现这一过程,Vue 还定义了
proxy()
方法和
Watcher
类。从而让开发者能真正使用一款 “响应式” 的前端框架来完成应用开发
3、模板编译原理
整体而言,Vue 的处理方式大致分为几步:
- 将模板进行解析,得到一棵抽象语法树(AST)
- 根据抽象语法树得到虚拟 DOM 树
- 将虚拟 DOM 树渲染为真实的 DOM
其中第一步和第二步由
render()
方法来完成,第 3 步由
mount()
方法来完成。
Vue 编译模板的过程:
-
根据不同环境使用不同的参数生成
compiler
- 使用 HTML parser 解析模板,并调用回调事件
- Vue 在回调事件中生成 AST
- 针对生成的 AST 进行优化(分析出纯静态的 DOM,将它们放入常量中,这样在重新渲染和 patch 的时候能直接跳过它们)
4、render 方法的原理
前文提过,Vue 在将模板编译为 AST 并且优化之后,会将 AST 转换成虚拟 DOM 树(即 VNode 树)。这个过程就是
render()
方法来完成的。
Vue.component('anchored-heading', {
render: function (createElement) {
return createElement(
'h' + this.level, // 标签名称
this.$slots.default // 子节点数组
props: {
level: {
type: Number,
required: true
使用
render()
方法而不使用模板的一个好处是带来更大的灵活性,像上面这个例子,可以根据传入的
level
参数动态决定需要渲染的元素。
render() 方法的原理是它可以由模块编译生成,也可以直接传入使用,而它的实质就是使用 createElement() 方法生成一棵虚拟 DOM 树
5、render 方法的生成——codegen
codegen 的逻辑就是读取 AST,然后将对应的元素、属性、子元素等信息都遍历一遍,生成了一个 render() 函数。在后续组件进行挂载时,render() 方法会被调用,此时就会生成整个虚拟 DOM
6、挂载和渲染
回顾一下,Vue 实例在经历初始化后,完成了很多事情,如依赖收集、数据监听、模板编译、生成 render() 方法等等。最终,Vue 实例还是要将其代表的逻辑渲染到真实的 web 页面上。
这个过程就需要调用 render() 方法,首先生成完整的虚拟 DOM 树,然后将虚拟 DOM 树挂载到 web 页面上
- 挂载
首先,针对 web 平台,Vue 为实例增加了一个
$mount
方法。它的定义在
platforms/web/runtime/index.js
中,本质上是调用了
mountComponent()
方法。
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
关于挂载的部分,它做了这样几件事情:
-
定义了一个
updateComponent()
方法,这个方法做的事情就是先获取vm._render()
的结果(即render()
方法返回的虚拟 DOM),然后调用vm._update()
-
创建了一个 vm 全局的
Watcher
,它监听的表达式是updateComponent()
方法,在进行依赖收集的时候,updateComponent()
被调用,即组件被完全重新渲染,此时就能收集到 vm 中所有的依赖,简单说就是 vm 在渲染时用到的任何依赖发生变化时都会触发这个Watcher
进行更新
- 渲染
vm._update() 负责将虚拟 DOM 渲染到真实的 DOM 中,该方法的第一个参数是组件的虚拟 DOM。它的逻辑并不复杂,核心逻辑只有下面几句:
const prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
这段代码首先判断了 vm._vnode 是否存在,如果不存在,则说明这个组件是初次渲染,否则说明之前渲染过,这一次渲染是需要进行更新。针对这两种情况,分别用不同的参数调用了__patch__() 方法:
如果是初次渲染,第一个参数是真实的 DOM 元素
如果不是初次渲染,第一个参数是前一次渲染的虚拟 DOM
7、组件机制
- 组件的引用和渲染
-
父实例在渲染的时候,针对虚拟 DOM 节点树递归调用
createElm()
-
createElm()
执行过程中,如果发现某个虚拟 DOM 节点是组件,则会调用createComponent()
-
createComponent()
调用init
hook,生成组件对应的 Vue 实例 - 实例初始化的时候完成组件的渲染工作
- 单文件组件
适当拆解一下
.vue
组件,会发现它的解析其实是在 JS 文件打包的时候(通过 webpack 的 vue-loader 或者类似的工具),将
.vue
文件解析成为 js 文件。而解析的过程从原理上讲则简单明了:
<template> 部分被模板解析、生成 AST,最后生成 render() 方法,成为组件对象的一部分
<script> 几乎不做处理,直接被导出使用
<style> 是纯工程化的处理方式,最终被动态写入 HTML 中,或者在打包的时候被单独提取成 CSS 文件
Vue 会最终将组件的各种声明都放到
vm.$options.components
中,供渲染时引用。在渲染时组件也拥有独立的 Vue 实例,在父实例渲染的时候只会生成一个占位虚拟 DOM,组件的渲染则由组件自行完成
五、实现细节
1、v-model 双向绑定
将双向绑定拆开来看,有两个方向的变化需要处理:
- 数据变动 -> DOM 变化
- 输入框变动 -> 数据变化
第 1 个方向其实和普通的模板数据渲染没有什么区别,这一点在之前已经进行过比较详细的分析,因此这里就不再详述。这里重点关注第 2 个点的实现。
在 Vue 中,双向绑定是通过 v-model 指令来实现的,但是这个指令在 1.0 和 2.0 中的实现原理差别比较大。从 Vue 2.0 开始,v-model 变成了一个语法糖,本质上相当于:value 的绑定 + @input/@change 事件绑定。
2、nextTick 实现解析
一些基本的常识:
- 当前正在执行的代码会顺序执行下去,这是最高优先级
- 异步方法的回调都会放在事件队列中,在当前执行的代码执行结束后被调用
- 事件队列分为两种,一种是 macrotask,也称宏任务,一种是 microtask,也称微任务
- 每一次事件循环时从异步队列中先取一个宏任务运行,然后将所有的微任务运行完,结束循环
- JS 操作完 DOM 后,DOM 的渲染、更新是在微任务中
前面我们说过 Vue 是数据驱动界面的,当数据发生变动时,Vue 会通过 VNode 的渲染去更新 DOM,而这个过程上本质还是使用 DOM API 修改 DOM。按上面的常识,JS 操作完 DOM 后,DOM 的渲染、更新是在微任务中的。也就是说,如下的代码是取不到更新后的 DOM 的(来自 Vue 官方文档):
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
因为此时 DOM 还没有更新,直接读取 DOM 返回的仍然是更新前的信息。此时我们就需要异步去读取。因此 Vue 提供了
nextTick()
方法来处理这种情况:
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () { vm.$el.textContent === 'new message' // true })
除此之外,在组件
mounted
生命周期事件发生时,Vue 实际上并未完成子组件的渲染,因此通过
this.$refs
来获取子组件也是无法获取到的。此时也需要通过
nextTick()
方法来异步读取
JS 在执行完宏任务后,会获取所有的微任务并一一执行,其中 DOM 更新也属于这些微任务中的一员。因此,如果
nextTick()
能够将回调函数安排到微任务中,将比安排到宏任务中更快被执行。Vue 的
nextTick()
实现正是这样一种思路:尽量将任务安排到微任务中,如果实在是不支持,则采用一些方法作回退,确保回调函数能被执行(即使是被安排到宏任务执行)
因为 Vue 会运行在各种不同的环境中,而各个环境的能力并不完全一样,所以这段逻辑中,Vue 分了很多情况来分别处理:
第 1 种情况,有原生 Promise,则使用 Promise 的
.then()
方法来安排异步任务,因为原生 Promise 的任务会被安排到微任务中:
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
isUsingMicroTask = true
第 2 种情况,没有原生 Promise,也不是 IE,并且
MutationObserver
可用,则会使用它,它是一个原生的 DOM 方法,可以在 DOM 元素发生变更时调用指定的回调。Vue 会创建一个 DOM 节点(文本节点),并修改它的属性为
0
或
1
(
counter = (counter + 1) % 2
),此时
MutationObserver
会观察到 DOM 节点发生变化,触发回调调用
flushCallbacks()
。值得注意的是,
MutationObserver
的回调任务也是被安排到微任务中:
else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
isUsingMicroTask = true
如果以上情况都不满足,则 Vue 会分别尝试用
setImmediate()
和
setTimeout()
来安排任务,此时任务会被安排到宏任务中:
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks)
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
六、Vue 周边
1、前端路由
- 前端路由概念
一个典型的前端路由在单页面场景下大概需要关注以下几个问题:
- 定义路由表,即各种 URL 分别对应哪些逻辑(一般来说就是对应界面的渲染)
- 获取当前访问的 URL,并根据路由表匹配中对应的逻辑并调用它(渲染对应的界面)
- 处理链接跳转,如果链接地址是在单页面应用的范围内,则不能使用浏览器导航,而是直接完成新 URL 对应的界面的渲染,并将浏览器中显示的 URL 更新为新界面对应的 URL
- 监视 URL 的变更,当用户手工更改 URL 或者有其它逻辑更改了 URL 之后,需要重新进行路由匹配并完成界面的渲染
一般来说,1 是纯计算逻辑,不需要什么特别的处理,2 可以由
location
这个 API 进行获取,因此前端路由中值得关注的核心问题主要就是 3 和 4,简单地归纳就是更新浏览器 URL 和监视浏览器 URL 改变
- hash 模式
修改 URL 中的 hash 部分。如
/home#/hello/world
,其中的 hash 部分就是
#/hello/world
,这部分在浏览器导航的时候并不会被传给后端服务器,也可以方便地用 JavaScript 修改,并且修改它时也不会发生重新导航的情况
除了需要更新 URL 以外,路由还需要能够监视 URL 的变更。在这个问题上,因为浏览器的能力不尽相同,有 3 种不同的方案:
- 针对老旧的浏览器,没有可用的 API:需要使用定时器,定时获取浏览器的 URL,与上次获取的结果进行比较,如果有变更则触发回调
-
较新的浏览器提供了
hashchange
事件,直接监听这个事件即可 -
更新的浏览器提供了
popstate
事件,这个事件与下文 history 模式有关,留到下面介绍
hash 模式使用虽然方便,但是有两个比较明显的问题:
- 这样的 URL 并不符合用户的固有认知,也不太美观
- 因为 hash 部分不会被传递给后端服务器,导致没有办法进行服务端渲染,进而使得搜索引擎的收录成为一个很大的问题
- history 模式
浏览器厂商和标准组织为这一场景给出了另一种更好的解决方案,即 vue-router 中支持的 history 模式。
这个模式的核心在于 history.pushState(state, title, url) 这个 API,它的含义是向浏览器的历史栈(即前进后退的栈)中压入一个新的状态,从逻辑上相当于跳转到了一个新的页面,但是并不真的重新加载或重新导航。在单页面应用的场景中,可以使用这个 API 很方便地修改浏览器中的 URL,并正确地处理前进 / 后退的问题。
那我们要如何监视 URL 的修改呢?浏览器为我们提供了 popstate 事件。当用户进行导航动作(前进 / 后退等)或有 history.back()、history.forward() 之类的调用时,popstate 事件就会发生。因此只要监听这个事件,就能获取浏览器 URL 可能发生了改变。
如果我们使用 history 模式的前端路由,当前端的界面发生变化时,对应的 URL 也会发生变化,此时 URL 是由前端逻辑负责写入的,例如
/hello/vue
。如果此时用户刷新了页面,或者将这个 URL 分享给了其它人,则对
/hello/vue
这个路由的访问会首先到达后端服务器,如果后端服务器不能正确处理这个地址的访问,就可能出现 404 的错误。
如果你在开发的时候发现一切正常,但一刷新页面就会 404,需要回到首页的地址才能访问,那么基本上就可以确定是因为后端没有办法正确处理由前端路由写入的 URL 而导致的问题。
这个问题的解决方法也比较简单,即让后端能正确地处理前端逻辑写入的 URL。因为前端只有一个页面,因此后端不论用户访问的 URL 是什么,只要碰到由前端路由负责控制的 URL,就统一返回唯一的一个页面的 HTML 即可
2、vue-router 实现细节
vue-router 的使用主要有几个步骤:
-
在插件安装阶段声明了
beforeCreate()
mixin,将_route
数据变成响应式数据 -
在初始化的时候初始化
History
,并进行初次路由匹配 -
初次路由匹配完成后监听后续浏览器 URL 变更,当路由变更时改变
_route
触发重新渲染 - 路由的设置和匹配由路由表完成,主要逻辑在 matcher 相关代码中
3、vuex
- 单向数据流介绍
这样做会带来几个好处:
- 由应用的状态数据就可以完全确定应用所有组件的运行状态,不容易出现由一些没有注意到的细节导致的 bug;
- 当不同的组件需要共享状态数据时,集中式的状态管理不需要额外的通讯机制,使用起来更容易,这一点在共享状态数据的组件层级较多时表现得更明显;
- 应用状态集中到一起,只要复制状态即可重现运行情况,非常有助于代码调试和故障排查
- 应用的变化均由状态数据的变化引起,很容易追查变化的来源
状态数据用 State 表示,整个流程是这样的:
- Vue 组件从 State 中获取数据并完成渲染;
-
当组件需要修改数据时,不能直接修改 State 中的状态数据,而是要使用
dispatch()
方法调用一个 Action; -
在 Action 中可以进行各种操作,比如调用后端 API 等,在操作完成后需要修改状态数据时,需要调用
commit()
方法,调用一个 Mutation; - Mutation 负责修改 State,修改完成后再回到 1,组件重新渲染。
4、vue-cli核心功能和原理
vue-cli 是 Vue 官方提供的命令行工具,它具有许多功能,如:
- 初始化新项目
- 以开发模式构建项目并提供热加载功能
- 构建打包生产环境的静态资源
- 安装 / 升级插件
- 读写配置文件
可以在命令行中直接使用
vue
命令来使用它。
这样的用法是利用了 npm 提供的机制,它需要开发者在
package.json
中的
bin
中指定命令的名称和命令对应的
.js
文件。当模块被安装的时候,npm 会自动在全局或者
node_modules/.bin
下生成命令行脚本。
@vue/cli
的
package.json
中是这样声明的:
{
"name": "@vue/cli",
"version": "4.5.11",
"bin": {
"vue": "bin/vue.js"
上面的代码声明了一个
vue
命令,当它被调用时执行
bin/vue.js
中的代码。因此
bin/vue.js
就是
@vue/cli
的命令行入口文件。
- 入口
bin/vue.js 作为命令行的入口文件,主要功能是处理命令的输入和解析。为了更方便地处理命令行输入的命令和参数解析,引用了 commander 模块。
整个文件比较长,但是结构是比较简单的,大部分的代码都在编写每个命令的参数格式和说明。
const program = require('commander')
const loadCommand = require('../lib/util/loadCommand')
program
.version(`@vue/cli ${require('../package').version}`)
.usage('<command> [options]')
// create命令
program
.command('create <app-name>')
.description('create a new project powered by vue-cli-service')
.option('-p, --preset <presetName>', 'Skip prompts and use saved or remote preset')
.option('-d, --default', 'Skip prompts and use default preset')
.action((name, cmd) => {
require('../lib/create')(name, options)
// 省略其它命令
// serve命令
program
.command('serve [entry]')
.description('serve a .js or .vue file in development mode with zero config')
.option('-o, --open', 'Open browser')
.option('-c, --copy', 'Copy local url to clipboard')
.option('-p, --port <port>', 'Port used by the server (default: 8080 or next available port)')
.action((entry, cmd) => {
loadCommand('serve', '@vue/cli-service-global').serve(entry, cleanArgs(cmd))
// 省略其它命令