前言
作为一个前端工程师,三大框架中vue,因其入门简单、上手快,深受广大开发者的青睐。那你知道从new Vue()到页面渲染是如何完成的嘛?
正文
想要了解vue是如何帮我们完成页面渲染。首先我们需要了解什么是vue?
vue什么?
从vue官网我们可以了解到
Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。
vue是个渐进式框架,他基于MVVM模型。将整个应用拆分为三个部分
- M: model层,用于存放数据和实现业务逻辑
- V: view层,UI层页面渲染
- VM: ViewModel层,用于将数据变动同步到UI视图、处理用户事件并更新数据 了解了vue后我们需要知道vue的三个核心模块
vue三个核心模块
- 响应式双向数据绑定
- 模板编译
- diff算法
双向数据绑定
vue实现响应式双向数据绑定,使用的是数据劫持+发布订阅模式实现的。
数据劫持
vue2.x的数据劫持使用的是Object.defineProperty()。而vue3.x使用的是Proxy。
通过以下demo举例vue2.x的数据劫持方法
function updateView(){
console.log('更新视图')
}
// 因为数组各种api会修改数组元素,想要劫持数据,使用Object.defineProperty()无法实现。所以选择重写数组原型链,劫持api使用
// 重新定义数组原型
const oldArrayProperty = Array.prototype;
const arrProto = Object.create(oldArrayProperty);
['push', 'pop', 'shift', 'unshift', 'splice','sort', 'reverse'].forEach(method => {
arrProto[method] = function(){
updateView()
oldArrayProperty[method].call(this, ...arguments)
}
});
// 重新定义属性,监听
function defineReactive(target, key, value){
// 深度监听,如果value是引用类型会递归深度监听
observer(value)
Object.defineProperty(target,key, {
get(){
return value
},
set(newVal){
// 设置新值时也需要监听,以防止新值为引用类型。
observer(newVal)
value = newVal
updateView()
}
})
}
// 监听函数
function observer(target){
if(typeof target !=='object' || target === null){
return target
}
// 如果是数组重写原型链
if(Array.isArray(target)){
target.__proto__ = arrProto
}
for(let key in target){
defineReactive(target, key, target[key])
}
}
const data = {
name: 'John',
age: 20,
info:{
address: '北京'
},
num: [1,2,3,4,5]
}
observer(data)
data.name = '哈哈哈'
data.age = 21
data.x = '1000'
data.info.address = '上海'
data.num.push(6)
实现了数据的劫持。当数据发生变化的时候,我们应该怎么样去更新视图呢?
vue使用发布订阅模式来实现。
这时我们需要实现一个消息订阅器Dep。他负责
- 收集订阅观察者watcher。
- 数据发生改变是通知订阅观察者watcher。
我们改造代码如下
// 重新定义属性,监听
function defineReactive(target, key, value){
// 深度监听
observer(value)
const dep = new Dep()
Object.defineProperty(target,key, {
get(){
if(需要添加订阅){
dep.addSub(订阅)
}
return value
},
set(newVal){
// 设置新值时也需要监听
observer(newVal)
value = newVal
dep.notify()
}
})
}
// 发布订阅器
class Dep{
constructor(){
this.subs = []
}
addSub(sub){
this.subs.push(sub)
}
notify(){
this.subs.forEach(item =>{
item.update()
})
}
}
此时我们给数据劫持的对象实现了一个消息订阅器。
每当其中key被引用触发getter时,判断当前需不需要添加订阅,如果需要则添加订阅。
同时当某个key被修改调用setter时,我们通过订阅器触发更新事件。
接下来就是实现一个观察者。即向订阅队列push的观察者。
他有一个update方法,当key的值发生改变时。订阅器会通知所有的订阅观察者。触发update方法,通知更新。
class Watcher{
constructor(vm, key, cb){
this.vm = vm
this.key = key
this.cb = cb
this.value = this.get()
}
update(){
var value = this.vm.data[this.key];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
}
get(){
var value = this.vm.data[this.key]
return value;
}
}
实现了Watcher后,我们如何判断需要不需要在getter中添加订阅呢?
这里我们需要定义一个全局变量Dep.target。同时对getter和Watcher进行改造
// 重新定义属性,监听
function defineReactive(target, key, value){
// 深度监听
observer(value)
const dep = new Dep()
Object.defineProperty(target,key, {
get(){
if(Dep.target){
dep.addSub(Dep.target)
}
return value
},
set(newVal){
// 设置新值时也需要监听
observer(newVal)
value = newVal
dep.notify()
}
})
}
class Watcher{
constructor(vm, key, cb){
this.vm = vm
this.key = key
this.cb = cb
this.value = this.get()
}
update(){
var value = this.vm.data[this.key];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
}
get(){
Dep.target = this // 将Dep.target赋值为自己本身
var value = this.vm.data[this.key] // 调用监听器里的getter函数。向subs添加一个观察者->自己本身
Dep.target = null // 将Dep.target置为null。释放。
return value;
}
}
// 测试代码
var data = {
name: 'John'
}
observer(data)
new Watcher(this, 'name', function(newVal, oldVal){
console.log('name更新了', newVal, oldVal)
})
源码点击这里vue双向数据绑定demo
到此为止我们就实现了vue的响应式数据双向绑定。(数组api相关的数据双向绑定未实现)
进行到这里我们需要整体捋一次逻辑
- ①vue首先将data进行数据劫持,通过
Object.defineProperty()。劫持每个数据的get、set方法 - ②为当前劫持对象的每一个key声明一个消息订阅器
const dep = new Dep() - ③在get方法中添加订阅的Watcher
dep.addsub() - ④在set方法中发布更新消息
dep.notify()此时我们发现我们在实际开发中是不需要自己new Watcher(),那实际中Vue是怎么样为数据添加watcher的呢? 通过Vue的源码可知。
- vue在DOM节点挂载之前首先为当前vm实例化一个全局
watcher
// src/core/instance/lifecycle.js
// ...
callHook(vm, 'beforeMount')
// ...
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// ...
// 第二个参数可以是函数或者是string
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
// ...
callHook(vm, 'mounted')
// ...
- 实例化
watcher时会将Dep.target指向自己并触发getter
- 如果
new Watcher()第二个参数是方法,getter就是这个方法,此时getter方法值就是刚刚传入的updateComponent方法 - =>
updateComponent执行时又自动执行vm._render() - =>
_render方法执行产生对vm.data中字段的引用 - => 从而触发字段的
getter() - => 而此时
Dep.taget === watcher - => 将
watcherpush 到dep.subs中 => 完成订阅收集
// src/core/observer/dep.js
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
// src/core/observer/watcher.js
// ...
constructor(...){
// ...
this.value = this.lazy
? undefined
: this.get()
// ...
}
// ...
get(){
pushTarget(this)
// ...
try {
value = this.getter.call(vm, vm) // getter => updateComponent
}
// ...
}
总体来说就是:
⑤vue在DOM节点挂载之前首先为当前vm实例化一个全局watcher => 实例化watcher时会触发自己的getter() => 从而触发render函数 => render函数执行 => 会触发vm.data中某些字段的getter方法,完成收集订阅。
⑥当vm.data中某字段的值发生改变时,触发字段的setter方法 => 执行dep.notify() => 全局watcher.update() => 在update时会获取watcher的value(调用this.get()) => 从而触发当前watcher的getter()方法(updateComponent) => 触发render函数 => 生成新的Vnode => 渲染页面。
通过⑤和⑥我们可以发现Vue为当前vm实现一个全局的watcher,而这个watcher在实例化时,触发render函数,完成消息收集。在vm.data数据发生改变时,也是触发render函数,重新生成新的Vnode。
通过上述6步Vue实现了响应式双向数据绑定。具体流程图如官网所示
模板编译
我们在编写vue组件时。会编写一个template组件,vue是如何将我们编写的template转换为我们HTML节点的呢?
vue使用vue-template-compiler插件将我们编写的template转换为render函数
template => ast抽象语法树(正则匹配)=> 遍历ast标记静态节点 => render函数
ps:当我们使用vue-cli或webpack搭建工程化项目时。模板编译在我们运行
npm run build时已经将template转换为render函数。而当我们使用script标签引入vue开发时,模板编译是在浏览器端执行的,所以性能要差一些。
模板编译阶段结束后。render函数就已经生成了。当vue生命周期进入beforeMount时才执行render函数生成Vnode
diff算法
生成Vnode后就直接将Vnode转换成DOM元素挂载嘛?nonono,这时Vue会将新旧两个Vnode进行比较,找出最小差异进行DOM操作。
此时就会使用到diff算法,来比较两个Vnode之前的差异,用较小的性能操作DOM。
vue的diff算法采用同级比较,只和自己相同级的节点进行比较。时间复杂度为O(n)。
引用网上一张图
接下来我就开始具体讲解diff算法是如何实现比较的。 当新的Vnode生成后。会触发patch(oldVnode, Vnode) 函数开始比较。
首先我们先看下流程图。
我们可以首先看下源码。这里只展示了部分关键源码
// src/core/vdom/patch.js
function patch (oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) { // 如果Vnode不存在。oldVnode存在 直接删除oldVnode
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// ...
if (isUndef(oldVnode)) { // 如果Vnode存在,OLdVnode不存在。添加生成Vnode节点。
// ...
createElm(vnode, insertedVnodeQueue)
}else{
// 如果Vnode存在,OLdVnode也存在。判断两者是否是同一个节点isSameVnode()
// 如果两个节点是相同节点。则触发pathVnode方法。去比较新旧两个节点的子节点。
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// ...
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else{
// ...
// 如果两个节点不是相同节点,则将OldVnode节点删除,添加一个新的Vnode节点。
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// ...
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
// ...
}
}
}
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
// pathVnode 时若新旧两个节点指向同一个内存地址。则说明未发生变化。不做任何操作
if (oldVnode === vnode) {
return
}
// ...
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 若Vnode子节点不是文本节点。且Vnode和OldVnode都存在子元素节点。则触发updateChildren()方法,这个方法很复杂。放在后边讲解。
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 若Vnode子节点不是文本节点。且只有Vnode存在子元素节点,则为Vnode添加子节点。并清空文本节点。
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 若Vnode子节点不是文本节点。且只有OldVnode存在子元素节点,则删除OldVnode的子节点。
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 若Vnode子节点不是文本节点。且Vnode和OldVnode都不存在子元素节点,且oldVnode存在文本节点。则清空文本节点。
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 判断Vnode子节点是否是文本节点。如果是文本节点且文本内容和OldVnode文本节点内容不相同,更新Vnode子节点文本内容。
nodeOps.setTextContent(elm, vnode.text)
}
// ...
}
通过流程图和源码分析。我们可以将整个流程梳理下
- 执行patch(OldVnode, Vnode)
- 如果Vnode不存在,OldVnode存在。则直接删除OldVnode。
- 如果Vnode存在,OLdVnode不存在。添加生成Vnode节点。
- 如果Vnode存在,OLdVnode也存在。判断两者是否是同一个节点isSameVnode()
- isSameVnode会判断两个节点tag key 等其他值是否相同。此处的key就是我们在v-for时传入的key。经典面试题为什么v-for中的key不要使用index。因为在patch时会通过key去判断新旧节点是否为同一个节点。而使用index后元素更改后index值不变,会认为两个节点是同一个节点然后再去执行其他比较代码,增加了path过程,性能会很差。
- 如果两个节点不是相同节点,则将OldVnode节点删除,添加一个新的Vnode节点。
- 如果两个节点是相同节点。则触发patchVnode方法。去比较新旧两个节点的子节点。
- patchVnode 时若新旧两个节点指向同一个内存地址。则说明未发生变化。不做任何操作
- 判断Vnode子节点是否是文本节点。如果是文本节点且文本内容和OldVnode文本节点内容不相同,更新Vnode子节点文本内容。
- 若Vnode子节点不是文本节点。且Vnode和OldVnode都存在子元素节点。则触发updateChildren()方法,这个方法很复杂。放在后边讲解。
- 若Vnode子节点不是文本节点。且只有Vnode存在子元素节点,则为Vnode添加子节点。并清空文本节点。
- 若Vnode子节点不是文本节点。且只有OldVnode存在子元素节点,则删除OldVnode的子节点。
- 若Vnode子节点不是文本节点。且Vnode和OldVnode都不存在子元素节点,且oldVnode存在文本节点。则清空文本节点。
接着我们梳理updateChildren方法
那么这个函数做了什么操作呢?
- 将Vnode的子节点Vch和oldVnode的子节点oldCh提取出来
- oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。(s1<=>s2,e1<=>e2,s1<=>e2,e1<=>s2)如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。
- 当结束比较时如果是oldCh已经完成遍历。那就说明有新增元素。将新节点按照index挂载到相应位置
- 当结束比较时如果是vCh已经完成遍历。那就是说明删除了部分元素,那么就在真实dom中将区间为[oldStartIdx, oldEndIdx]的多余节点删掉。
大家可以阅读这篇文章,详细了解下updateChildren函数。
到此为止。Vue就将整个DOM渲染到页面上啦!
Vue的三个核心内容也就全部讲完啦!
关注公众号
喜欢的同学可以关注公众号