深入剖析Vue源码 - 完整渲染过程

2023-10-27

继上一节内容,我们将Vue复杂的挂载流程通过图解流程,代码分析的方式简单梳理了一遍,其中也讲到了模板编译的大致流程,然而在挂载的核心处,我们并没有分析模板编译后函数如何渲染为可视化的DOM节点。这一节,我们将重新回到Vue实例挂载的最后一个环节:渲染DOM节点。在渲染真实DOM的过程中,Vue引进了虚拟DOM的概念,虚拟DOM作为JS对象和真实DOM中间的一个缓冲存,极大的优化了JS频繁操作DOM的性能问题,接下来我们将慢慢展开分析。

4.1 Virtual DOM

4.1.1 浏览器的渲染流程

当浏览器接收到一个Html文件时,JS引擎和浏览器的渲染引擎便开始工作了。从渲染引擎的角度,它首先会将html文件解析成一个DOM树,与此同时,浏览器将识别并加载CSS样式,并和DOM树一起合并为一个渲染树。有了渲染树后,渲染引擎将计算所有元素的位置信息,最后通过绘制,在屏幕上打印最终的内容。而JS引擎的作用是通过DOM相关的API去操作DOM对象,而当我们操作DOM时,很容易触发到渲染引擎的回流或者重绘。

  • 回流: 当我们对DOM的修改引发了元素尺寸的变化时,浏览器需要重新计算元素的大小和位置,最后将重新计算的结果绘制出来,这个过程称为回流。
  • 重绘: 当我们对DOM的修改只单纯改变元素的颜色时,浏览器此时并不需要重新计算元素的大小和位置,而只要重新绘制新样式。这个过程称为重绘。

很显然回流比重绘更加耗费性能

通过了解浏览器基本的渲染机制,我们很容易联想到当不断的通过JS修改DOM时,不经意间会触发到渲染引擎的回流或者重绘,而这个性能开销是非常巨大的。因此为了降低开销,我们可以做的是尽可能减少DOM操作。

4.1.2 缓冲层-虚拟DOM

虚拟DOM是优化频繁操作DOM引发性能问题的产物。虚拟DOM(下面称为Virtual DOM)是将页面的状态抽象为JS对象的形式,本质上是JS和真实DOM的中间层,当我们想用JS脚本大批量进行DOM操作时,会优先作用于Virtual DOM这个JS对象,最后通过对比将要改动的部分通知并更新到真实的DOM。尽管最终还是操作真实的DOM,但Virtual DOM可以将多个改动合并成一个批量的操作,从而减少 dom 重排的次数,进而缩短了生成渲染树和绘制所花的时间。

我们看一个真实的DOM包含了什么:

浏览器将一个真实 DOM设计得很复杂,不仅包含了自身的属性描述,大小位置等定义,也囊括了 DOM拥有的浏览器事件等。正因为如此复杂的结构,我们频繁去操作 DOM或多或少会带来浏览器性能问题。而作为数据和真实 DOM之间的一层缓冲, Virtual DOM 只是用来映射到真实 DOM的渲染,因此不需要包含操作 DOM 的方法,只要在对象中重点关注几个属性即可。

// 真实DOM
<div id="real"><span>dom</span></div>

// 真实DOM对应的JS对象
{
    tag: 'div',
    data: {
        id: 'real'
    },
    children: [{
        tag: 'span',
        children: 'dom'
    }]
}
复制代码

4.2 Vnode

Vue源码在渲染机制的优化上,同样引进了virtual dom的概念,它是用Vnode这个构造函数去描述一个DOM节点。

4.2.1 Vnode构造函数
var VNode = function VNode (tag,data,children,text,elm,context,componentOptions,asyncFactory) {
    this.tag = tag; // 标签
    this.data = data;  // 数据
    this.children = children; // 子节点
    this.text = text;
    ···
    ···
  };
复制代码

Vnode定义的属性也差不多有20几个,这里列举大部分属性,只重点关注几个关键属性:标签名,数据,子节点。其他的属性都是用来扩展Vue的灵活性。

除此之外,源码中还定义了Vnode的其他方法

4.2.2 创建Vnode注释节点
// 创建注释vnode节点
var createEmptyVNode = function (text) {
    if ( text === void 0 ) text = '';

    var node = new VNode();
    node.text = text;
    node.isComment = true; // 标记注释节点
    return node
};
复制代码
4.2.3 创建Vnode文本节点
// 创建文本vnode节点
function createTextVNode (val) {
    return new VNode(undefined, undefined, undefined, String(val))
}
复制代码
4.2.4 克隆vnode

vnode的克隆只是一层浅拷贝,不会对子节点进行深度克隆。

function cloneVNode (vnode) {
    var cloned = new VNode(
      vnode.tag,
      vnode.data,
      vnode.children && vnode.children.slice(),
      vnode.text,
      vnode.elm,
      vnode.context,
      vnode.componentOptions,
      vnode.asyncFactory
    );
    ···
    return cloned
  }
复制代码

4.3 Vnode的创建

先简单回顾一下挂载的流程,挂载的过程调用的是Vue实例上$mount方法,而$mount的核心是mountComponent方法。在这之前,如果我们传递的是template模板,会经过一系列的模板编译过程,并根据不同平台生成对应代码,浏览器对应的是render函数;如果传递的是render函数,则忽略模板编译过程。有了render函数后,调用vm._render()方法会将render函数转化为Virtual DOM,最终利用vm._update()Virtual DOM渲染为真实的DOM

Vue.prototype.$mount = function(el, hydrating) {
    ···
    return mountComponent(this, el)
}
function mountComponent() {
    ···
    updateComponent = function () {
        vm._update(vm._render(), hydrating);
    };
}

复制代码

vm._render()方法会将render函数转化为Virtual DOM,我们看源码中如何定义的。

// 引入Vue时,执行renderMixin方法,该方法定义了Vue原型上的几个方法,其中一个便是 _render函数
renderMixin();//
function renderMixin() {
    Vue.prototype._render = function() {
        var ref = vm.$options;
        var render = ref.render;
        ···
        try {
            vnode = render.call(vm._renderProxy, vm.$createElement);
        } catch (e) {
            ···
        }
        ···
        return vnode
    }
}
复制代码

抛开其他代码,_render函数的核心是render.call(vm._renderProxy, vm.$createElement)部分,vm.$createElement方法会作为render函数的参数传入。这个参数也是在手写render函数时使用的createElement参数的由来

new Vue({
    el: '#app',
    render: function(createElement) {
        return createElement('div', {}, this.message)
    },
    data() {
        return {
            message: 'dom'
        }
    }
})
复制代码

vm.$createElementVueinitRender所定义的方法,其中 vm._ctemplate内部编译成render函数时调用的方法,vm.$createElement是手写render函数时调用的方法。两者的唯一区别是:内部生成的render方法可以保证子节点都是Vnode(下面有特殊的场景),而手写的render需要一些检验和转换。

function initRender(vm) {
    vm._c = function(a, b, c, d) { return createElement(vm, a, b, c, d, false); }
    vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
}
复制代码

createElement 方法实际上是对 _createElement 方法的封装,在调用_createElement创建Vnode之前,会对传入的参数进行处理。例如当没有data数据时,参数会往前填充。

function createElement (
    context, // vm 实例
    tag, // 标签
    data, // 节点相关数据,属性
    children, // 子节点
    normalizationType,
    alwaysNormalize // 区分内部编译生成的render还是手写render
  ) {
    // 对传入参数做处理,可以没有data,如果没有data,则将第三个参数作为第四个参数使用,往上类推。
    if (Array.isArray(data) || isPrimitive(data)) {
      normalizationType = children;
      children = data;
      data = undefined;
    }
    // 根据是alwaysNormalize 区分是内部编译使用的,还是用户手写render使用的
    if (isTrue(alwaysNormalize)) {
      normalizationType = ALWAYS_NORMALIZE;
    }
    return _createElement(context, tag, data, children, normalizationType) // 真正生成Vnode的方法
  }
复制代码
4.3.1 数据规范检测

Vue既然暴露给用户用render函数去写渲染模板,就需要考虑用户操作带来的不确定性,因此在生成Vnode的过程中,_createElement会先进行数据规范的检测,将不合法的数据类型错误提前暴露给用户。接下来将列举几个容易犯错误的实际场景,方便理解源码中如何处理这类错误的。

    1. 用响应式对象做节点属性
new Vue({
    el: '#app',
    render: function (createElement, context) {
       return createElement('div', this.observeData, this.show)
    },
    data() {
        return {
            show: 'dom',
            observeData: {
                attr: {
                    id: 'test'
                }
            }
        }
    }
})
复制代码
    1. 特殊属性key为非字符串,数字类型
new Vue({
    el: '#app',
    render: function(createElement) {
        return createElement('div', { key: this.lists }, this.lists.map(l => {
           return createElement('span', l.name)
        }))
    },
    data() {
        return {
            lists: [{
              name: '111'
            },
            {
              name: '222'
            }
          ],
        }
    }
})
复制代码

这些规范都会在创建Vnode节点之前发现并报错,源代码如下:

function _createElement (context,tag,data,children,normalizationType) {
    // 数据对象不能是定义在Vue data属性中的响应式数据。
    if (isDef(data) && isDef((data).__ob__)) {
      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 的特殊处理,组件相关知识放到特定章节分析。
    if (isDef(data) && isDef(data.is)) {
      tag = data.is;
    }
    if (!tag) {
      // 防止动态组件 :is 属性设置为false时,需要做特殊处理
      return createEmptyVNode()
    }
    // key值只能为string,number这些原始数据类型
    if (isDef(data) && isDef(data.key) && !isPrimitive(data.key)
    ) {
      {
        warn(
          'Avoid using non-primitive value as key, ' +
          'use string/number value instead.',
          context
        );
      }
    }
    ···
    // 省略后续操作
  }
复制代码
4.3.2 子节点children规范化

Virtual DOM需要保证每一个子节点都是Vnode类型,这里分两种场景。

  • 1.render函数编译,理论上通过render函数编译生成的都是Vnode类型,但是有一个例外,函数式组件返回的是一个数组(关于组件,以及函数式组件内容,我们放到专门讲组件的时候专题分析),这个时候Vue的处理是将整个children拍平。
  • 2.用户定render函数,这个时候也分为两种情况,一个是chidren为文本节点,这时候通过前面介绍的createTextVNode 创建一个文本节点的 VNode; 另一种相对复杂,当children中有v-for的时候会出现嵌套数组,这时候的处理逻辑是,遍历children,对每个节点进行判断,如果依旧是数组,则继续递归调用,直到类型为基础类型时,调用createTextVnode方法转化为Vnode。这样经过递归,children变成了一个类型为Vnode的数组。
function _createElement() {
    ···
    if (normalizationType === ALWAYS_NORMALIZE) {
      // 用户定义render函数
      children = normalizeChildren(children);
    } else if (normalizationType === SIMPLE_NORMALIZE) {
      // render 函数是编译生成的
      children = simpleNormalizeChildren(children);
    }
}

// 处理编译生成的render 函数
function simpleNormalizeChildren (children) {
    for (var i = 0; i < children.length; i++) {
        // 子节点为数组时,进行开平操作,压成一维数组。
        if (Array.isArray(children[i])) {
        return Array.prototype.concat.apply([], children)
        }
    }
    return children
}

// 处理用户定义的render函数
function normalizeChildren (children) {
    // 递归调用,直到子节点是基础类型,则调用创建文本节点Vnode
    return isPrimitive(children)
      ? [createTextVNode(children)]
      : Array.isArray(children)
        ? normalizeArrayChildren(children)
        : undefined
  }

// 判断是否基础类型
function isPrimitive (value) {
    return (
      typeof value === 'string' ||
      typeof value === 'number' ||
      typeof value === 'symbol' ||
      typeof value === 'boolean'
    )
  }
复制代码

=== 进行数据检测和组件规范化后,接下来通过new VNode便可以生成一棵```VNode``树。===具体细节由于篇幅原因,不展开分析。

4.4 虚拟Vnode映射成真实DOM - update

回到 updateComponent的最后一个过程,虚拟的DOMvirtual dom生成后,调用Vue原型上_update方法,将虚拟DOM映射成为真实的DOM

updateComponent = function () {
    // render生成虚拟DOM,update渲染真实DOM
    vm._update(vm._render(), hydrating);
};
复制代码

从源码上可以知道,update主要有两个调用时机,一个是初次数据渲染时,另一个是数据更新时触发真实DOM更新。这一节只分析初次渲染的操作,数据更新放到响应式系统中展开。

function lifecycleMixin() {
    Vue.prototype._update = function (vnode, hydrating) {
        var vm = this;
        var prevEl = vm.$el;
        var prevVnode = vm._vnode; // prevVnode为旧vnode节点
        // 通过是否有旧节点判断是初次渲染还是数据更新
        if (!prevVnode) {
            // 初次渲染
            vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)
        } else {
            // 数据更新
            vm.$el = vm.__patch__(prevVnode, vnode);
        }
}
复制代码

_update的核心是__patch__方法,而__patch__来源于:

// 浏览器端才有DOM,服务端没有dom,所以patch为一个空函数
  Vue.prototype.__patch__ = inBrowser ? patch : noop;
复制代码

patch方法又是createPatchFunction方法的返回值,createPatchFunction内部定义了一系列辅助的方法,但其核心是通过调用createEle方法,createEle会调用一系列封装好的原生DOMAPI进行dom操作,创建节点,插入子节点,递归创建一个完整的DOM树并插入到Body中。这部分逻辑分支较为复杂,在源码上打debugger并根据实际场景跑不同的分支有助于理解这部分的逻辑。内容较多就不一一展开。

总结

这一节分析了mountComponent的两个核心方法,renderupdate,他们分别完成对render函数转化为Virtual DOM和将Virtual DOM映射为真实DOM 的过程。整个渲染过程逻辑相对也是比较清晰的。


转载于:https://juejin.im/post/5cdd18455188254a930860bd

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

深入剖析Vue源码 - 完整渲染过程 的相关文章

随机推荐

  • GLES3.0中文API-glBeginQuery

    名称 glBeginQuery 划定查询对象的边界 C规格 void glBeginQuery GLenum target GLuint id void glEndQuery GLenum target 参数 glBeginQuery ta
  • react中“create-react-app”不是内部或者外部命令也不是可运行程序的解决办法

    换个命令创建项目 npm init react app my app 可创建 npm run start 启动
  • antd-design/pro-table组件说明(官方readme)

    ant design pro table Antd官网没找到Pro Table的相关说明文档 下面是从依赖包里捞出来的readme Demo codesandbox API pro table 在 antd 的 table 上进行了一层封装
  • 洛谷 贪心 部分背包 线段覆盖 排队接水 python

    P2240 深基12 例1 部分背包问题 数据结构选择 python没有结构体 选用二维数组 物品价值 重量 存储单个物品方便排序 根据贪心策略 首先计算性价比 然后按性价比由大到小排序 只要没有达到背包承载的重量 就装入 python n
  • 第八章 综合案例——构建DVD租赁商店数据仓库①

    8 3 2 加载日期数据至日期维度表 1 打开Kettle工具 创建转换 使用Kettle工具 创建一个转换load dim date 并添加生成记录控件 增加序列控件 JavaScript代码控件 表输出控件以及Hop跳连接线 2 配置
  • 用作者提供的net1->net2生成MTCNN的训练样本(positive,negative,part,landmark)

    本代码基于作者提供的python版本代码修改 参考 https github com DuinoDu mtcnn blob master demo py 作者提供 https github com dlunion mtcnn blob ma
  • C++中pair使用详细说明

    一 pair 的介绍 pair 是一个很实用的 小玩意 当想要将两个元素绑在一起作为一个合成元素 又不想要因此定义结构体时 使用 pair 可以很方便地作为一个代替品 也就是说 pair 实际上可以看作一个内部有两个元素的结构体 且这两个元
  • Python轻量级Web框架Flask(3)——Flask路由参数/Flask请求与响应/重定项/异常处理

    1 Flask路由参数和methods参数 路由其实就是一个路径 就是 a route template test 中的 template test 每一个路由对应的是唯一的一个功能 如果要实现很多个功能 就需要很多个路由 methods参
  • #992: invalid macro definition: USE_STDPERIPH_DRIVER.STM32F10X_MD问题解决方法

    现像 在进行STM32进行建立工程过程中 进行编译后出现如下报错 no source Error command line 992 invalid macro definition USE STDPERIPH DRIVER STM32F10
  • 我的NVIDA开发者之旅-在GPU上运行Pytorch代码

    我的NVIDIA开发者之旅 征文活动进行中 目录 介绍 查看GPU信息 Pytorch指定显卡 Demo 参考 介绍 Pytorch与CUDA的安装可以参照我之前写的深度学习环境搭建 Windows10安装cuDNN Pytorch能够使用
  • Linux网络基础5(链路层---以太网)

    链路层 负责相邻设备之间的数据传输 以太网协议 ethernet 协议格式 48位源端 对端mac地址 识别指定相邻设备 mac地址 uint8 t mac 6 网卡的物理硬件地址 在出厂时设定 16位数据类型 用于数据分用时上层解析协议的
  • Kettle 将文本文件转为excel输出

    关注微信公共号 小程在线 关注CSDN博客 程志伟的博客 一 将文本文件转为excel输出 1 选择打开 新建转换 2 点击文本文件输入 3 点击浏览 找到我们需要的文本文件 4 选择好之后点击增加 5 显示文件的具体路径 6 点击 显示文
  • html中img标签特性及块元特性

    img特性 1 img不会独占一行 一行可以放多个 2 img是行内元素 但具有行内块属性 可以自由设置宽高 这种内容可替换的元素 虽然属于 inline 但又能设置height width等值 块元素特性 1 每一个块级元素都是独占一行
  • ajax可以做哪些事,Ajax

    1 ajax 是什么 有什么作用 ajax主要是实现页面和web服务器之间数据的异步传输 不采用ajax的页面 当用户在页面发起请求时 就要进行整个页面的刷新 刷新快慢取决于服务器的处理快慢 在这个过程中用户必须得等待 不能进行其他操作 采
  • Typora 远程代码执行漏洞(CVE-2023-2317)

    1 漏洞介绍 Typora是一款编辑器 Typora 1 6 7之前版本存在安全漏洞 该漏洞源于通过在标签中加载 typora app typemark updater update html 可以在Typora主窗口中加载JavaScri
  • Nginx:配置worker进程的所属用户

    Nginx的配置文件 etc nginx nginx conf中定义了启动worker时的所属用户 全局块 user www data worker进程的所属用户 nginx默认设置为www data 但如果该用户配置的不正确 可能造成业务
  • 【Python 实战】---- 批量对图片添加不同水印

    1 需求 多水印 多图片 对图片和水印进行一一对应的批量添加 2 效果对比 加水印前和加水印后 3 开发思路 由于水印是多个 同时图片也是多个 所以考虑使用excel对他们进行配置 好一一对应 字体的获取 不同文字的字体不同 水印显示的位置
  • 常见的六大聚类算法

    1 K Means K均值 聚类 算法步骤 1 首先我们选择一些类 组 并随机初始化它们各自的中心点 中心点是与每个数据点向量长度相同的位置 这需要我们提前预知类的数量 即中心点的数量 2 计算每个数据点到中心点的距离 数据点距离哪个中心点
  • Kubernetes CKA考试和真题(中)

    前言 上一篇文章分享了CKA考试和前5个题目及其解答 这一篇继续分享6 10题 第六题 使用service暴露应用 题目 重新配置现有的deployment front end 以及添加名为http的端口规范来公开现有的容器nginx的端口
  • 深入剖析Vue源码 - 完整渲染过程

    继上一节内容 我们将Vue复杂的挂载流程通过图解流程 代码分析的方式简单梳理了一遍 其中也讲到了模板编译的大致流程 然而在挂载的核心处 我们并没有分析模板编译后函数如何渲染为可视化的DOM节点 这一节 我们将重新回到Vue实例挂载的最后一个