前端开发之从new Vue()到放弃

1,045 阅读11分钟

前言

作为一个前端工程师,三大框架中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()。劫持每个数据的getset方法
  • 为当前劫持对象的每一个key声明一个消息订阅器const dep = new Dep()
  • 在get方法中添加订阅的Watcherdep.addsub()
  • 在set方法中发布更新消息 dep.notify() 此时我们发现我们在实际开发中是不需要自己new Watcher(),那实际中Vue是怎么样为数据添加watcher的呢? 通过Vue的源码可知。
  1. 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')
// ...
  1. 实例化watcher时会将Dep.target指向自己并触发getter
  • 如果new Watcher()第二个参数是方法,getter就是这个方法,此时getter方法值就是刚刚传入的updateComponent方法
  • => updateComponent执行时又自动执行vm._render()
  • => _render方法执行产生对vm.data中字段的引用
  • => 从而触发字段的getter()
  • => 而此时Dep.taget === watcher
  • => 将watcher push 到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时会获取watchervalue(调用this.get()) => 从而触发当前watchergetter()方法(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的三个核心内容也就全部讲完啦!

关注公众号

喜欢的同学可以关注公众号