【vue源码03】vue实例的构建过程梳理😀

5/21/2024 Vue源码学习

Vue源码中有很多细节方面的处理,直接生啃的话很容易被这些代码分散注意,所以这篇博客先整理一下Vue实例构建的过程,让我们对Vue的源码执行关键流程、关键原理有个大概的掌握。

# 构建时

Vue框架有静态编译的部分、也有运行时部分。在构建时(就是项目打包),解析各vue文件,生成对应的js代码,然后运行时执行js代码创建vue实例。

# 编译模板

编译模板指的就是编译vue文件中<template>中这部分像html一样的模板代码,通过对模板的编译生成渲染函数。编译分为几个关键步骤:解析(Parsing)、优化(Optimization)、生成代码(Code Generation)。

# 解析

在解析阶段,Vue 会将模板字符串解析为一个抽象语法树(AST)。AST 是模板的结构化表示,它描述了模板的各个部分及其关系。

  • Lexer(词法分析): 词法分析器会扫描模板字符串,将其分解为一系列的令牌(tokens)。每个令牌代表模板中的一个基本单元,如标签、属性、文本等。

  • Parser(语法分析): 语法分析器会将这些令牌转换为 AST。AST 是一个树状结构,树的每个节点表示模板中的一个元素或指令。

例如,这样的模板:

<div id="app">
  <p>{{ message }}</p>
</div>

解析成AST后就像这样:

{
  "type": "Element",
  "tag": "div",
  "attrsList": [{ "name": "id", "value": "app" }],
  "children": [
    {
      "type": "Element",
      "tag": "p",
      "attrsList": [],
      "children": [
        {
          "type": "Expression",
          "expression": "message"
        }
      ]
    }
  ]
}

解析的过程其实意外的单纯,就是将模板一个字符一个字符的解析,利用有限状态机来构建AST。

# 优化

在优化阶段,Vue 会对 AST 进行静态分析,以标记出静态节点和静态根节点。这有助于在后续的更新过程中跳过不必要的比较,提升性能。

  • 静态节点: 不依赖于响应式数据的节点被标记为静态节点。静态节点在初次渲染后不会再改变,因此可以在更新时跳过它们。
  • 静态根节点: 包含静态子树的节点被标记为静态根节点,进一步优化了整棵子树的比较

其实就是最大利用静态编译的特点,在运行前做好一些标记之类的准备,让运行时省去一些工作。比如标记一下静态的节点,比如一个div中的文本内容,这个内容是永远不会变化的,在后续更新执行diff算法比较差异时,就可以不用比较这个节点的内容,提高效率。

# 生成代码

在代码生成阶段,Vue 会将优化后的 AST 转换为渲染函数。渲染函数是一个 JavaScript 函数,它返回虚拟 DOM 树。

  • 生成器(Code Generator): 代码生成器会遍历 AST,将每个节点转换为相应的代码片段,最后将这些片段拼接成完整的渲染函数。 例如,对于上述的 AST,生成的渲染函数可能像这样:
function render() {
  return _c('div', { attrs: { id: 'app' } }, [
    _c('p', [_v(_s(message))])
  ])
}

看着带下划线的方法心里会比较抵触哈哈,毕竟从命名看不出他是干嘛的,所谓恐惧来源于未知

  • _c 是创建虚拟节点的方法。
  • _v 是创建文本节点的方法。
  • _s 是将表达式转换为字符串的方法。

# 编译总结

总结一下,Vue 的模板编译过程可以分为三个主要阶段:

  1. 解析(Parsing): 模板字符串被解析为 AST。包括词法分析和语法分析两个步骤。
  2. 优化(Optimization): 对 AST 进行静态分析,标记出静态节点和静态根节点,以优化渲染性能。
  3. 生成代码(Code Generation): 将优化后的 AST 转换为渲染函数。渲染函数在运行时被调用,生成虚拟 DOM 树。

# 解析< script setup >

< script setup >中通常会包括定义响应式数据、方法、计算属性和生命周期钩子等。

例如这样的代码:

<script setup>
import { ref } from 'vue';

const message = ref('Hello Vue 3');
</script>

编译器遇到后,就会提取其中的内容,将其转化成标准的js代码。编译器会进行以下操作:

  • 解析模块导入:处理模块导入语句,例如 import { ref } from 'vue';。

  • 识别响应式数据:识别 ref 或 reactive 等响应式 API,并将它们初始化的变量存储起来。

  • 收集返回值:识别在 setup 函数中返回的变量和方法。

上面的代码会被转化成这样:

function setup() {
  const message = ref('Hello Vue 3');
  return {
    message
  };
}

也就是将解析后的内容封装到了setup函数里面。

如果遇到了有多个script标签的情况,比如这样:

<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue 3');
</script>

<script>
export default {
  data() {
    return {
      otherMessage: 'Hello from data option'
    };
  }
};
</script>

编译器会对选项进行合并,最终生成如下代码:

export default {
  data() {
    return {
      otherMessage: 'Hello from data option'
    };
  },
  setup() {
    const message = ref('Hello Vue 3');
    return {
      message
    };
  },
}

如果文件中还带有模板,编译器就会处理模板,将其编译成渲染函数,也就是我们上面提到的模板编译过程~

这样的组件代码:

<template>
  <div>{{ message }}</div>
</template>

<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue 3');
</script>

<script>
export default {
  data() {
    return {
      otherMessage: 'Hello from data option'
    };
  }
};
</script>

会被编译成:

export default {
  data() {
    return {
      otherMessage: 'Hello from data option'
    };
  },
  setup() {
    const message = ref('Hello Vue 3');
    return {
      message
    };
  },
  render() {
    return h('div', null, message.value);
  }
};

模板会被编译成渲染函数,装载到组件的render函数中。串起来了~

# 运行时

在编译时,我们已经解析了各.vue的文件,解析了模板和script标签,此时各个文件都是js暴露出来的、代表组件的对象,里面有像setup、render这些方法。运行时我们就是要动态的解析这些对象,初始化组件实例、构建响应式系统、挂载vue实例、生成虚拟dom以及渲染成真实dom。

# 创建Vue实例

就算使用vue的脚手架,我们在main.js文件里面也会创建一个vue实例吧?当vue实例开始创建时,首先会合并各个选项,包括编译生成的setup函数和渲染函数。vue实例的创建方法会整理成这样:

const app = Vue.createApp({
  setup() {
    const message = ref('Hello Vue 3');
    return { message };
  },
  render() {
    return h('div', null, message.value);
  }
});

# 初始化Vue实例、构建响应式系统

接下来就会执行Vue实例的setup方法,将setup方法返回的数据和方法绑定到组件实例上,同时会在执行的各个时机执行对应的生命周期回调。

  • 组件实例:组件实例是用来代表组件的一个抽象,其实也就是一个简单的对象,里面有一个组件中的属性、方法、以及各个生命周期回调和组件状态等等,每个组件都会有一个组件实例来代表组件状态。

在setup函数执行、初始化组件实例的过程中,就会遇到很多ref、reactive、computed等方法了,从而开始构建响应式系统,并且将这些数据绑定到组件实例上,让他在各个模板中可以被访问(也就是渲染函数render里面可以访问到),并且数据会响应式变化。

详细一点说,vue的响应式是基于数据劫持来实现的,ref或reactive执行的时候,会注册代理对象(基于proxy),并在getter和setter中执行依赖收集和依赖触发。

依赖收集指的是当代理对象的某个属性被访问时,会将访问这个属性的函数收集进一个容器中(触发getter),当代理对象属性被设置的时候(触发setter)进行依赖触发,遍历执行读取了该属性的每一个函数。

实际上,读取了这些代理对象的函数,就是各个组件通过模板编译而来的渲染函数render了(模板上使用响应式数据,被编译成渲染函数,这个渲染函数就是要被收集的副作用函数)。当数据有所改变的时候,会触发代理对象的setter,将读取了该属性的渲染函数重新执行,重新渲染页面视图。(当然还涉及diff算法等)

# 生成虚拟DOM

到这个阶段,就是执行实例的渲染函数render了。渲染函数执行后会生成虚拟DOM树,虚拟DOM就是一个普通的js对象,用来代表页面真实DOM的抽象结构。 比如这样的模板:

<template>
  <div>{{ message }}</div>
</template>

会被编译成这样的渲染函数:

function render() {
  return h('div', null, this.message);
}

渲染函数执行后,会生成这样的虚拟DOM:

const vnode = {
  tag: 'div',
  props: null,
  children: 'Hello Vue 3'
};

# 渲染真实DOM

# patch函数

这部分就是渲染器的工作了。渲染器中会有各种的patch函数,负责真实DOM的渲染以及更新。

# 创建真实DOM节点

在初次渲染时,patch 函数会遍历虚拟 DOM 树,创建相应的真实 DOM 节点,并将它们插入到页面中。

例如:

const el = document.createElement(vnode.tag);
el.textContent = vnode.children;
document.querySelector('#app').appendChild(el);

# 更新真实DOM

之前有说过,当数据改变的时候,会触发响应式重新执行渲染函数,重新生成虚拟DOM并需要重新挂载到页面上,而如何使更新的成本最小、即只更新有变化的地方,这就是patch函数更新部分的工作了,此时就会执行Diff算法,对比新旧虚拟DOM的差异,然后根据差异更新真实DOM。

举个不恰当,但容易理解的例子,当message变成Hello Vue3 Updated时:

const newVnode = {
  tag: 'div',
  props: null,
  children: 'Hello Vue 3 Updated'
};

// 比较 newVnode 和 oldVnode,更新真实 DOM
if (newVnode.children !== vnode.children) {
  el.textContent = newVnode.children;
}

# 挂载完成

此时组件已经彻底挂载完成啦,当然就会执行组件的mounted生命周期回调。

# 总结

本篇博客梳理了vue从模板编译到vue实例创建、组件实例构建、响应式系统构建、虚拟DOM生成、渲染真实DOM、更新真实DOM的整个过程,理清的编译器、响应式系统、渲染器之间的关系,以及Vue构建的关键过程。我自认为先从宏部理解vue的工作流程是非常重要的,这样看源码或者相关书籍的时候心里也会有底,不容易产生“这里是干嘛的”疑问。

本篇博客还是只是简单的梳理流程,具体各部分在实现上还有非常多可以学习的细节,后续我们再对各部分进行更加深入的学习🤣