如果你需要动态编译模版(比如:将字符串模版传递给 template
选项,或者通过提供一个挂载元素的方式编写 html 模版),你将需要编译器,因此需要一个完整的构建包。
当你使用 vue-loader
或者 vueify
时,*.vue
文件中的模版在构建时会被编译为 JavaScript 的渲染函数。因此你不需要包含编译器的全量包,只需使用只包含运行时的包即可。
只包含运行时的包体积要比全量包的体积小 30%。因此尽量使用只包含运行时的包
处理组件配置项
初始化组件实例的关系属性,比如 、children、、refs 等
处理自定义事件
调用 beforeCreate 钩子函数
初始化组件的 inject 配置项,得到 ret[key] = val
形式的配置对象,然后对该配置对象进行浅层的响应式处理(只处理了对象第一层数据),并代理每个 key 到 vm 实例上
数据响应式,处理 props、methods、data、computed、watch 等选项
解析组件配置项上的 provide 对象,将其挂载到 vm._provided 属性上
调用 created 钩子函数
如果发现配置项上有 el 选项,则自动调用 mount 方法,也就是说有了 el 选项,就不需要再手动调用 mount 方法,反之,没提供 el 选项则必须手动调用 $mount
接下来则进入挂载阶段
面试官 问:Vue 响应式原理是怎么实现的?
答:
响应式的核心是通过 Object.defineProperty
拦截对数据的访问和设置
响应式的数据分为两类:
面试官 问:methods、computed 和 watch 有什么区别?
答:
DOCTYPE html>
<html lang="en">
<head>
<title>methods、computed、watch 有什么区别title>
head>
<body>
<div id="app">
<div>{{ returnMsg() }}div>
<div>{{ returnMsg() }}div>
<div>{{ getMsg }}div>
<div>{{ getMsg }}div>
div>
<script src="../../dist/vue.js">script>
<script>
new Vue({
el: '#app',
data: {
msg: 'test'
},
mounted() {
setTimeout(() => {
this.msg = 'msg is changed'
}, 1000)
},
methods: {
returnMsg() {
console.log('methods: returnMsg')
return this.msg
}
},
computed: {
getMsg() {
console.log('computed: getMsg')
return this.msg + ' hello computed'
}
},
watch: {
msg: function(val, oldVal) {
console.log('watch: msg')
new Promise(resolve => {
setTimeout(() => {
this.msg = 'msg is changed by watch'
}, 1000)
})
}
}
})
script>
body>
html>
示例其实就是答案了
使用场景
区别
methods VS computed
通过示例会发现,如果在一次渲染中,有多个地方使用了同一个 methods 或 computed 属性,methods 会被执行多次,而 computed 的回调函数则只会被执行一次。
通过阅读源码我们知道,在一次渲染中,多次访问 computedProperty,只会在第一次执行 computed 属性的回调函数,后续的其它访问,则直接使用第一次的执行结果(watcher.value),而这一切的实现原理则是通过对 watcher.dirty 属性的控制实现的。而 methods,每一次的访问则是简单的方法调用(this.xxMethods)。
computed VS watch
通过阅读源码我们知道,computed 和 watch 的本质是一样的,内部都是通过 Watcher 来实现的,其实没什么区别,非要说区别的化就两点:1、使用场景上的区别,2、computed 默认是懒执行的,切不可更改。
初始设置 dirty 和 lazy 的值为 true,lazy 为 true 不会立即 get 方法(懒执行),而是会在读取 computed 值时执行
也就是说,computed 属性依赖的 data 不发生变化时,不会调用 setter 函数重新计算值,而是读取上一次计算值
methods VS watch
methods 和 watch 之间其实没什么可比的,完全是两个东西,不过在使用上可以把 watch 中一些逻辑抽到 methods 中,提高代码的可读性。
面试官 问:Vue 的异步更新机制是如何实现的?
答:
Vue 的异步更新机制的核心是利用了浏览器的异步任务队列来实现的,首选微任务队列,宏任务队列次之。
当响应式数据更新后,会调用 dep.notify 方法,通知 dep 中收集的 watcher 去执行 update 方法,watcher.update 将 watcher 自己放入一个 watcher 队列(全局的 queue 数组)。
然后通过 nextTick 方法将一个刷新 watcher 队列的方法(flushSchedulerQueue)放入一个全局的 callbacks 数组中。
如果此时浏览器的异步任务队列中没有一个叫 flushCallbacks 的函数,则执行 timerFunc 函数,将 flushCallbacks 函数放入异步任务队列。如果异步任务队列中已经存在 flushCallbacks 函数,等待其执行完成以后再放入下一个 flushCallbacks 函数。
flushCallbacks 函数负责执行 callbacks 数组中的所有 flushSchedulerQueue 函数。
flushSchedulerQueue 函数负责刷新 watcher 队列,即执行 queue 数组中每一个 watcher 的 run 方法,从而进入更新阶段,比如执行组件更新函数或者执行用户 watch 的回调函数。
完整的执行过程其实就是今天源码阅读的过程。
面试关 问:Vue 的 nextTick API 是如何实现的?
答:
Vue.nextTick 或者 vm.$nextTick 的原理其实很简单,就做了两件事:
try catch
包裹然后放入 callbacks 数组面试官 问:Vue.use(plugin) 做了什么?
答:
负责安装 plugin 插件,其实就是执行插件提供的 install 方法。
面试官 问:Vue.mixin(options) 做了什么?
答:
负责在 Vue 的全局配置上合并 options 配置。然后在每个组件生成 vnode 时会将全局配置合并到组件自身的配置上来。
面试官 问:Vue.component(compName, Comp) 做了什么?
答:
负责注册全局组件。其实就是将组件配置注册到全局配置的 components 选项上(options.components),然后各个子组件在生成 vnode 时会将全局的 components 选项合并到局部的 components 配置项上。
this.options.components.compName = CompConstructor
面试官 问:Vue.directive(‘my-directive’, {xx}) 做了什么?
答:
在全局注册 my-directive 指令,然后每个子组件在生成 vnode 时会将全局的 directives 选项合并到局部的 directives 选项中。原理同 Vue.component 方法:
this.options.directives['my-directive'] = {xx}
面试官 问:Vue.filter(‘my-filter’, function(val) {xx}) 做了什么?
答:
负责在全局注册过滤器 my-filter,然后每个子组件在生成 vnode 时会将全局的 filters 选项合并到局部的 filters 选项中。原理是:
this.options.filters['my-filter'] = function(val) {xx}
。面试官 问:Vue.extend(options) 做了什么?
答:
Vue.extend 基于 Vue 创建一个子类,参数 options 会作为该子类的默认全局配置,就像 Vue 的默认全局配置一样。所以通过 Vue.extend 扩展一个子类,一大用处就是内置一些公共配置,供子类的子类使用。
Sub.extend = Super.extend
,这样子类同样可以扩展出其它子类面试官 问:Vue.set(target, key, val) 做了什么
答:
由于 Vue 无法探测普通的新增 property (比如 this.myObject.newProperty = ‘hi’),所以通过 Vue.set 为向响应式对象中添加一个 property,可以确保这个新 property 同样是响应式的,且触发视图更新。
obj[key] = val
obj[key] = val
,但是不会做响应式处理面试官 问:Vue.delete(target, key) 做了什么?
答:
删除对象的 property。如果对象是响应式的,确保删除能触发更新视图。这个方法主要用于避开 Vue 不能检测到 property 被删除的限制,但是你应该很少会使用它。当然同样不能删除根级别的响应式属性。
delete obj.key
,然后执行依赖更新即可面试官 问:Vue.nextTick(cb) 做了什么?
答:
Vue.nextTick(cb) 方法的作用是延迟回调函数 cb 的执行,一般用于 this.key = newVal
更改数据后,想立即获取更改过后的 DOM 数据:
this.key = 'new val'
Vue.nextTick(function() {
// DOM 更新了
})
其内部的执行过程是:
this.key = 'new val
,触发依赖通知更新,将负责更新的 watcher 放入 watcher 队列面试官 问:vm.$set(obj, key, val) 做了什么?
答:
vm. s e t 用 于 向 响 应 式 对 象 添 加 一 个 新 的 p r o p e r t y , 并 确 保 这 个 新 的 p r o p e r t y 同 样 是 响 应 式 的 , 并 触 发 视 图 更 新 。 由 于 V u e 无 法 探 测 对 象 新 增 属 性 或 者 通 过 索 引 为 数 组 新 增 一 个 元 素 , 比 如 : ‘ t h i s . o b j . n e w P r o p e r t y = ′ v a l ′ ‘ 、 ‘ t h i s . a r r [ 3 ] = ′ v a l ′ ‘ 。 所 以 这 才 有 了 v m . set 用于向响应式对象添加一个新的 property,并确保这个新的 property 同样是响应式的,并触发视图更新。由于 Vue 无法探测对象新增属性或者通过索引为数组新增一个元素,比如:`this.obj.newProperty = 'val'`、`this.arr[3] = 'val'`。所以这才有了 vm. set用于向响应式对象添加一个新的property,并确保这个新的property同样是响应式的,并触发视图更新。由于Vue无法探测对象新增属性或者通过索引为数组新增一个元素,比如:‘this.obj.newProperty=′val′‘、‘this.arr[3]=′val′‘。所以这才有了vm.set,它是 Vue.set 的别名。
面试官 问:vm.$delete(obj, key) 做了什么?
答:
vm.$delete 用于删除对象上的属性。如果对象是响应式的,且能确保能触发视图更新。该方法主要用于避开 Vue 不能检测属性被删除的情况。它是 Vue.delete 的别名。
面试官 问:vm.$watch(expOrFn, callback, [options]) 做了什么?
答:
vm.$watch 负责观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。当其发生变化时,回调函数就会被执行,并为回调函数传递两个参数,第一个为更新后的新值,第二个为老值。
这里需要 注意 一点的是:如果观察的是一个对象,比如:数组,当你用数组方法,比如 push 为数组新增一个元素时,回调函数被触发时传递的新值和老值相同,因为它们指向同一个引用,所以在观察一个对象并且在回调函数中有新老值是否相等的判断时需要注意。
vm.$watch 的第一个参数只接收简单的响应式数据的键路径,对于更复杂的表达式建议使用函数作为第一个参数。
至于 vm.$watch 的内部原理是:
面试官 问:vm.$on(event, callback) 做了什么?
答:
监听当前实例上的自定义事件,事件可由 vm.触发,回调函数会接收所有传入事件触发函数(emit)的额外参数。
vm.$on 的原理很简单,就是处理传递的 event 和 callback 两个参数,将注册的事件和回调函数以键值对的形式存储到 vm._event 对象中,vm._events = { eventName: [cb1, cb2, …], … }。
面试官 问:vm.$emit(eventName, […args]) 做了什么?
答:
触发当前实例上的指定事件,附加参数都会传递给事件的回调函数。
其内部原理就是执行 vm._events[eventName]
中所有的回调函数。
备注:从 和emit 的实现原理也能看出,组件的自定义事件其实是谁触发谁监听,所以在这会儿再回头看 Vue 源码解读(2)—— Vue 初始化过程 中关于 initEvent 的解释就会明白在说什么,因为组件自定义事件的处理内部用的就是 vm.、emit。
面试官 问:vm.$off([event, callback]) 做了什么?
答:
移除自定义事件监听器,即移除 vm._events 对象上相关数据。
面试官 问:vm.$once(event, callback) 做了什么?
答:
监听一个自定义事件,但是该事件只会被触发一次。一旦触发以后监听器就会被移除。
其内部的实现原理是:
vm.$off(event, 包装函数)
移除该事件vm.$on(event, 包装函数)
注册事件面试官 问:vm._update(vnode, hydrating) 做了什么?
答:
官方文档没有说明该 API,这是一个用于源码内部的实例方法,负责更新页面,是页面渲染的入口,其内部根据是否存在 prevVnode 来决定是首次渲染,还是页面更新,从而在调用 patch 函数时传递不同的参数。该方法在业务开发中不会用到。
面试官 问:vm.$forceUpdate() 做了什么?
答:
迫使 Vue 实例重新渲染,它仅仅影响组件实例本身和插入插槽内容的子组件,而不是所有子组件。其内部原理到也简单,就是直接调用 vm._watcher.update()
,它就是 watcher.update()
方法,执行该方法触发组件更新。
面试官 问:vm.$destroy() 做了什么?
答:
负责完全销毁一个实例。清理它与其它实例的连接,解绑它的全部指令和事件监听器。在执行过程中会调用 beforeDestroy
和 destroy
两个钩子函数。在大多数业务开发场景下用不到该方法,一般都通过 v-if 指令来操作。其内部原理是:
vm.$off
方法移除所有的事件监听面试官 问:vm.$nextTick(cb) 做了什么?
答:
vm.$nextTick 是 Vue.nextTick 的别名,其作用是延迟回调函数 cb 的执行,一般用于 this.key = newVal
更改数据后,想立即获取更改过后的 DOM 数据:
this.key = 'new val'
Vue.nextTick(function() {
// DOM 更新了
})
其内部的执行过程是:
this.key = 'new val'
,触发依赖通知更新,将负责更新的 watcher 放入 watcher 队列面试官 问:vm._render 做了什么?
答:
官方文档没有提供该方法,它是一个用于源码内部的实例方法,负责生成 vnode。其关键代码就一行,执行 render 函数生成 vnode。不过其中加了大量的异常处理代码。
面试官 问:什么是 Hook Event?
答:
Hook Event 是 Vue 的自定义事件结合生命周期钩子实现的一种从组件外部为组件注入额外生命周期方法的功能。
面试官 问:Hook Event 是如果实现的?
答:
<comp @hook:lifecycleMethod="method" />
这就是 Hook Event 的实现原理。
hook:xx
格式的事件(xx 为 Vue 的生命周期函数),则将 vm._hasHookEvent
置为 true
,表示该组件有 Hook EventcallHook
方法来执行这些生命周期函数,在生命周期函数执行之后,如果发现 vm._hasHookEvent
为 true,则表示当前组件有 Hook Event,通过 vm.$emit('hook:xx')
触发 Hook Event 的执行面试官 问:简单说一下 Vue 的编译器都做了什么?
答:
Vue 的编译器做了三件事情:
面试官 问:详细说一说编译器的解析过程,它是怎么将 html 字符串模版变成 AST 对象的?
答:
如果匹配到结束标签,就从 stack 数组中拿出最后一个元素,它和当前匹配到的结束标签是一对。
再次处理开始标签上的属性,这些属性和前面处理的不一样,比如:key、ref、scopedSlot、样式等,并将处理结果放到元素的 AST 对象上
然后将当前元素和父元素产生联系,给当前元素的 ast 对象设置 parent 属性,然后将自己放到父元素的 ast 对象的 children 数组中
得到一个对象,包括 标签名(tagName)、所有的属性(attrs)、标签在 html 模版字符串中的索引位置
进一步处理上一步得到的 attrs 属性,将其变成 [{ name: attrName, value: attrVal, start: xx, end: xx }, …] 的形式
通过标签名、属性对象和当前元素的父元素生成 AST 对象,其实就是一个 普通的 JS 对象,通过 key、value 的形式记录了该元素的一些信息
接下来进一步处理开始标签上的一些指令,比如 v-pre、v-for、v-if、v-once,并将处理结果放到 AST 对象上
处理结束将 ast 对象存放到 stack 数组
处理完成后会截断 html 字符串,将已经处理掉的字符串截掉
遍历 HTML 模版字符串,通过正则表达式匹配 “<”
跳过某些不需要处理的标签,比如:注释标签、条件注释标签、Doctype。
备注:整个解析过程的核心是处理开始标签和结束标签
解析开始标签
解析闭合标签
最后遍历完整个 html 模版字符串以后,返回 ast 对象
面试官:详细说一下静态标记的过程
答:
面试官:什么样的节点才可以被标记为静态节点?
答:
面试官 问:简单说一下 Vue 的编译器都做了什么?
答:
Vue 的编译器做了三件事情:
面试官:详细说一下渲染函数的生成过程
答:
大家一说到渲染函数,基本上说的就是 render 函数,其实编译器生成的渲染有两类:
渲染函数生成的过程,其实就是在遍历 AST 节点,通过递归的方式,处理每个节点,最后生成形如:_c(tag, attr, children, normalizationType)
的结果。tag 是标签名,attr 是属性对象,children 是子节点组成的数组,其中每个元素的格式都是 _c(tag, attr, children, normalizationTYpe)
的形式,normalization 表示节点的规范化类型,是一个数字 0、1、2,不重要。
在处理 AST 节点过程中需要大家重点关注也是面试中常见的问题有:
单纯的 v-once 节点处理方式和静态节点一致
v-if 节点的处理结果是一个三元表达式
v-for 节点的处理结果是可执行的 _l 函数,该函数负责生成 v-for 节点的 vnode
组件的处理结果和普通元素一样,得到的是形如 _c(compName)
的可执行代码,生成组件的 vnode
将生成静态节点 vnode 函数放到 staticRenderFns 数组中
返回一个 _m(idx) 的可执行函数,意思是执行 staticRenderFns 数组中下标为 idx 的函数,生成静态节点的 vnode
静态节点是怎么处理的
静态节点的处理分为两步:
v-once、v-if、v-for、组件 等都是怎么处理的
第一类就是一个 render 函数,负责生成动态节点的 vnode
第二类是放在一个叫 staticRenderFns 数组中的静态渲染函数,这些函数负责生成静态节点的 vnode
到这里,Vue 编译器 的源码解读就结束了。相信大家在阅读的过程中不免会产生云里雾里的感觉。这个没什么,编译器这块儿确实是比较复杂,可以说是整个框架最难理解也是代码量最大的一部分了。一定要静下心来多读几遍,遇到无法理解的地方,一定要勤动手,通过示例代码加断点调试的方式帮助自己理解。
当你读完几遍以后,这时候情况可能就会好一些,但是有些地方可能还会有些晕,这没事,正常。毕竟这是一个框架的编译器,要处理的东西太多太多了,你只需要理解其核心思想(模版解析、静态标记、代码生成)就可以了。后面会有 手写 Vue 系列,编译器这部分会有一个简版的实现,帮助加深对这部分知识的理解。
编译器读完以后,会发现有个不明白的地方:编译器最后生成的代码都是经过 with
包裹的,比如:
<div id="app">
<div v-for="item in arr" :key="item">{{ item }}</div>
</div>
经过编译后生成:
with (this) {
return _c(
'div',
{
attrs:
{
"id": "app"
}
},
_l(
(arr),
function (item) {
return _c(
'div',
{
key: item
},
[_v(_s(item))]
)
}
),
0
)
}
都知道,with
语句可以扩展作用域链,所以生成的代码中的 _c、_l、_v、_s
都是 this 上一些方法,也就是说在运行时执行这些方法可以生成各个节点的 vnode。
所以联系前面的知识,响应式数据更新的整个执行过程就是:
响应式拦截到数据的更新
dep 通知 watcher 进行异步更新
watcher 更新时执行组件更新函数 updateComponent
首先执行 vm._render 生成组件的 vnode,这时就会执行编译器生成的函数
问题:
_c、_l、、_v、_s
等方法是什么?下一篇文章 render helper 将会带来这部分知识的详细解读,也是面试经常被问题的:比如:v-for
的原理是什么?
面试官 问:一个组件是如何变成 VNode?
答:
组件实例初始化,最后执行 $mount 进入挂载阶段
如果是只包含运行时的 vue.js,只直接进入挂载阶段,因为这时候的组件已经变成了渲染函数,编译过程通过模块打包器 + vue-loader + vue-template-compiler 完成的
如果没有使用预编译,则必须使用全量的 vue.js
挂载时如果发现组件配置项上没有 render 选项,则进入编译阶段
将模版字符串编译成 AST 语法树,其实就是一个普通的 JS 对象
然后优化 AST,遍历 AST 对象,标记每一个节点是否为静态静态;然后再进一步标记出静态根节点,在组件后续更新时会跳过这些静态节点的更新,以提高性能
接下来从 AST 生成渲染函数,生成的渲染函数有两部分组成:
接下来将渲染函数放到组件的配置对象上,进入挂载阶段,即执行 mountComponent 方法
最终负责渲染组件和更新组件的是一个叫 updateComponent 方法,该方法每次执行前首先需要执行 vm._render 函数,该函数负责执行编译器生成的 render,得到组件的 VNode
将一个组件生成 VNode 的具体工作是由 render 函数中的 _c、_o、_l、_m
等方法完成的,这些方法都被挂载到 Vue 实例上面,负责在运行时生成组件 VNode
提示:到这里首先要明白什么是 VNode,一句话描述就是 —— 组件模版的 JS 对象表现形式,它就是一个普通的 JS 对象,详细描述了组件中各节点的信息
下面说的有点多,其实记住一句就可以了,设置组件配置信息,然后通过
new VNode(组件信息)
生成组件的 VNode
_c,负责生成组件或 HTML 元素的 VNode,_c 是所有 render helper 方法中最复杂,也是最核心的一个方法,其它的 _xx 都是它的组成部分
new VNode(标签信息)
得到 VNode_l,运行时渲染 v-for 列表的帮助函数,循环遍历 val 值,依次为每一项执行 render 方法生成 VNode,最终返回一个 VNode 数组
_m,负责生成静态节点的 VNode,即执行 staticRenderFns 数组中指定下标的函数
简单总结 render helper 的作用就是:在 Vue 实例上挂载一些运行时的工具方法,这些方法用在编译器生成的渲染函数中,用于生成组件的 VNode。
好了,到这里,一个组件从初始化开始到最终怎么变成 VNode 就讲完了,最后剩下的就是 patch 阶段了,下一篇文章将讲述如何将组件的 VNode 渲染到页面上。
面试官 问:你能说一说 Vue 的 patch 算法吗?
答:
Vue 的 patch 算法有三个作用:负责首次渲染和后续更新或者销毁组件
首先是全量更新所有的属性
如果新老 VNode 都有孩子,则递归执行 updateChildren,进行 diff 过程
针对前端操作 DOM 节点的特点进行如下优化:
如果新的 VNode 有孩子,老的 VNode 没孩子,则新增这些新孩子节点
如果老的 VNode 有孩子,新的 VNode 没孩子,则删除这些老孩子节点
剩下一种就是更新文本节点
同层比较(降低时间复杂度)深度优先(递归)
而且前端很少有完全打乱节点顺序的情况,所以做了四种假设,假设新老 VNode 的开头结尾存在相同节点,一旦命中假设,就避免了一次循环,降低了 diff 的时间复杂度,提高执行效率。如果不幸没有命中假设,则执行遍历,从老的 VNode 中找到新的 VNode 的开始节点
找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置
如果老的 VNode 先于新的 VNode 遍历结束,则剩余的新的 VNode 执行新增节点操作
如果新的 VNode 先于老的 VNode 遍历结束,则剩余的老的 VNode 执行删除操纵,移除这些老节点
如果老的 VNode 是真实元素,则表示首次渲染,创建整棵 DOM 树,并插入 body,然后移除老的模版节点
如果老的 VNode 不是真实元素,并且新的 VNode 也存在,则表示更新阶段,执行 patchVnode
如果新的 VNode 不存在,老的 VNode 存在,则调用 destroy,销毁老节点
好了,到这里,Vue 源码解读系列就结束了,如果你认认真真的读完整个系列的文章,相信你对 Vue 源码已经相当熟悉了,不论是从宏观层面理解,还是某些细节方面的详解,应该都没问题。即使有些细节现在不清楚,但是当遇到问题时,你也能一眼看出来该去源码的什么位置去找答案。
到这里你可以试着在自己的脑海中复述一下 Vue 的整个执行流程。过程很重要,但 总结 才是最后的升华时刻。如果在哪个环节卡住了,可再回去读相应的部分就可以了。
图文摘自:李永宁lyn【Vue源码学习】