6
头图

哈喽,很高兴你能点开这篇博客,本博客是针对 Vite 源码的解读系列文章,认真看完后相信你能对 Vite 的工作流程及原理有一个简单的了解。

Vite 是一种新型的前端构建工具,能够显著提升前端开发体验。

我将会使用图文结合的方式,尽量让本篇文章显得不那么枯燥(显然对于源码解读类文章来说,这不是个简单的事情)。

如果你还没有使用过 Vite,那么你可以看看我的前两篇文章,我也是刚体验没两天呢。(如下)

本篇文章是 Vite 源码解读系列的第三篇文章,往期文章可以看这里:

本篇文章解读的主要是 vite 源码本体,在往期文章中,我们了解到:

  • vite 在本地开发时通过 connect 库提供开发服务器,通过中间件机制实现多项开发服务器配置,没有借助 webpack 打包工具,加上利用 rollup (部分功能)调度内部 plugin 实现了文件的转译,从而达到小而快的效果。
  • vite 在构建生产产物时,将所有的插件收集起来,然后交由 rollup 进行处理,输出用于生产环境的高度优化过的静态资源。

本篇文章,我会针对贯穿前两期文章的 vite 的插件 —— @vitejs/plugin-vue 来进行源码解析。

好了,话不多说,我们开始吧!

vite:vue

vite:vue 插件是在初始化 vue 项目的时候,就被自动注入到 vite.config.js 中的插件。(如下)

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue()
  ]
});

该插件导出了几个钩子函数,这几个钩子函数,部分是用于 rollup,部分是 vite 专属。(如下图)

image

在开始阅读源码之前,我们需要先了解一下 viterollup 中每一个钩子函数的调用时机和作用。

字段说明所属
name插件名称viterollup 共享
handleHotUpdate执行自定义 HMR(模块热替换)更新处理vite 独享
config在解析 Vite 配置前调用。可以自定义配置,会与 vite 基础配置进行合并vite 独享
configResolved在解析 Vite 配置后调用。可以读取 vite 的配置,进行一些操作vite 独享
configureServer是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件。vite 独享
transformIndexHtml转换 index.html 的专用钩子。vite 独享
options在收集 rollup 配置前,vite (本地)服务启动时调用,可以和 rollup 配置进行合并viterollup 共享
buildStartrollup 构建中,vite (本地)服务启动时调用,在这个函数中可以访问 rollup 的配置viterollup 共享
resolveId在解析模块时调用,可以返回一个特殊的 resolveId 来指定某个 import 语句加载特定的模块viterollup 共享
load在解析模块时调用,可以返回代码块来指定某个 import 语句加载特定的模块viterollup 共享
transform在解析模块时调用,将源代码进行转换,输出转换后的结果,类似于 webpackloaderviterollup 共享
buildEndvite 本地服务关闭前,rollup 输出文件到目录前调用viterollup 共享
closeBundlevite 本地服务关闭前,rollup 输出文件到目录前调用viterollup 共享

在了解了 viterollup 的所有钩子函数后,我们只需要按照调用顺序,来看看 vite:vue 插件在每个钩子函数的调用期间都做了些什么事情吧。

config

config(config) {
  return {
    define: {
      __VUE_OPTIONS_API__: config.define?.__VUE_OPTIONS_API__ ?? true,
      __VUE_PROD_DEVTOOLS__: config.define?.__VUE_PROD_DEVTOOLS__ ?? false
    },
    ssr: {
      external: ['vue', '@vue/server-renderer']
    }
  }
}

vite:vue 插件中的 config 做的事情比较简单,首先是做了两个全局变量 __VUE_OPTIONS_API____VUE_PROD_DEVTOOLS__ 的替换工作。然后又设置了要为 SSR 强制外部化的依赖。

configResolved

config 钩子执行完成后,下一个调用的是 configResolved 钩子。(如下)

configResolved(config) {
  options = {
    ...options,
    root: config.root,
    sourceMap: config.command === 'build' ? !!config.build.sourcemap : true,
    isProduction: config.isProduction
  }
},

vite:vue 中的 configResolved 钩子,读取了 rootisProduction 配置,存储在插件内部的 options 属性中,以便提供给后续的钩子函数使用。

然后,判断当前命令是否为 build,如果是构建生产产物,则读取 sourcemap 配置来判断是否生成 sourceMap,而本地开发服务始终会生成 sourceMap 以供调试使用。

configureServer

configureServer 钩子中,vite:vue 插件只是将 server 存储在内部 options 选项中,并无其他操作。(如下)

configureServer(server) {
  options.devServer = server;
}

buildStart

buildStart 钩子函数中,创建了一个 compiler,用于后续对 vue 文件的编译工作。(如下)

buildStart() {
  options.compiler = options.compiler || resolveCompiler(options.root)
}

image

complier 中内置了很多实用方法,这些方法负责按照规则对 vue 文件进行庖丁解牛。

load

在运行完了上述几个钩子后,vite 本地开发服务就已经启动了。

我们打开本地服务的地址,对资源发起请求后,将会进入下一个钩子函数。(如下图)

image

打开服务后,首先进入的是 load 钩子,load 钩子主要做的工作是返回 vue 文件中被单独解析出去的同名文件。

vite 内部会将部分文件内容解析到另一个文件,然后通过在文件加载路径后面加上 ?vuequery 参数来解析该文件。比如解析template(模板)、script(js 脚本)、cssstyle 模块)...(如下图)

image

而这几个模块(templatescriptstyle)都是由 complier.parse 解析而来(如下)

const { descriptor, errors } = compiler.parse(source, {
  filename,
  sourceMap
});

transform

load 返回了对应的代码片段后,进入到 transform 钩子。

transform 主要做的事情有三件:

  • 转译 vue 文件
  • 转译以 vue 文件解析的 template 模板
  • 转译以 vue 文件解析的 style 样式
简单理解,这个钩子对应的就是 webpackloader

image

这里,我们以一个 TodoList.vue 文件为例,展开聊聊 transform 所做的文件转译工作。

下面是 TodoList.vue 源文件,它做了一个可供增删改查的 TodoList,你也可以通过 第二期文章 - Vite + Vue3 初体验 —— Vue3 篇 了解它的详细功能。

<script setup lang="ts">
import { DeleteOutlined, CheckOutlined, CheckCircleFilled, ToTopOutlined } from '@ant-design/icons-vue';
import { Input } from "ant-design-vue";
import { ref } from "vue";
import service from "@/service";
import { getUserKey } from '@/service/auth';

// 创建一个引用变量,用于绑定 Todo List 数据
const todoList = ref<{
  id: string;
  title: string;
  is_completed: boolean;
  is_top: boolean;
}[]>([]);
// 初始化 todo list
const getTodoList = async () => {
  const reply = await service.get('/todo/get-todo-list', { params: { key: getUserKey() } });
  todoList.value = reply.data.data;
}
getTodoList();

// 删除、完成、置顶的逻辑都与 todoList 放在同一个地方,这样对于逻辑关注点就更加聚焦了
const onDeleteItem = async (index: number) => {
  const id = todoList.value[index].id;
  await service.post('/todo/delete', { id });

  todoList.value.splice(index, 1);
}
const onCompleteItem = async (index: number) => {
  const id = todoList.value[index].id;
  await service.post('/todo/complete', { id });

  todoList.value[index].is_completed = true;
  // 重新排序,将已经完成的项目往后排列
  const todoItem = todoList.value.splice(index, 1);
  todoList.value.push(todoItem[0]);
}
const onTopItem = async (index: number) => {
  const id = todoList.value[index].id;
  await service.post('/todo/top', { id });

  todoList.value[index].is_top = true;
  // 重新排序,将已经完成的项目往前排列
  const todoItem = todoList.value.splice(index, 1);
  todoList.value.unshift(todoItem[0]);
}

// 新增 Todo Item 的逻辑都放在一处
// 创建一个引用变量,用于绑定输入框
const todoText = ref('');
const addTodoItem = () => {
  // 新增一个 TodoItem,请求新增接口
  const todoItem = {
    key: getUserKey(),
    title: todoText.value
  }
  return service.post('/todo/add', todoItem);
}
const onTodoInputEnter = async () => {
  if (todoText.value === '') return;

  await addTodoItem();
  await getTodoList();

  // 添加成功后,清空 todoText 的值
  todoText.value = '';
}
</script>

<template>
  <section class="todo-list-container">
    <section class="todo-wrapper">
      <!-- v-model:value 语法是 vue3 的新特性,代表组件内部进行双向绑定是值 key 是 value -->
      <Input v-model:value="todoText" @keyup.enter="onTodoInputEnter" class="todo-input" placeholder="请输入待办项" />
      <section class="todo-list">
        <section v-for="(item, index) in todoList" 
          class="todo-item" 
          :class="{'todo-completed': item.is_completed, 'todo-top': item.is_top}">
          <span>{{item.title}}</span>
          <div class="operator-list">
            <CheckCircleFilled v-show="item.is_completed" />
            <DeleteOutlined v-show="!item.is_completed" @click="onDeleteItem(index)" />
            <ToTopOutlined v-show="!item.is_completed" @click="onTopItem(index)" />
            <CheckOutlined v-show="!item.is_completed" @click="onCompleteItem(index)" />
          </div>
        </section>
      </section>
    </section>
  </section>
</template>

<style scoped lang="less">
.todo-list-container {
  display: flex;
  justify-content: center;
  width: 100vw;
  min-height: 100vh;
  box-sizing: border-box;
  padding-top: 100px;
  background: linear-gradient(rgba(219, 77, 109, .02) 60%, rgba(93, 190, 129, .05));
  .todo-wrapper {
    width: 60vw;
    .todo-input {
      width: 100%;
      height: 50px;
      font-size: 18px;
      color: #F05E1C;
      border: 2px solid rgba(255, 177, 27, 0.5);
      border-radius: 5px;
    }
    .todo-input::placeholder {
      color: #F05E1C;
      opacity: .4;
    }
    .ant-input:hover, .ant-input:focus {
      border-color: #FFB11B;
      box-shadow: 0 0 0 2px rgb(255 177 27 / 20%);
    }
    .todo-list {
      margin-top: 20px;
      .todo-item {
        box-sizing: border-box;
        padding: 15px 10px;
        cursor: pointer;
        border-bottom: 2px solid rgba(255, 177, 27, 0.3);
        color: #F05E1C;
        margin-bottom: 5px;
        font-size: 16px;
        transition: all .5s;
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding-right: 10px;
        .operator-list {
          display: flex;
          justify-content: flex-start;
          align-items: center;
          :first-child {
            margin-right: 10px;
          }
        }
      }

      .todo-top {
        background: #F05E1C;
        color: #fff;
        border-radius: 5px;
      }

      .todo-completed {
        color: rgba(199, 199, 199, 1);
        border-bottom-color: rgba(199, 199, 199, .4);
        transition: all .5s;
        background: #fff;
      }

      .todo-item:hover {
        box-shadow: 0 0 5px 8px rgb(255 177 27 / 20%);
        border-bottom: 2px solid transparent;
      }

      .todo-completed:hover {
        box-shadow: none;
        border-bottom-color: rgba(199, 199, 199, .4);
      }
    }
  }
}
</style>

进入到 transformMain 函数,可以发现 transformMain 内部主要做了几件事情:

  • 解构 vue 文件的 scripttemplatestyle
  • 解析 vue 文件中的 script 代码;
  • 解析 vue 文件中的 template 代码;
  • 解析 vue 文件中的 style 代码;
  • 解析 vue 文件中的 自定义模块 代码;
  • 处理 HMR(模块热重载)的逻辑;
  • 处理 ssr 的逻辑;
  • 处理 sourcemap 的逻辑;
  • 处理 ts 的转换,转成成 es

接下来,我们将深入源码,将每一项任务深入解析。

解构 scripttemplatestyle

vue 文件中包含 scripttemplatestyle 三大部分,transformMain 内部先通过 createDescriptor 中的 compiler 将这三大块分离解析出来,作为一个大对象,然后方便后面的解析。(如下图)

image

compiler 中,会先使用 parse 方法,将源码 source 解析成 AST 树。(如下图)

image

在下图中可以看出,解析后的 AST 树有三个模块,主要就是 scripttemplatestyle

image

接下来,就是将各个模块的属性、代码行数记录起来,比如 style 标签,就记录了 lang: less 的信息,以供后面的解析。

解析 Template

vue 文件中的 template 写了很多 vue 的语法糖,比如下面这行

<Input v-model:value="todoText" @keyup.enter="onTodoInputEnter" class="todo-input" placeholder="请输入待办项" />

像这种语法,浏览器是无法识别并将事件绑定到 vue 的内部函数中的,所以 vite 对这类标签先做了一遍内部转换,转换成可执行的函数,再通过浏览器执行函数生成一套 虚拟 DOM,最后再由 vue 内部的渲染引擎将 虚拟 DOM 渲染成 真实 DOM

现在我们就可以看看 vite 内部对 template 语法的转译过程,vite 内部是通过 genTemplateCode 函数来实现的。

genTemplateCode 内部,首先是将 template 模板语法解析成了 AST 语法树。(如下图)

image

然后再通过不同的转译函数,对对应的 AST 节点进行转换。

image

下面我们以 Input 节点为例来简单解释一下转译的过程。

image

将这个步骤重复,直到整棵 template 树都解析完成。

解析 script 标签

下面,我们来看看对 script 标签的解析部分,对应的内部函数是 genScriptCode

这个函数所做的事情主要是下面几件事情:

  1. 解析 script 标签中定义的变量;
  2. 解析 script 标签中定义的引入 import,后面将会转换成相对路径引入;
  3. script 标签编译成一个代码片段,该代码片段导出 _defineComponent(组件)封装的对象,内置 setup 钩子函数。

我们用图来说明以上三个步骤。(如下图)

image

解析 style 标签

style 标签解析的比较简单,只是将代码解析成了一个 import 语句(如下)

import "/Users/Macxdouble/Desktop/ttt/vite-try/src/components/TodoList.vue?vue&type=style&index=0&scoped=true&lang.less"

随后,根据该请求中 query 参数中的 typelang,由 vite:vue 插件的 load 钩子(上一个解析的钩子)中的 transformStyle 函数来继续处理样式文件的编译。这部分我就不做展开了,感兴趣的同学可以自行阅读代码。

编译 tses

scripttemplatestyle 部分的代码都解析完毕后,接下来还做了下面几个处理:

  • 解析 vue 文件中的 自定义模块 代码;
  • 处理 HMR(模块热重载)的逻辑;
  • 处理 ssr 的逻辑;
  • 处理 sourcemap 的逻辑;
  • 处理 ts 的转换,转成成 es

由于篇幅原因,这里只对 tses 的转换做个简单介绍,这一步主要是在内部通过 esbuild 完成了 tses 的转换,我们可以看到这个工具有多快。(如下图)

image

输出代码

ts 也转译成 es 后,vite:vue 将转换成了 esscripttemplatestyle 代码合并在一起,然后通过 transform 输出,最终输出为一个 es 模块,被页面作为 js 文件加载。(如下图)

image

handleHotUpdate

最后,我们来看看对于文件模块热重载的处理,也就是 handleHotUpdate 钩子。

我们在启动项目后,在 App.vue 文件的 setup 中加入一行代码。

console.log('Test handleHotUpdate');

在代码加入并且保存后,被 vite 内部的 watcher 捕获到变更,然后触发了 handleHotUpdate 钩子,将修改的文件传入。

vite:vue 内部会使用 compiler.parse 函数对 App.vue 文件进行解析,将 scripttemplatestyle 标签解析出来。(也就是上面解析的编译步骤)(如下图)。

image

然后,handleHotUpdate 函数内部会检测发生变更的内容,将变更的部分添加到 affectedModules 数组中。(如下图)

image

然后,handleHotUpdateaffectedModules 返回,交由 vite 内部处理。

最后,vite 内部会判断当前变更文件是否需要重新加载页面,如果不需要重新加载的话,则会发送一个 update 消息给客户端的 ws,通知客户端重新加载对应的资源并执行。(如下图)

image

好了,这样一来,模块热重载的内容我们也清楚了。

小结

本期对 @vitejs/plugin-vue 的解析就到这里结束了。

可以看出,vite 内部结合了 rollup 预设了插件的多个生命周期钩子,在编译的各个阶段进行调用,从而达到 webpackloader + plugin 的组合效果。

vite/rollup 直接使用 plugin 就替代了 webpackloader + plugin 功能,可能也是为了简化概念,整合功能,让插件的工作更简单,让社区的插件开发者也能更好的参与贡献。

vite 的快不仅仅是因为运行时不编译原生 es 模块,还有在运行时还利用了 esbuild 这类轻而快的编译库来编译 ts,从而使得整个本地开发时变得非常地轻快。

下一章,我们将对 vite 插件进行实战练习:实现一个 vite 插件,它的功能是通过指定标签就能加载本地 md 文件。

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!


晒兜斯
1.8k 声望534 粉丝