Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue原理解析之Virtual Dom #3

Open
Joe3Ray opened this issue Mar 3, 2020 · 0 comments
Open

Vue原理解析之Virtual Dom #3

Joe3Ray opened this issue Mar 3, 2020 · 0 comments

Comments

@Joe3Ray
Copy link
Owner

Joe3Ray commented Mar 3, 2020

今年给自己立了个 flag,每周要写一篇高质量的技术文章。没想到刚坚持到第 3 周就面临断档(这周公司需求爆发),还好之前有一些存货。这篇文章是 17 年写的,基于 [email protected],在思否和掘金的反馈都不错,技术细节可能有些过时,没有研究过相关技术的同学可以拿来开拓下思路。

DOM是文档对象模型(Document Object Model)的简写,在浏览器中我们可以通过js来操作DOM,但是这样的操作性能很差,于是Virtual Dom应运而生。我的理解,Virtual Dom就是在js中模拟DOM对象树来优化DOM操作的一种技术或思路。

VNode对象

一个VNode的实例对象包含了以下属性

  • tag: 当前节点的标签名
  • data: 当前节点的数据对象,具体包含哪些字段可以参考vue源码types/vnode.d.ts中对VNodeData的定义
    2256707704-5899bb5027d14_articlex
  • children: 数组类型,包含了当前节点的子节点
  • text: 当前节点的文本,一般文本节点或注释节点会有该属性
  • elm: 当前虚拟节点对应的真实的dom节点
  • ns: 节点的namespace
  • context: 编译作用域
  • functionalContext: 函数化组件的作用域
  • key: 节点的key属性,用于作为节点的标识,有利于patch的优化
  • componentOptions: 创建组件实例时会用到的选项信息
  • child: 当前节点对应的组件实例
  • parent: 组件的占位节点
  • raw: raw html
  • isStatic: 静态节点的标识
  • isRootInsert: 是否作为根节点插入,被<transition>包裹的节点,该属性的值为false
  • isComment: 当前节点是否是注释节点
  • isCloned: 当前节点是否为克隆节点
  • isOnce: 当前节点是否有v-once指令

VNode分类

3396104381-5899cba0185f2_articlex

VNode可以理解为vue框架的虚拟dom的基类,通过new实例化的VNode大致可以分为几类

  • EmptyVNode: 没有内容的注释节点
  • TextVNode: 文本节点
  • ElementVNode: 普通元素节点
  • ComponentVNode: 组件节点
  • CloneVNode: 克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned属性为true
  • ...

createElement解析

const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2

function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {
  // 兼容不传data的情况
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  // 如果alwaysNormalize是true
  // 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值
  if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE
  // 调用_createElement创建虚拟节点
  return _createElement(context, tag, data, children, normalizationType)
}

function _createElement (context, tag, data, children, normalizationType) {
  /**
   * 如果存在data.__ob__,说明data是被Observer观察的数据
   * 不能用作虚拟节点的data
   * 需要抛出警告,并返回一个空节点
   * 
   * 被监控的data不能被用作vnode渲染的数据的原因是:
   * data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作
   */
  if (data && data.__ob__) {
    process.env.NODE_ENV !== 'production' && warn(
      `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
      'Always create fresh vnode data objects in each render!',
      context
    )
    return createEmptyVNode()
  }
  // 当组件的is属性被设置为一个falsy的值
  // Vue将不会知道要把这个组件渲染成什么
  // 所以渲染一个空节点
  if (!tag) {
    return createEmptyVNode()
  }
  // 作用域插槽
  if (Array.isArray(children) &&
      typeof children[0] === 'function') {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  // 根据normalizationType的值,选择不同的处理方法
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  // 如果标签名是字符串类型
  if (typeof tag === 'string') {
    let Ctor
    // 获取标签名的命名空间
    ns = config.getTagNamespace(tag)
    // 判断是否为保留标签
    if (config.isReservedTag(tag)) {
      // 如果是保留标签,就创建一个这样的vnode
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
      // 如果不是保留标签,那么我们将尝试从vm的components上查找是否有这个标签的定义
    } else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 如果找到了这个标签的定义,就以此创建虚拟组件节点
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 兜底方案,正常创建一个vnode
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
    // 当tag不是字符串的时候,我们认为tag是组件的构造类
    // 所以直接创建
  } else {
    vnode = createComponent(tag, data, context, children)
  }
  // 如果有vnode
  if (vnode) {
    // 如果有namespace,就应用下namespace,然后返回vnode
    if (ns) applyNS(vnode, ns)
    return vnode
  // 否则,返回一个空节点
  } else {
    return createEmptyVNode()
  }
}

简单的梳理了一个流程图,可以参考下

3231863732-5899db016ab81_articlex

patch原理

patch函数的定义在src/core/vdom/patch.js中,我们先来看下这个函数的逻辑

patch函数接收6个参数:

  • oldVnode: 旧的虚拟节点或旧的真实dom节点
  • vnode: 新的虚拟节点
  • hydrating: 是否要跟真是dom混合
  • removeOnly: 特殊flag,用于<transition-group>组件
  • parentElm: 父节点
  • refElm: 新节点将插入到refElm之前

patch的策略是:

  1. 如果vnode不存在但是oldVnode存在,说明意图是要销毁老节点,那么就调用invokeDestroyHook(oldVnode)来进行销毁
  2. 如果oldVnode不存在但是vnode存在,说明意图是要创建新节点,那么就调用createElm来创建新节点
  3. vnodeoldVnode都存在时
    • 如果oldVnodevnode是同一个节点,就调用patchVnode来进行patch
    • vnodeoldVnode不是同一个节点时,如果oldVnode是真实dom节点或hydrating设置为true,需要用hydrate函数将虚拟dom和真是dom进行映射,然后将oldVnode设置为对应的虚拟dom,找到oldVnode.elm的父节点,根据vnode创建一个真实dom节点并插入到该父节点中oldVnode.elm的位置

这里面值得一提的是patchVnode函数,因为真正的patch算法是由它来实现的(patchVnode中更新子节点的算法其实是在updateChildren函数中实现的,为了便于理解,我统一放到patchVnode中来解释)。

patchVnode算法是:

  1. 如果oldVnodevnode完全一致,那么不需要做任何事情
  2. 如果oldVnodevnode都是静态节点,且具有相同的key,当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elmoldVnode.child都复制到vnode上,也不用再有其他操作
  3. 否则,如果vnode不是文本节点或注释节点
    • 如果oldVnodevnode都有子节点,且2方的子节点不完全一致,就执行更新子节点的操作(这一部分其实是在updateChildren函数中实现),算法如下
      • 分别获取oldVnodevnodefirstChildlastChild,赋值给oldStartVnodeoldEndVnodenewStartVnodenewEndVnode
      • 如果oldStartVnodenewStartVnode是同一节点,调用patchVnode进行patch,然后将oldStartVnodenewStartVnode都设置为下一个子节点,重复上述流程
        clipboard.png
      • 如果oldEndVnodenewEndVnode是同一节点,调用patchVnode进行patch,然后将oldEndVnodenewEndVnode都设置为上一个子节点,重复上述流程
        clipboard.png
      • 如果oldStartVnodenewEndVnode是同一节点,调用patchVnode进行patch,如果removeOnlyfalse,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程
        clipboard.png
      • 如果newStartVnodeoldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnlyfalse,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述流程
        clipboard.png
      • 如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点
      • 如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这2个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnlyfalse,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述流程
        clipboard.png
      • 如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程
      • 如果oldStartVnodeoldEndVnode重合了,并且newStartVnodenewEndVnode也重合了,这个循环就结束了
    • 如果只有oldVnode有子节点,那就把这些节点都删除
    • 如果只有vnode有子节点,那就创建这些子节点
    • 如果oldVnodevnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串
  4. 如果vnode是文本节点或注释节点,但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以

生命周期

patch提供了5个生命周期钩子,分别是

  • create: 创建patch时
  • activate: 激活组件时
  • update: 更新节点时
  • remove: 移除节点时
  • destroy: 销毁节点时

这些钩子是提供给Vue内部的directives/ref/attrs/style等模块使用的,方便这些模块在patch的不同阶段进行相应的操作,这里模块定义在src/core/vdom/modulessrc/platforms/web/runtime/modules2个目录中

vnode也提供了生命周期钩子,分别是

  • init: vdom初始化时
  • create: vdom创建时
  • prepatch: patch之前
  • insert: vdom插入后
  • update: vdom更新前
  • postpatch: patch之后
  • remove: vdom移除时
  • destroy: vdom销毁时

vue组件的生命周期底层其实就依赖于vnode的生命周期,在src/core/vdom/create-component.js中我们可以看到,vue为自己的组件vnode已经写好了默认的init/prepatch/insert/destroy,而vue组件的mounted/activated就是在insert中触发的,deactivated就是在destroy中触发的

题外话

最近有刚毕业不久的同行问过我技术成长相关的问题,我的观点是环境很重要

17 年写这篇文章的时候,我在百度负责搜索前端的组件化改造,从纯Smarty模板改造成Smarty + Vue,需要对Vue进行深度定制,所以花了不少力气研究Vue的技术细节,这个阶段个人技术成长飞快,产出也很丰富,除了对框架原理的深入了解,还自研了基于phpvue ssr方案,用node开发了周边工具链。

在富有挑战性的环境下,人会被推着成长,技术学完就能用,比自己干学然后放在一边强太多。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant