美文网首页
vue2合集

vue2合集

作者: Da_xiong | 来源:发表于2022-04-09 11:39 被阅读0次

第一章:响应式原理

一、观察者模式

  • 观察者(订阅者) -- Watcher
    update():当事件发生时,具体要做的事情
  • 目标(发布者) -- Dep
    subs 数组:存储所有的观察者
    addSub():添加观察者
    notify():当事件发生,调用所有观察者的 update() 方法
  • 没有事件中心
// 目标(发布者)
// Dependency
class Dep {
  constructor() {
    // 存储所有的观察者
    this.subs = []
  }
  // 添加观察者
  addSub(sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // 通知所有观察者
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
// 观察者(订阅者)
class Watcher {
  update() {
    console.log('update')
  }
}
// 测试
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
  • 总结
    观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。
    发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。

二、响应式原理

  1. watcher在第一次视图绑定变量的时候(插值表达式、指令处理时)创建,每个key有自己的一个watcher。
  2. 创建watcher的时候应该加到对应的发布者里去,所以在watcher构造函数处调用dep.addSub(watcher)
  3. 发布者当数据变更时需要通知观察者dep.notify(),所以发布者的实例创建即new Dep()应该是在数据变更处,即Observer的walk里。
  4. 而watcher构造函数里为了能调用到Observer的walk里defineProperty的dep实例,可以借用this.oldValue = vm[key]去操作。所以可以把dep.addSub(watcher)写在getter里,并通过Dep.target控制只有第一次才addSub。
// 处理插值表达式
  compileText(node) {
    const regExp = /\{\{(.+?)\}\}/
    const value = node.textContent
    if (regExp.test(value)) {
      const key = RegExp.$1.trim()
      node.textContent = value.replace(regExp, this.vm[key])

      // 为当前的key创建watcher,当key变化时更新视图
      new Watcher(this.vm, key, (newValue) => {
        node.textContent = newValue
      })
    }
  }
class Watcher {
  // 每个数据变化都要触发更新,所以每个key对应一个watcher
  constructor(vm, key, cb) {
    this.vm = vm
    this.key = key
    this.cb = cb
    
    // 创建watcher的时候应该加到对应的发布者里去,发布者当数据变更时需要通知观察者,所以发布者应该是在数据变更处,那么添加watcher也可以放在对应地方即Observer的walk里
    // 此处跟Observer里walk相关的也就获取旧数据,为了在walk里面触发添加watcher,可以把添加写在getter里,并通过Dep.target控制只有第一次才addSub

    // 把watcher对象记录到Dep类的静态属性target
    Dep.target = this
    // 触发get方法,在get方法中会调用addSub
    this.oldValue = vm[key]
    Dep.target = null
  }

  // 当数据变化的时候更新视图
  update() {
    let newValue = this.vm[this.key]
    if (this.oldValue === newValue) {
      return
    }

    this.cb(newValue)
  }
}
class Observer {
  constructor(data) {
    this.walk(data)
  }

  walk(data) {
    if (!data || typeof data !== 'object') {
      return
    }
    
    Object.keys(data).forEach(key => {
      const that = this
      const val = data[key] // 避免循环先存下来
      const dep = new Dep() // 负责收集依赖,当数据变化的时候通知观察者
      this.walk(val)
      Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get() {
          // 只有创建watcher时Dep.target才存在,所以只有创建watcher的时候获取数据才会触发添加watcher
          Dep.target && dep.addSub(Dep.target)

          return val
        },
        set(newVal) {
          if (newVal === val) {
            return
          }
          that.walk(newVal)
          val = newVal // val被闭包缓存了,此处修改的是当前作用域下的val,get返回的也是同一个val
          dep.notify() // 通知观察者调用update
        }
      })
    })
  }
}

一、对象监测

object.defineProperty只能观测到取值和改值,如果是增加或删除key是监测不到的,还得通过Vue.setVue.delete解决。

二、数组监测

数组的读取和整个替换是可以通过object.defineProperty监测到的,但数据内部元素的增删改查无法监测到。可以自行实现数组的增删改查方法,在里面实现监测和调用watcher。但数组下标的修改无法监测。
Array原型中可以改变数组自身内容的方法有7个,分别是:push,pop,shift,unshift,splice,sort,reverse

// 源码位置:/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'
]

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
    }
  })
})

做好数组方法拦截器之后,还要让用户的数组实例默认都用这些修改后的方法。

  1. 因为数组实例本身没有push之类的方法,都是用原型上的,所以我们可以直接把实例的原型proto改成修改后的原型。
  2. 若不支持proto,则直接往实例上加修改后的方法。
// 源码位置:/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)

function protoAugment (target, src: Object, keys: any) {
  target.__proto__ = src
}

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])
  }
}

第二章:模板编译

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

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

一、模板解析

  1. 解析html
  2. 遇到文本就parseText()
  3. 遇到过滤器就parseFilters()

(一)解析html

先判断是否有父节点且父节点为纯文本标签 (script,style,textarea),如果是则不需要解析里面的内容。否则才进一步解析

  1. 解析注释
  2. 解析条件语句
  3. 解析doctype
  4. 解析开始标签,并推入栈中

(1) 解析标签名

const cname = '[a-zA-Z_][\\w\\-\\.]*'; // 利用RegExp需要做转义

标签名可以为字母或下划线开头,然后-或.连接的单词
(2) 解析标签属性

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
  • 匹配属性名:[^\s"'<>\/=]+非关闭的特殊字符
  • 匹配属性值:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+值里面多了个es6的模板字符串

(3) 解析结束标记,判断是否自闭合标签

const startTagClose = /^\s*(\/?)>/
let end = html.match(startTagClose)
'></div>'.match(startTagClose) // [">", "", index: 0, input: "></div>", groups: undefined]
'/>'.match(startTagClose) // ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]

自闭合标签的end[1]是"/",非自闭合标签是""。

  1. 解析结束标签,并出栈判断标签是否一致
    (1)从后往前找栈里闭合标签的位置,正常应该是在栈顶。
    (2)若不在栈顶,则栈顶往后的标签都没正确闭合,要提示并调用options.end自动闭合。
    (3)若栈里找不到闭合标签对应的,且为</p>或者</br>,根据浏览器特性则会自动把这两个标签补全。
  2. 解析文本
    (1)如果整个都没有<,则都是文本
    (2)如果有,则<之前的是文本。<之后的先判断是否是五种类型,不是的话也是文本;查找下一个<,如有没有则都是文本;如果有,继续上面的循环。

(二)解析文本

分为有变量文本和无变量文本。用户如果传了解析符就用用户的解析,否则正则匹配{{}}

(三)标记静态节点

经过上两步处理后的AST节点是带有type的,1表示元素节点,2表示带变量文本,3表示纯文本,可以根据这个判断是否为静态节点。

  1. 静态节点的纯文本内容不包含变量
  2. 子节点全是静态节点的节点称为静态根节点

这些节点不会变化,所以不需要vnode去patch变化,标记出来忽略patch可以提升性能。
如果元素节点是静态节点,那就必须满足以下几点要求:

  1. 如果节点使用了v-pre指令,那就断定它是静态节点;
  2. 如果节点没有使用v-pre指令,那它要成为静态节点必须满足:
  • 不能使用v-if、v-else、v-for指令;
  • 当前节点的父节点不能是带有 v-for 的 template 标签;
  • 不能是内置组件,即标签名不能是slot和component;
  • 标签名必须是平台保留标签,即不能是组件;
  • 不能使用动态绑定语法,即标签上不能有v-、@、:开头的属性;
  • 节点的所有属性的 key 都必须是静态节点才有的 key,注:静态节点的key是有限的,它只能是type,tag,attrsList,attrsMap,plain,parent,children,attrs之一;

标记完当前节点是否为静态节点之后,如果该节点是元素节点,那么还要继续去递归判断它的子节点。如果当前节点的子节点中有标签带有v-if、v-else-if、v-else等指令时,这些子节点在每次渲染时都只渲染一个,所以其余没有被渲染的肯定不在node.children中,而是存在于node.ifConditions,所以我们还要把node.ifConditions循环一遍。

一个节点要想成为静态根节点,它必须满足以下要求:

  • 节点本身必须是静态节点;
  • 必须拥有子节点 children;
  • 子节点不能只是只有一个文本节点;

否则的话,对它的优化成本将大于优化后带来的收益。
如果当前节点不是静态根节点,那就继续递归遍历它的子节点node.children和node.ifConditions

第三章、虚拟Dom

一、虚拟Dom

用JS的计算性能来换取操作DOM所消耗的性能。不要盲目的去更新视图,而是通过对比数据变化前后的状态,计算出视图中哪些地方需要更新,只更新需要更新的地方。
在视图渲染之前,把写好的template模板先编译成VNode并缓存下来,等到数据发生变化页面需要重新渲染的时候,我们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异,然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,最终完成一次视图更新。

VNode类型:

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

二、diff算法

整个patch无非就是干三件事:

  • 创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
  • 删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
  • 更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。

(一)创建节点

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

(二)删除节点

如果某些节点再新的VNode中没有而在旧的oldVNode中有,那么就需要把这些节点从旧的oldVNode中删除。删除节点非常简单,只需在要删除节点的父元素上调用removeChild方法即可。源码如下:

function removeNode (el) {
    const parent = nodeOps.parentNode(el)  // 获取父节点
    if (isDef(parent)) {
      nodeOps.removeChild(parent, el)  // 调用父节点的removeChild方法
    }
  }

(三)更新节点

  1. 如果newNode和oldNode都是静态节点,则跳过。
  2. 如果newNode是文本节点:
  • oldNode也是文本节点,则对比是否相同,不同改文本成相同。
  • oldNode不是文本节点,则改成文本节点,并替换文本成相同。
  1. 如果newNode是元素节点:
  • newNode有子节点,如果oldNode有子节点则递归对比更新替换。
    如果oldNode没有子节点:
    a. 且为空节点,则直接创建子节点并插入。
    b. 且为文本节点,则清空文本,并创建子节点插入。
  • newNode没有子节点,而且第二步已经排查不是文本节点,则直接清空oldNode。

三、更新子节点

创建新的节点要插入到所有未处理节点之前,而不是已处理节点之后,否则会出现下面的问题。


创建子节点

先比较oldStart和newStart,直到不相同;比较oldEnd和newEnd;再比较oldStart和newEnd;再比较oldEnd和newStart;都不同相同则遍历。当老节点或新节点的所有子节点全部遍历完(oldStart>oldEnd或者newStart>newEnd),则循环结束。

  • 新旧开始节点如果是sameVnode(key和sel相同),会重用旧的dom节点只是修改内容,调用patchVnode()对比和更新节点。把旧开始和新开始索引往后移动。
  • 旧开始和新结束如果相同,调用patchVnode()对比和更新,把oldStart对应的dom元素移动到oldEnd之后(因为旧开始和新结束是相同节点),并且oldStart++,newEnd--。
  • 旧结束和新开始,调用patchVnode()对比和更新,把oldEnd对应的dom元素移动到oldStart之前,并且oldEnd--,newStart++。
  • 如果都不相同,则遍历旧节点中是否有newStart相同节点,有的话对比更新,并把对应节点移到oldStart之前,清空旧节点索引;如果没有,则直接在oldStart之前创建新的节点。

第四章、生命周期

Vue实例的生命周期大致可分为4个阶段:

  • 初始化阶段:为Vue实例上初始化一些属性,事件以及响应式数据;
  • 模板编译阶段:将模板编译成渲染函数;
  • 挂载阶段:将实例挂载到指定的DOM上,即将模板渲染到真实DOM中;
  • 销毁阶段:将实例自身从父组件中删除,并取消依赖追踪及事件监听器;

初始化

  1. 所以data之类的要在created钩子之后才能拿到。
export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
    )
    vm._self = vm
    initLifecycle(vm)       // 初始化生命周期
    initEvents(vm)        // 初始化事件
    initRender(vm)         // 初始化渲染
    callHook(vm, 'beforeCreate')  // 调用生命周期钩子函数
    initInjections(vm)   //初始化injections
    initState(vm)    // 初始化props,methods,data,computed,watch
    initProvide(vm) // 初始化 provide
    callHook(vm, 'created')  // 调用生命周期钩子函数
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}
  1. 合并options的时候,把一些内置组件扩展到 Vue.options.components 上,Vue 的内置组件目前 有<keep-alive>、<transition> 和<transition-group> 组件,这也就是为什么我们在其它组件中使用这些组件不需要注册的原因。以及合并mixins和extends,不同watch和data采用不同合并策略(策略模式)。钩子函数合并成数组,按顺序执行所有钩子。
  2. initLifecycle往实例上挂$parent(第一个非抽象组件的父组件)、$children$root等。
  3. initEvents父组件给子组件的注册事件中,把自定义事件传给子组件,在子组件实例化的时候进行初始化;而浏览器原生事件是在父组件中处理。
    换句话说:实例初始化阶段调用的初始化事件函数initEvents实际上初始化的是父组件在模板中使用v-on或@注册的监听子组件内触发的事件。
  4. 由于inject的数据在data和props里可用,所以要先initInjections,再initStateinitProvideinitInjections就是把vm.$options.inject取出来放实例上,而且指定shouldObserve = false将其标识未非响应式,所以

provide 和 inject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。

  • 先规范化成标准对象写法。
  • 然后每一个数据key从当前组件起,不断的向上游父级组件中查找该数据key对应的值,直到找到为止。
  • 如果在上游所有父级组件中没找到,那么就看在inject 选项是否为该数据key设置了默认值,如果设置了就使用默认值。
  • 如果没有设置,则抛出异常。
  1. initState初始化顺序:props,methods,data,computed,watch,所以data可以用props和methods里的数据,watch和computed可以用data、props、methods里的数据。
    通过属性拦截监听的话,数据越多依赖越多,依赖追踪的内存开销就会很大。所以从Vue 2.0版本起,Vue只对每个组件进行侦测,所以在每个组件上新增了vm._watchers属性,用来存放用到的所有状态依赖,当其中一个状态发生变化时就会通知到组件,然后由组件内部使用虚拟DOM进行数据比对,从而降低内存开销,提高性能。
  2. initProps如果传入的prop是Boolean类型,则以下三种情况的默认值为true。
<Child name></Child>
<Child name="name"></Child>
<Child userName="user-name"></Child>
  1. 初始化methods无非就干了三件事:判断method有没有?method的命名符不符合命名规范?如果method既有又符合规范那就把它挂载到vm实例上。

  2. 初始化计算属性:计算属性的结果会被缓存,除非依赖的响应式属性变化才会重新计算。非服务端渲染环境下计算属性才应该有缓存。
    如果是函数则直接设置为getter,对象则直接认为是{getter, setter}。通过dirty字段判断,如果dirty为true则重新计算返回,否则返回缓存值。只有每次依赖变化的时候会将dirty设置为true。

    计算属性原理
  3. 最后,会判断用户是否传入了el选项,如果传入了则调用$mount函数进入模板编译与挂载阶段,如果没有传入el选项,则不进入下一个生命周期阶段,需要用户手动执行vm.$mount方法才进入下一个生命周期阶段。

编译阶段

运行时版本没有编译阶段,因为vue-loader或vueify在构建时会把*.vue文件内部的模板预编译成渲染函数。
在只包含运行时版本的$mount方法中获取到DOM元素后直接进入挂载阶段,而在完整版本的$mount方法中是先将模板进行编译,然后回过头调只包含运行时版本的$mount方法进入挂载阶段。它们的区别在于在$mount方法中是否进行了模板编译。

挂载阶段

挂载阶段所做的主要工作是创建Vue实例并用其替换el选项对应的DOM元素,同时还要开启对模板中数据(状态)的监控,当数据(状态)发生变化时通知其依赖进行视图更新。

销毁阶段

  • 将当前的Vue实例从其父级实例中删除,取消当前实例上的所有依赖追踪并且移除实例上的所有事件监听器。
  • 实例身上的依赖包含两部分:一部分是实例自身依赖其他数据,需要将实例自身从其他数据的依赖列表中删除;另一部分是实例内的数据对其他数据的依赖(如用户使用$watch创建的依赖),也需要从其他数据的依赖列表中删除实例内数据。

keep-alive

  1. 不常变动的组件或者需要缓存的组件用<keep-alive>包裹起来,这样<keep-alive>就会帮我们把组件保存在内存中,而不是直接的销毁,这样做可以保留组件的状态或避免多次重新渲染,以提高页面性能。
  2. <keep-alive>组件可接收三个属性:
  • include - 字符串或正则表达式。只有名称匹配的组件会被缓存。
  • exclude - 字符串或正则表达式。任何名称匹配的组件都不会被缓存。
  • max - 数字。最多可以缓存多少组件实例。一旦这个数字达到了,在新实例被创建之前,已缓存组件中最久没有被访问的实例会被销毁掉。
  1. <keep-alive> 只处理第一个子元素,所以一般和它搭配使用的有 component 动态组件或者是 router-view。
  2. 从cache里面找组件的key,如果有则直接取出来实例,并调整放在cache最后一位。如果没有,则把vnode加到cache里,并把key加到keys最后面(最前面最少用的缓存不够最早被删除,LRU(Least recently used,最近最少使用)“如果数据最近被访问过,那么将来被访问的几率也更高”)。

指令

在虚拟DOM渲染更新的create、update、destory阶段都得处理指令逻辑。
自定义指令有五个生命周期(也叫钩子函数),分别是 bind、inserted、update、componentUpdated、unbind

  1. bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  2. inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
  3. update:被绑定于元素所在的模板更新时调用,而无论绑定值是否变化。通过比较更新前后的绑定值,可以忽略不必要的模板更新。
  4. componentUpdated:被绑定元素所在模板完成一次更新周期时调用。
  5. unbind:只调用一次,指令与元素解绑时调用。

原理
1.在生成 ast 语法树时,遇到指令会给当前元素添加 directives 属性
2.通过 genDirectives 生成指令代码
3.在 patch 前将指令的钩子提取到 cbs 中,在 patch 过程中调用对应的钩子
4.当执行指令对应钩子函数时,调用对应指令定义的方法

nextTick

  1. Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver(两个微任务)setImmediateMessageChannel如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。
  2. vue内部第一次执行nextTick的时候,这个时候把nextTick当作微任务插入到微任务队列。
  3. 插入后它往自己的回调函数队列先插了一个更新dom函数(flushCallbacks里有watcher.run)。
  4. 往后再调用nextTick,就不再往微任务队列插入了。而是往上面的回调函数队列里面插入回调函数。
vm.msg = 'Hello world'; // 往微任务队列里面加一个任务,并加锁;创建一个callback队列,并往队列里添加一个更新虚拟dom的函数;此时callback['更新dom方法']
Vue.nextTick(cb1) // 遇到锁不再往微任务队列里加任务,而是直接把cb1添加到已有的callback队列;此时callback['更新dom方法', 'cb1']
// 等到下次微任务执行,拿出这个nextTick回调函数,回调函数里的内容就是遍历执行callback['更新dom方法', 'cb1']

https://juejin.cn/post/6939704519668432910#heading-4
https://blog.csdn.net/weixin_42707287/article/details/111931861

Vue.set和Vue.delete

  • 如果是数组,则调用数组改装好后的splice。
  • 如果是对象,非响应式的(没observer实例)则直接加,否则定义成响应式defineReactive(ob.value, key, val)并触发视图更新ob.dep.notify()
  • 删除的话响应式删除之后直接ob.dep.notify(),非响应式直接删除。

相关文章

网友评论

      本文标题:vue2合集

      本文链接:https://www.haomeiwen.com/subject/crlpsrtx.html