vue源码学习(数据侦测、虚拟DOM、Diff算法)

深入剖析Vue框架源码,涵盖组件构建、变化检测、依赖收集、虚拟DOM生成与更新机制,揭示数据驱动视图的核心原理。

vue源码

├─dist                   # 项目构建后的文件
├─scripts                # 与项目构建相关的脚本和配置文件
├─flow                   # flow的类型声明文件
├─src                    # 项目源代码
│    ├─complier          # 与模板编译相关的代码
│    ├─core              # 通用的、与运行平台无关的运行时代码
│    │  ├─observe        # 实现变化侦测的代码
│    │  ├─vdom           # 实现virtual dom的代码
│    │  ├─instance       # Vue.js实例的构造函数和原型方法
│    │  ├─global-api     # 全局api的代码
│    │  └─components     # 内置组件的代码
│    ├─server            # 与服务端渲染相关的代码
│    ├─platforms         # 特定运行平台的代码,如weex
│    ├─sfc               # 单文件组件的解析代码
│    └─shared            # 项目公用的工具代码
└─test                   # 项目测试代码
变化检测

变化检测追踪状态,数据变化响应视图

angular通过脏值检测 react对比虚拟DO

Object的变化侦测

变化检测通过object.definProperty()来检测对象的读写

let car = {
  'brand':'BMW',
  'price':3000
}

let car = {}
let val = 3000
Object.defineProperty(car, 'price', {
  enumerable: true,
  configurable: true,
  get(){
    console.log('price属性被读取了')
    return val
  },
  set(newVal){
    console.log('price属性被修改了')
    val = newVal
  }
})

源码中Observer类会通过递归的方式吧对象所有属性都转为可监测对象

// 源码位置:src/core/observer/index.js

/**
 * Observer类会通过递归的方式把一个对象的所有属性都转化成可观测对象
 */
export class Observer {
  constructor (value) {
    this.value = value
    // 给value新增一个__ob__属性,值为该value的Observer实例
    // 相当于为value打上标记,表示它已经被转化成响应式了,避免重复操作
    def(value,'__ob__',this)
    if (Array.isArray(value)) {
      // 当value为数组时的逻辑
      // ...
    } else {
      this.walk(value)
    }
  }

  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
}
/**
 * 使一个对象转化成可观测对象
 * @param { Object } obj 对象
 * @param { String } key 对象的key
 * @param { Any } val 对象的某个key的值
 */
function defineReactive (obj,key,val) {
  // 如果只传了obj和key,那么val = obj[key]
  if (arguments.length === 2) {
    val = obj[key]
  }
  if(typeof val === 'object'){
      new Observer(val)
  }
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      console.log(`${key}属性被读取了`);
      return val;
    },
    set(newVal){
      if(val === newVal){
          return
      }
      console.log(`${key}属性被修改了`);
      val = newVal;
    }
  })
}
依赖收集

object可观测之后知道数据什么时候发生变化,当数据变化时去通知视图进行更新,问题来了,通知谁变化呢?不能数据变就全部更新吧,谁用到就更新谁

  • 什么是依赖收集

    每个数据建一个依赖数组,谁依赖就添加进去,当这个数据变化的时候就去对应的依赖数组中,把所有的依赖都通知一遍

  • 什么时候收集依赖?什么时候通知依赖更新?

    谁获取谁依赖,所以在getter中收集依赖,同时,当这个数据触发setter时,会通知所有的依赖更新视图

  • 依赖收集到哪里

    依赖如果放在一个数组,功能有欠缺而且代码过于耦合,所以给每一个数据都建立一个依赖管理器Dep

    // 源码位置:src/core/observer/dep.js
    export default class Dep {
      constructor () {
        this.subs = []
      }
    
      addSub (sub) {
        this.subs.push(sub)
      }
      // 删除一个依赖
      removeSub (sub) {
        remove(this.subs, sub)
      }
      // 添加一个依赖
      depend () {
        if (window.target) {
          this.addSub(window.target)
        }
      }
      // 通知所有依赖更新
      notify () {
        const subs = this.subs.slice()
        for (let i = 0, l = subs.length; i < l; i++) {
          subs[i].update()
        }
      }
    }
    
    /**
     * Remove an item from an array
     */
    export function remove (arr, item) {
      if (arr.length) {
        const index = arr.indexOf(item)
        if (index > -1) {
          return arr.splice(index, 1)
        }
      }
    }
    
    function defineReactive (obj,key,val) {
      if (arguments.length === 2) {
        val = obj[key]
      }
      if(typeof val === 'object'){
        new Observer(val)
      }
      const dep = new Dep()  //实例化一个依赖管理器,生成一个依赖管理数组dep
      Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get(){
          dep.depend()    // 在getter中收集依赖
          return val;
        },
        set(newVal){
          if(val === newVal){
              return
          }
          val = newVal;
          dep.notify()   // 在setter中通知依赖更新
        }
      })
    }
    
  • 依赖到底是谁

    Vue中还实现了一个Watcher类 ,谁用到数据,谁就是依赖,就为谁创建一个Watcher实例。之后数据变化时不直接通知依赖更新,而是通知依赖对应的Watch实例,由Watcher实例去通知真正的视图

    export default class Watcher {
      constructor (vm,expOrFn,cb) {
        this.vm = vm;
        this.cb = cb;
        this.getter = parsePath(expOrFn)
        this.value = this.get()
      }
      get () {
        window.target = this;
        const vm = this.vm
        let value = this.getter.call(vm, vm)
    	// 相当于this.getter(this.vm)    
        window.target = undefined;
        return value
      }
      update () {
        const oldValue = this.value
        this.value = this.get()
        this.cb.call(this.vm, this.value, oldValue)
      }
    }
    
    /**
     * Parse simple path.
     * 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
     * 例如:
     * data = {a:{b:{c:2}}}
     * parsePath('a.b.c')(data)  // 2
     */
    const bailRE = /[^\w.$]/
    export function parsePath (path) {
      if (bailRE.test(path)) {
        return
      }
      const segments = path.split('.')
      return function (obj) {
        for (let i = 0; i < segments.length; i++) {
          if (!obj) return
          obj = obj[segments[i]]
        }
        return obj
      }
    }
    

    Watcher代码实现逻辑

    1. 实例化Watcher调用构造函数
    2. 构造函数调用get方法
    3. get方法中 通过window.target = this 把实例赋值给全局唯一对象,然后通过let value = this.getters.call(vm, vm)获取被依赖的数据目的是触发数据上的getter 然后数据上的getter通过window.target存入依赖数组 在最后将window.target释放掉
    4. 数据变化会触发setter在setter中遍历依赖 调用所有watcher中update方法,watcher中的update方法调用构造函数中传的回调函数cb
不足之处

通过Object.defineProperty方法实现对object数据的可观测,但是这个方法只能观测到object数据的取值和设置值,如果向object添加或者删除新的key/value时,它无法观测到,也就无法通知依赖更新视图

vue中通过增加两个全局API:Vue.set和Vue.delete来解决这个问题

总结

整个流程

1. Data通过object.defineProperty方法实现数据观测,通过observe类递归转换为getter/setter的形式追踪变化
2. 外界通过watcher读取数据 触发getter添加到依赖列表Dep类中
3. 当数据变化会触发setter 从而遍历dep通知watcher,调用update方法,watcher又通过调用回调函数 发送更改通知
Array的变化侦测

因为对象是通过object.defineProperty而数组没有,但是其实收集方式还是相同的

data(){
  return {
    arr:[1,2,3]
  }
}

只要使用arr这个数据还是从object数据对象中获取,同样是在getter中收集依赖

使Array数据可观测

object变化是通过setter来追踪 Array变化必然操作了数组,所以把操作数组的方法都重写一遍,不改变原有功能下进行扩展

let arr = [1,2,3]
arr.push(4)
Array.prototype.newPush = function(val){
  console.log('arr被修改了')
  this.push(val)
}
arr.newPush(4)
  1. 数组方法拦截器

    Vue中创建了一个数组方法拦截器,拦截在数组实例与数组原型之间,重写数组方法,数组实例使用实例方法的时候使用的是拦截器里的方法,而不是使用Array.prototype 上的原生方法

    Array原型中可以改变数组自身内容的方法有七种:push pop shift unshift splice sort reserve

    // 源码位置:/src/core/observer/array.js
    
    const arrayProto = Array.prototype
    // 创建一个对象作为拦截器
    export const arrayMethods = Object.create(arrayProto)
    
    // 改变数组自身内容的7个方法
    const methodsToPatch = [
      'push',
      'pop',
      'shift',
      'unshift',
      'splice',
      'sort',
      'reverse'
    ]
    
    /**
     * Intercept mutating methods and emit events
     */
    methodsToPatch.forEach(function (method) {
      const original = arrayProto[method]      // 缓存原生方法
      Object.defineProperty(arrayMethods, method, {
        enumerable: false,
        configurable: true,
        writable: true,
        value:function mutator(...args){
          const result = original.apply(this, args)
          return result
        }
      })
    })
    

    浅拷贝原型方法方法, 遍历改变数组的方法 缓存原生方法 然后在拷贝的原型上调用object.defineProperty改变value,在value里面重写方法

  2. 使用拦截器

    拦截器做好之后 需要挂载到数组实例和Array.prototypr之间,这样拦截器才能够生效 挂载不难,只需要把数据_proto_ 属性设置为拦截器arrayMethods即可

    // 源码位置:/src/core/observer/index.js
    export class Observer {
      constructor (value) {
        this.value = value
        if (Array.isArray(value)) {
          const augment = hasProto
            ? protoAugment
            : copyAugment
          augment(value, arrayMethods, arrayKeys)
        } else {
          this.walk(value)
        }
      }
    }
    // 能力检测:判断__proto__是否可用,因为有的浏览器不支持该属性
    export const hasProto = '__proto__' in {}
    
    const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
    
    /**
     * Augment an target Object or Array by intercepting
     * the prototype chain using __proto__
     */
    function protoAugment (target, src: Object, keys: any) {
      target.__proto__ = src
    }
    
    /**
     * Augment an target Object or Array by defining
     * hidden properties.
     */
    /* istanbul ignore next */
    function copyAugment (target: Object, src: Object, keys: Array<string>) {
      for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        def(target, key, src[key])
      }
    }
    

    判断是否支持_proto_,如果支持,调用protoAugment 则把value的_proto__指向为arrayMethods 否则就循环添加到value上面

再谈依赖收集

Array的依赖收集也在observe类中收集依赖

  • 收集到哪里
// 源码位置:/src/core/observer/index.js
export class Observer {
  constructor (value) {
    this.value = value
    this.dep = new Dep()    // 实例化一个依赖管理器,用来收集数组依赖
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
}
  • 如何收集依赖

    数组的依赖在getter中收集,那么如何收集呢?依赖管理器定义在Observer类中,而需要在getter中收集依赖,所以说必须在getter中能够访问到observer中的依赖管理器,才能把依赖存进去

function defineReactive (obj,key,val) {
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      if (childOb) {
        childOb.dep.depend()
      }
      return val;
    },
    set(newVal){
      if(val === newVal){
        return
      }
      val = newVal;
      dep.notify()   // 在setter中通知依赖更新
    }
  })
}

/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 * 尝试为value创建一个0bserver实例,如果创建成功,直接返回新创建的Observer实例。
 * 如果 Value 已经存在一个Observer实例,则直接返回它
 */
export function observe (value, asRootData){
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

首先通过observe函数判断有没有_ob_这个属性如果有并且是指向Observer类那么返回observer实例没有就创建observer实例

在defineReactive函数中先调用observe函数获取 observer实例 在getter中通过实例添加依赖

  • 如何通知依赖

    依赖收集之后,在拦截器里通知依赖,想要通知依赖要先访问到依赖,访问依赖只需要访问observer实例 然后调用实例依赖管理器上的notify方法去通知依赖更新就行

    /**
     * Intercept mutating methods and emit events
     */
    methodsToPatch.forEach(function (method) {
      const original = arrayProto[method]
      def(arrayMethods, method, function mutator (...args) {
        const result = original.apply(this, args)
        const ob = this.__ob__
        // notify change
        ob.dep.notify()
        return result
      })
    })
    
深度侦测

之前讲的都是数组自身变化的侦测,比如给数组新增或删除数组中 的一个元素,但是在vue中不论是object还是Array数据实现的数据侦测都是深度侦测,即同时要侦测所有子数据的变化

let arr = [
  {
    name:'NLRX',
    age:'18'
  }
]
export class Observer {
  value: any;
  dep: Dep;

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      const augment = hasProto
        ? protoAugment
        : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)   // 将数组中的所有元素都转化为可被侦测的响应式
    } else {
      this.walk(value)
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

export function observe (value, asRootData){
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
}

对于Array数据 调用了observeArray方法 遍历数组每个元素 通过observe函数把每一个元素转为可侦测的响应式数据

而object 已经在defineReactive中进行了递归操作

数据新增元素的侦测

对于数组已经可以将其全部转化为响应式数据,但是如果向数组里面增加元素的话,也需要将新增的元素转化为可侦测的响应式数据

只需要拿到新增的这个元素,然后调用observe函数将其转化即可。新增元素的方法有三个,分别是push、unshift、splice只需对这三个函数分别处理,拿到新增元素转化为响应式数据即可

/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args   // 如果是push或unshift方法,那么传入参数就是新增的元素
        break
      case 'splice':
        inserted = args.slice(2) // 如果是splice方法,那么传入参数列表中下标为2的就是新增的元素
        break
    }
    if (inserted) ob.observeArray(inserted) // 调用observe函数将新增的元素转化成响应式
    // notify change
    ob.dep.notify()
    return result
  })
})
不足之处

通过索引修改数据的值时 数组无法侦测到 Vue通过增加两个全局api: Vue.set和Vue.delete 实现

汇总

Observer构造函数 先进行赋值 value 然后创建依赖存储类Dep实例,挂载__ob__属性,然后判断是否是数组,如果是再根据是否有__proto__属性,如果有就把数组的__proto_属性指向重写部分方法之后的数组原型,否则通过循环挂载在value上, 重写的方法包括改变数组的七种方法push pop shift unshift splice sort reserve,同时为了保证新增的元素也能变成响应式,还需要调用observer类上面的observeArray函数,需要重写的时候还要进行判断是否是新增元素的方法 push unshift splice,在重写方法中调用observer实例上的依赖存储实例dep的notify方法,notify方法中遍历通知依赖数据的watcher 调用watcher的update方法。如果不是数组则通过调用walk方法遍历对象调用defineReactive方法 创建依赖集合实例, 创建Observer实例 如果有就直接取没有就new, object.defineProperty() 修改get方法 通过调用getter 之后检查Dep上的target 是否存在 如果有就添加到 依赖数组 如果存在childOb实例就把依赖也添加到依赖数组 如果是依赖数组则遍历添加,set中通过通知dep依赖集合实例 调用notify方法

虚拟DOM
##### 	什么是虚拟DOM

​ 所谓虚拟dom就是用js对象来描述一个DOM节点

<div class="a" id="b">我是内容</div>

{
  tag:'div',        // 元素标签
  attrs:{           // 属性
    class:'a',
    id:'b'
  },
  text:'我是内容',  // 文本内容
  children:[]       // 子元素
}

这个js对象就称为这个真是dom节点的虚拟dom节点

为什么要有虚拟dom

Vue是数据驱动视图,数据改变视图就要更新,更新视图的时候难免要操作DOM,而在操作真实dom时非常耗费性能,这是因为浏览器的标准把dom设计的非常复杂,一个dom元素非常庞大

let div = document.createElement('div')
let str = ''
for (const key in div) {
  str += key + ''
}
console.log(str)

img

解决方法是更新视图的时候尽可能少操作DOM,用js的计算性能换取dom操作所消耗的性能,最直观的思路就是对比数据变化前后的状态,计算图中哪些地方需要更新,只更新需要更新的地方,其余地方不变,我们可以通过JS模拟一个DOM节点,称为虚拟DOM节点,当数据发生变化时,对比前后的虚拟DOM节点,通过DOM_DIFF算法计算出需要更新的地方然后去更新需要更新的视图

vue中的虚拟DOM
  1. VNode类

    vue中存在一个VNode类,通过这个类可以实例化不同类型的虚拟DOM节点

    // 源码位置:src/core/vdom/vnode.js
    
    export default class VNode {
      constructor (
        tag?: string,
        data?: VNodeData,
        children?: ?Array<VNode>,
        text?: string,
        elm?: Node,
        context?: Component,
        componentOptions?: VNodeComponentOptions,
        asyncFactory?: Function
      ) {
        this.tag = tag                                /*当前节点的标签名*/
        this.data = data        /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
        this.children = children  /*当前节点的子节点,是一个数组*/
        this.text = text     /*当前节点的文本*/
        this.elm = elm       /*当前虚拟节点对应的真实dom节点*/
        this.ns = undefined            /*当前节点的名字空间*/
        this.context = context          /*当前组件节点对应的Vue实例*/
        this.fnContext = undefined       /*函数式组件对应的Vue实例*/
        this.fnOptions = undefined
        this.fnScopeId = undefined
        this.key = data && data.key           /*节点的key属性,被当作节点的标志,用以优化*/
        this.componentOptions = componentOptions   /*组件的option选项*/
        this.componentInstance = undefined       /*当前节点对应的组件的实例*/
        this.parent = undefined           /*当前节点的父节点*/
        this.raw = false         /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
        this.isStatic = false         /*静态节点标志*/
        this.isRootInsert = true      /*是否作为跟节点插入*/
        this.isComment = false             /*是否为注释节点*/
        this.isCloned = false           /*是否为克隆节点*/
        this.isOnce = false                /*是否有v-once指令*/
        this.asyncFactory = asyncFactory
        this.asyncMeta = undefined
        this.isAsyncPlaceholder = false
      }
    
      get child (): Component | void {
        return this.componentInstance
      }
    }
    

    通过不同属性描述各种类型的真实DOM节点

  2. VNode的类型

    不同属性的搭配,可以描述几种类型的节点

    • 注释节点
    • 文本节点
    • 元素节点
    • 组件节点
    • 函数式组件节点
    • 克隆节点

    2.1 注释节点

    // 创建注释节点
    export const createEmptyVNode = (text: string = '') => {
      const node = new VNode()
      node.text = text
      node.isComment = true
      return node
    }
    

    2.2 文本节点

    // 创建文本节点
    export function createTextVNode (val: string | number) {
      return new VNode(undefined, undefined, undefined, String(val))
    }
    

    2.3 克隆节点

    // 创建克隆节点
    export function cloneVNode (vnode: VNode): VNode {
      const cloned = new VNode(
        vnode.tag,
        vnode.data,
        vnode.children,
        vnode.text,
        vnode.elm,
        vnode.context,
        vnode.componentOptions,
        vnode.asyncFactory
      )
      cloned.ns = vnode.ns
      cloned.isStatic = vnode.isStatic
      cloned.key = vnode.key
      cloned.isComment = vnode.isComment
      cloned.fnContext = vnode.fnContext
      cloned.fnOptions = vnode.fnOptions
      cloned.fnScopeId = vnode.fnScopeId
      cloned.asyncMeta = vnode.asyncMeta
      cloned.isCloned = true
      return cloned
    }
    

    克隆节点就是把节点属性复制一遍,isClone属性为true

    2.4 元素节点

    元素节点更贴近于真实DOM 描述节点属性有tag等, 元素节点情况复杂没有写死

    // 真实DOM节点
    <div id='a'><span>难凉热血</span></div>
    
    // VNode节点
    {
      tag:'div',
      data:{},
      children:[
        {
          tag:'span',
          text:'难凉热血'
        }
      ]
    }
    

    2.5 组件节点

    组件节点除了元素节点具有的属性之外 还有两个特有的属性:

    • componentOptions: 组件的option选项,如组件的props等
    • componentInstance: 当前组件节点对应的Vue实例

    2.6 函数式组件节点

    函数式组件节点相对于组件节点,又有两个特有的属性:

    • fnContext: 函数是组建对应的Vue实例
    • fnOptions: 组件的option选项
  3. VNode的作用

    在视图渲染前,把写好的template模板先编译成VNode并缓存,等到数据发生变化页面需要重新渲染的时候,等数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异,然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,完成一次视图更新

Vue中的DOM-DIFF

DOM-DIFF算法是整个虚拟DOM的核心

patch

Vue中,把DOM-DIFF过程叫patch过程。patch意思为补丁,指对旧的VNode修补,打补丁从而得到新的VNode。patch过程中主要是在新的VNode基础上,更新旧的oldVNode,整个patch主要三件事

  • 创建节点: 新的VNode 中有而旧的oldVNode中没有
  • 删除节点
  • 更新节点
创建节点

VNode可以描述6种类型的节点,而实际上只有三种类型的节点能够被创建并插入到DOM中,它们分别是:元素节点,文本节点,注释节点。所以Vue在创建节点的时候会判断新的Vnode中有而旧的中没有的这个节点是属于哪种类型,从而嗲用不同的方法创建并插入到DOM中

// 源码位置: /src/core/vdom/patch.js
function createElm (vnode, parentElm, refElm) {
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      	vnode.elm = nodeOps.createElement(tag, vnode)   // 创建元素节点
        createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
        insert(parentElm, vnode.elm, refElm)       // 插入到DOM中
    } else if (isTrue(vnode.isComment)) {
      vnode.elm = nodeOps.createComment(vnode.text)  // 创建注释节点
      insert(parentElm, vnode.elm, refElm)           // 插入到DOM中
    } else {
      vnode.elm = nodeOps.createTextNode(vnode.text)  // 创建文本节点
      insert(parentElm, vnode.elm, refElm)           // 插入到DOM中
    }
  }
  • 判断是否为元素节点只需判断tag标签 如果是就调用createElement,有子节点就递归创建好之后insert插入当前元素节点 最后插入到DOM中
  • 判断是否为注释节点,只需判断VNode的isComment属性是否为true即可,是就调用createComment方法创建注释节点,再插入到DOM中
  • 如果两个都不是就调用createTextNode创建文本节点

nodeOps是为了Vue的跨平台兼容性,对节点操作进行了封装。例如nodeOps.createTextNode() 在浏览器端等同于document.createTextNode()

删除节点

删除节点只需要调用父节点的removeChild方法

function removeNode (el) {
    const parent = nodeOps.parentNode(el)  // 获取父节点
    if (isDef(parent)) {
      nodeOps.removeChild(parent, el)  // 调用父节点的removeChild方法
    }
  }
更新节点
<p>我是不会变化的文字</p>

这个节点只包含纯文字没有变量,所以数据改变和它没有关系,这种节点称之为静态节点

更新节点的时候需要对以下三种情况进行判断并分别处理

  1. 如果新旧节点都是静态节点

    和数据无关,无需处理

  2. 如果VNode为文本节点

    如果VNode是文本节点,说明这个接地那只包含纯文本,那么只需要看oldVNode是否也是文本节点,如果是,就比较文本是否不同,如果不同就更改文本,如果不是文本节点就直接调用setTextNode方法改为文本就点

  3. 如果VNode是元素节点

    如果VNode是元素节点

    • 包含子节点

      如果新的节点包含子节点,那么此时要看旧的节点是否包含子节点,如果旧的节点里也包含子节点,就要递归对比更新子节点;如果旧的节点里不包含子节点,那么旧节点就可能是空节点或者文本节点,如果旧的是空节点就把新的节点里的子节点创建一份然后插入到旧的节点里面,如果是文本节点就清空文本,然后插入子节点

    • 不包含子节点

      不包含子节点又不是文本节点。说明是空节点,直接清空就行

    // 更新节点
    function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
      // vnode与oldVnode是否完全一样?若是,退出程序
      if (oldVnode === vnode) {
        return
      }
      const elm = vnode.elm = oldVnode.elm
    
      // vnode与oldVnode是否都是静态节点?若是,退出程序
      if (isTrue(vnode.isStatic) &&
        isTrue(oldVnode.isStatic) &&
        vnode.key === oldVnode.key &&
        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
      ) {
        return
      }
    
      const oldCh = oldVnode.children
      const ch = vnode.children
      // vnode有text属性?若没有:
      if (isUndef(vnode.text)) {
        // vnode的子节点与oldVnode的子节点是否都存在?
        if (isDef(oldCh) && isDef(ch)) {
          // 若都存在,判断子节点是否相同,不同则更新子节点
          if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
        }
        // 若只有vnode的子节点存在
        else if (isDef(ch)) {
          /**
           * 判断oldVnode是否有文本?
           * 若没有,则把vnode的子节点添加到真实DOM中
           * 若有,则清空Dom中的文本,再把vnode的子节点添加到真实DOM中
           */
          if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        }
        // 若只有oldnode的子节点存在
        else if (isDef(oldCh)) {
          // 清空DOM中的子节点
          removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        }
        // 若vnode和oldnode都没有子节点,但是oldnode中有文本
        else if (isDef(oldVnode.text)) {
          // 清空oldnode文本
          nodeOps.setTextContent(elm, '')
        }
        // 上面两个判断一句话概括就是,如果vnode中既没有text,也没有子节点,那么对应的oldnode中有什么就清空什么
      }
      // 若有,vnode的text属性与oldVnode的text属性是否相同?
      else if (oldVnode.text !== vnode.text) {
        // 若不相同:则用vnode的text替换真实DOM的文本
        nodeOps.setTextContent(elm, vnode.text)
      }
    }
    
更新子节点
###### 更新子节点

当新旧节点都有子节点时,则两个节点的VNode实例上的children属性为包含的子节点数组,通过内外层循环,外层循环新节点内层循环旧节点,查找有没有和新节点子节点数组相同的子节点

for (let i = 0; i < newChildren.length; i++) {
  const newChild = newChildren[i];
  for (let j = 0; j < oldChildren.length; j++) {
    const oldChild = oldChildren[j];
    if (newChild === oldChild) {
      // ...
    }
  }
}

以上过程会存在四种情况

  • 创建子节点

    如果在newChildren里面的某个子节点没有在oldChildren中找到,那么说明是新增的节点,那么创建子节点

  • 删除子节点

    如果newChildren里面的所有子节点都循环完毕后,oldChildren还有未处理的子节点,那就说明未处理子节点需要废弃,将这些节点删除

  • 移动子节点

    如果newChildren中某个子节点在oldChildren中位置不同,说明需要调整位置到newChildren的位置

  • 更新子节点

    如果newChidren某个子节点在oldChildren里找到相同子节点且位置相同,那么就更新oldChildren里该节点,使之与newChildren里的该节点相同

创建子节点

创建节点方法和之前相同,循环时oldChildren子节点如果被处理,则标记为已处理, 插入节点是要注意是插入到所有未处理节点之前

删除子节点

方法同之前

更新子节点

同之前

移动子节点

同样是移动到所有未处理节点之前

// 源码位置: /src/core/vdom/patch.js

if (isUndef(idxInOld)) {    // 如果在oldChildren里找不到当前循环的newChildren里的子节点
    // 新增节点并插入到合适位置
    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
    // 如果在oldChildren里找到了当前循环的newChildren里的子节点
    vnodeToMove = oldCh[idxInOld]
    // 如果两个节点相同
    if (sameVnode(vnodeToMove, newStartVnode)) {
        // 调用patchVnode更新节点
        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
        oldCh[idxInOld] = undefined
        // canmove表示是否需要移动节点,如果为true表示需要移动,则移动节点,如果为false则不用移动
        canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
    }
}
优化更新子节点

双层循环在大数据量时,时间复杂度很大,不利于性能提升

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jMuMeJby-1596438477690)(https://vue-js.com/learn-vue/assets/img/15.e9bdf5c1.png)]

// 循环更新子节点
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0               // oldChildren开始索引
    let oldEndIdx = oldCh.length - 1   // oldChildren结束索引
    let oldStartVnode = oldCh[0]        // oldChildren中所有未处理节点中的第一个
    let oldEndVnode = oldCh[oldEndIdx]   // oldChildren中所有未处理节点中的最后一个

    let newStartIdx = 0               // newChildren开始索引
    let newEndIdx = newCh.length - 1   // newChildren结束索引
    let newStartVnode = newCh[0]        // newChildren中所有未处理节点中的第一个
    let newEndVnode = newCh[newEndIdx]  // newChildren中所有未处理节点中的最后一个

    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    // 以"新前"、"新后"、"旧前"、"旧后"的方式开始比对节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // 如果oldStartVnode不存在,则直接跳过,比对下一个
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 如果新前与旧前节点相同,就把两个节点进行patch更新
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 如果新后与旧后节点相同,就把两个节点进行patch更新
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // 如果新后与旧前节点相同,先把两个节点进行patch更新,然后把旧前节点移动到oldChilren中所有未处理节点之后
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        // 如果新前与旧后节点相同,先把两个节点进行patch更新,然后把旧后节点移动到oldChilren中所有未处理节点之前
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 如果不属于以上四种情况,就进行常规的循环比对patch
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        // 如果在oldChildren里找不到当前循环的newChildren里的子节点
        if (isUndef(idxInOld)) { // New element
          // 新增节点并插入到合适位置
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 如果在oldChildren里找到了当前循环的newChildren里的子节点
          vnodeToMove = oldCh[idxInOld]
          // 如果两个节点相同
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 调用patchVnode更新节点
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined
            // canmove表示是否需要移动节点,如果为true表示需要移动,则移动节点,如果为false则不用移动
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      /**
       * 如果oldChildren比newChildren先循环完毕,
       * 那么newChildren里面剩余的节点都是需要新增的节点,
       * 把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
       */
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      /**
       * 如果newChildren比oldChildren先循环完毕,
       * 那么oldChildren里面剩余的节点都是需要删除的节点,
       * 把[oldStartIdx, oldEndIdx]之间的所有节点都删除
       */
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }

新前和旧前对比相同就指数同时加1 否则判断 新后和旧后如果相同就指数同时减一,否则判断 新后与旧前如果相同就 oldStartIdx加1 newEndIdx减1 否则判断 新前和旧后 如果不同就按正常的循环找到相同节点 如果最后oldStartIdx>oldEndIdx说明newChildren里的节点<newStartIdx, odlStartIdx>需要修改新增 如果最后 newStartIdx > newEndIdx说明oldChildren里多余 的节点需要删除

模板编译篇
前言

在之前介绍虚拟DOM和DOM的patch(DOM-DIFF)过程,而虚拟DOM存在的必要条件是得先有VNode,VNode从何而来?把用户写的模板进行编译就会产生VNode

什么是模板编译

vue会把用户在template标签中写的类似于原生Html的内容进行编译,把原生HTML内容找出来,再把非原生HTML找出来,经过一系列的逻辑处理生成渲染函数,也就是render函数,而render函数会把模板内容生成对应的VNode,而VNode再经过patch过程从而得到将要渲染的视图中的VNode 最后根据VNodec创建真实的DOM节点并插入到视图中 最终完成视图的渲染更新

render函数的这一过程称之为模板编译过程

整体渲染流程

img

模板编译内部流程
抽象语法树AST

所谓抽象语法树,在计算机科学中,抽象语法树,是源代码语法结构的一种抽象表示,它以树状的形式表现编程语言的语法结构。树上的每个节点都表示源代码中的一种结构,之所以说语法是抽象的,是因为这里的语法并不会表示出真实语法中的每个细节,比如,嵌套括号被隐含在树的结构中,没有以节点的形式呈现;而类似于if-condition then 这样的条件语句 可以使用带有两个分支的节点来表示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rWJKjUC8-1596438477694)(https://vue-js.com/learn-vue/assets/img/2.5596631a.png)]

具体流程

将一堆字符串模板解析成抽象语法树后,就可以进行各种操作处理,处理完后用处理的AST来生成render函数,流程大致分为三个阶段

  1. 模板解析阶段:一堆模板字符串用正则等方式解析成抽象语法树AST
  2. 优化阶段:遍历AST,找出静态节点,打上标记
  3. 代码生成阶段: 将AST转换为渲染函数

三个阶段分别对应三个模块,源码路径:

  1. 模板解析阶段——解析器——源码路径:src/compiler/parser/index.js;
  2. 优化阶段——优化器——源码路径:src/compiler/optimizer.js;
  3. 代码生成阶段——代码生成器——源码路径:src/compiler/codegen/index.js
// 源码位置: /src/complier/index.js

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 模板解析阶段:用正则等方式解析 template 模板中的指令、class、style等数据,形成AST
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    // 优化阶段:遍历AST,找出其中的静态节点,并打上标记;
    optimize(ast, options)
  }
  // 代码生成阶段:将AST转换成渲染函数;
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})
模板解析阶段(整体运行流程)
整体流程

在模板解析阶段主要做的工作是把用户在template标签内写的模板使用正则等方式解析成抽象语法树AST。这一阶段在源码中对应解析器parse模块

解析器,顾名思义,就是把用户所写的模板根据一定的解析规则解析出有效的信息,最后用这些信息行成AST。 template中除了常规html外,用户还有一些文本以及文本中包含过滤器。这些不同的内容需要不同的解析规则,所以解析器不可能只有一个,除了常规的html解析器,应该还有文本解析器以及过滤器解析器

html解析器是主线,解析过程中如果碰到文本内容,就调用文本解析器进行解析,如果碰到过滤器就调用过滤器解析器进行解析

源码
// 代码位置:/src/complier/parser/index.js

/**
 * Convert HTML string to AST.
 */
export function parse(template, options) {
   // ...
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    start (tag, attrs, unary) {

    },
    end () {

    },
    chars (text: string) {

    },
    comment (text: string) {

    }
  })
  return root
}
模板解析阶段(Html解析)
HTML解析器内部运行流程

HTML解析器就是parseHTML函数,在模板解析主线函数parse中调用了该函数,并传入两个参数

// 代码位置:/src/complier/parser/index.js

/**
 * Convert HTML string to AST.
 * 将HTML模板字符串转化为AST
 */
export function parse(template, options) {
   // ...
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    // 当解析到开始标签时,调用该函数
    start (tag, attrs, unary) {

    },
    // 当解析到结束标签时,调用该函数
    end () {

    },
    // 当解析到文本时,调用该函数
    chars (text) {

    },
    // 当解析到注释时,调用该函数
    comment (text) {

    }
  })
  return root
}

代码可以看到两个参数分别是

  • template:待转换的模板字符串
  • options: 转换时所需的选项

在options中有四个钩子函数,parseHTML解析模板字符串,当解析到文本和注释调用对应的钩子函数,最后把不同的提取内容生成对应 的AST

  • 当解析到开始标签的时候调用start函数生成元素类型的AST

    // 当解析到标签的开始位置时,触发start
    start (tag, attrs, unary) {
    	let element = createASTElement(tag, attrs, currentParent)
    }
    
    export function createASTElement (tag,attrs,parent) {
      return {
        type: 1,
        tag,
        attrsList: attrs,
        attrsMap: makeAttrsMap(attrs),
        parent,
        children: []
      }
    }
    
  • 当解析到结束标签时调用end函数

  • 当解析到文本时调养chars函数生成文本类型的AST节点

    // 当解析到标签的文本时,触发chars
    chars (text) {
    	if(text是带变量的动态文本){
        let element = {
          type: 2,
          expression: res.expression,
          tokens: res.tokens,
          text
        }
      } else {
        let element = {
          type: 3,
          text
        }
      }
    }
    
  • 当解析到注释时调用comment函数生成注释类型的AST节点

    // 当解析到标签的注释时,触发comment
    comment (text: string) {
      let element = {
        type: 3,
        text,
        isComment: true
      }
    }
    

一遍解析不同的内容一边调用对应的钩子函数生成对应的AST,最终完成整个模板字符串转化为AST,这就是HTML解析器索要做的工作

解析不同的内容

通常模板内会包含如下内容

  • 文本
  • HTML注释
  • 条件注释
  • DOCTYPE
  • 开始标签
  • 结束标签

根据不同的特点写不同的正则来从模板字符串中解析出来,再对不同的内容做处理

  1. 解析HTML注释
const comment = /^<!\--/
if (comment.test(html)) {
  // 若为注释,则继续查找是否存在'-->'
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    // 若存在 '-->',继续判断options中是否保留注释
    if (options.shouldKeepComment) {
      // 若保留注释,则把注释截取出来传给options.comment,创建注释类型的AST节点
      options.comment(html.substring(4, commentEnd))
    }
    // 若不保留注释,则将游标移动到'-->'之后,继续向后解析
    advance(commentEnd + 3)
    continue
  }
}

advance函数是用来移动游标的

function advance (n) {
  index += n   // index为解析游标
  html = html.substring(n)
}
  1. 解析条件注释

    // 解析是否是条件注释
    const conditionalComment = /^<!\[/
    if (conditionalComment.test(html)) {
      // 若为条件注释,则继续查找是否存在']>'
      const conditionalEnd = html.indexOf(']>')
    
      if (conditionalEnd >= 0) {
        // 若存在 ']>',则从原本的html字符串中把条件注释截掉,
        // 把剩下的内容重新赋给html,继续向后匹配
        advance(conditionalEnd + 2)
        continue
      }
    }
    
  2. 解析DOCTYPE

    const doctype = /^<!DOCTYPE [^>]+>/i
    // 解析是否是DOCTYPE
    const doctypeMatch = html.match(doctype)
    if (doctypeMatch) {
      advance(doctypeMatch[0].length)
      continue
    }
    
  3. 解析开始标签

    /**
     * 匹配开始标签的正则
     */
    const ncname = '[a-zA-Z_][\\w\\-\\.]*'
    const qnameCapture = `((?:${ncname}\\:)?${ncname})`
    const startTagOpen = new RegExp(`^<${qnameCapture}`)
    
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
        start: index
      }
    }
    
    // 以开始标签开始的模板:
    '<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
    // 以结束标签开始的模板:
    '</div><div></div>'.match(startTagOpen) => null
    // 以文本开始的模板:
    '我是文本</p>'.match(startTagOpen) => null
    

后续内容略。。。

https://vue-js.com/learn-vue/complie/HTMLParse.html#_5-%E5%9B%9E%E5%BD%92%E6%BA%90%E7%A0%81

总结

HTML解析器工作流程,一遍解析不同的内容一遍调用对应的钩子函数生成对应的AST节点,最终完成整个模板字符串转化为AST

同时在解析器内维护了一个栈,用来保证构建的AST节点层级与真正的DOM层级一致

 // 若为条件注释,则继续查找是否存在']>'
 const conditionalEnd = html.indexOf(']>')

 if (conditionalEnd >= 0) {
   // 若存在 ']>',则从原本的html字符串中把条件注释截掉,
   // 把剩下的内容重新赋给html,继续向后匹配
   advance(conditionalEnd + 2)
   continue
 }

}


3. 解析DOCTYPE

```javascript
const doctype = /^<!DOCTYPE [^>]+>/i
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
  advance(doctypeMatch[0].length)
  continue
}
  1. 解析开始标签

    /**
     * 匹配开始标签的正则
     */
    const ncname = '[a-zA-Z_][\\w\\-\\.]*'
    const qnameCapture = `((?:${ncname}\\:)?${ncname})`
    const startTagOpen = new RegExp(`^<${qnameCapture}`)
    
    const start = html.match(startTagOpen)
    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
        start: index
      }
    }
    
    // 以开始标签开始的模板:
    '<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
    // 以结束标签开始的模板:
    '</div><div></div>'.match(startTagOpen) => null
    // 以文本开始的模板:
    '我是文本</p>'.match(startTagOpen) => null
    

后续内容略。。。

https://vue-js.com/learn-vue/complie/HTMLParse.html#_5-%E5%9B%9E%E5%BD%92%E6%BA%90%E7%A0%81

总结

HTML解析器工作流程,一遍解析不同的内容一遍调用对应的钩子函数生成对应的AST节点,最终完成整个模板字符串转化为AST

同时在解析器内维护了一个栈,用来保证构建的AST节点层级与真正的DOM层级一致

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值