Vite3 源码整体浅解析

406 阅读10分钟

前言

Vite 法语意为“快速的”,是一种前端构建工具,功能同 webpack、rollup 和 esbuild(这三者都是针对JavaScript 模块打包的工具),但仍是基于 rollup 和 esBuild 构建的。两个地址:

将源码下载到本地,开始学习~

整体梳理

从 package.json 文件知道项目名称 vite-monorepo,也暗含该项目使用 monorepo 方式来管理之意,其核心理念:一个仓库(repo)管理多个模块/包(package),模块/包间相互独立,单独管理(关于 monorepo 管理项目的优缺点可详细了解)。

该项目管理一个文档(docs,由 vitepress (静态站点生成器)搭建的文档站点)和六个模块(一个脚手架(create-vite)、一个构建工具(vite)和四个插件(plugin-legacy、 plugin-react、plugin-vue 和 plugin-vue-jsx),置于 packages目录下)。

模块名称模块描述打包工具Node内置模块第三方模块
create-vite脚手架unbuildfs和 pathcross-spawn、kolorist、minmist 和prompts
vite前端构建工具rollupperf_hooks、fs、path和readline 等esbuild、rollup、cac、chokidar、picocolors、dotenv 等
plugin-legacy给传统浏览器提供支持unbuildpath、module、url和crypto 等vite、rollup 和 magic-string 等
plugin-react提供完整的 React 支持unbuildpathvite和magic-string 等
plugin-vue提供 Vue3 单文件组件支持unbuildpathvite 和 rollup 等
plugin-vue-jsx提供 Vue3 JSX 支持(通过专用的 Babel 转换插件)unbuildpathvite、@vue/babel-plugin-jsx和 @babel/core 等

下面进行单个模块的源码学习~

create-vite

create-vite 是搭建 vite 项目的脚手架(cli) ,脚手架命令 create-vite(由 package.json 文件 bin 字段指出), 其入口地址 index.js,通过一系列与用户的交互最终生成一个vite项目,供用户后续项目开发。过程中,使用 minimist 处理命令行参数 、promots 与用户交互、kolorist 将颜色放入标准输入/标准输出的库(即给 console.log 输出加颜色)及如果自定义脚手架框架时使用 cross-spawn 执行相应命令。

通过该源码的学习,可以了解到 自定义命令的执行过程 以及 node 与用户的交互格式化输出 ,可以看下笔者前面写的两篇文章:

命令 create-vite my-project --template vue 执行过程大致如下:

create-vite3.png

使用了 unbuild 构建工具(基于 rollup)进行打包,其默认配置文件 build.config.ts,通过执行命令 yarn prepublishOnly 来打包,打包后项目根目录会生成一个 dist 目录,里面两个文件

  • index.cjs:commonJs
  • index.mjs:esm

项目入口文件 index.js 代码:

#!/usr/bin/env node

import './dist/index.mjs'

vite

vite 是基于 rollup 和 esbuild 的前端构建工具。esbuild 是 go 语言编写,而 go语言是 C 语言开发的,所以 esbuild 的构建速度极快,在 vite 的构建过程中负责编译和预编译工作,而 rollup 用来打包。

命令 vite 的入口文件 bin/vite.js,而入口文件引入并执行了 src/node/cli.ts,该文件使用 cac 创建了四个命令:

  • serve/dev:默认命令,开启本地服务;
  • build:构建打包,使用 rollup;
  • optimize:依赖优化;
  • preview:预览

执行这四个命令时,首先要做的是获取配置数据,主要分两部分:

  • inlinConfig 命令行配置参数;
  • userConfig(可通过 --config 显示指定配置文件,也可用官方提供的默认配置文件 vite.config.*)
// serve/dev
const config = await resolveConfig(inlineConfig, 'serve', 'development')

// build
const config = await resolveConfig(inlineConfig, 'build', 'production')

// optimize
const config = await resolveConfig(
    {
        root,
        base: options.base,
        configFile: options.config,
        logLevel: options.logLevel
    },
    'build',
    'development'
)

// preview
const config = await resolveConfig(inlineConfig, 'serve', 'production')

而 resolveConfig 方法作用:聚合这两部分的配置数据。


vite的工作机制:用户向vite请求资源时,vite内部开启了一个开发服务器vite devServer,在这个服务器内部有个工作机制,相当于一个流水线(插件)。插件又分为系统插件(vue)和用户插件。用户请求过来必定经过所有的插件,去处理一下。这就有点向过滤器、中间件。将请求所需的资源进行处理,处理完成之后把转换的结果返回给用户,得到用户想要的东西。

图片2.png

vite特点:请求一个页面,显示的就是这个页面的内容,无需加载和页面无关的东西。这就意味着,以后的项目不管多大,它的加载速度都是一样的。

插件的形式可以是对象也可以是一个函数:包含插件名称、相关钩子函数(在vite工作的时间节点去执行)。

未完待续~

vite 插件

前言

vite 插件基于 rollup 插件,vite 的构建过程中对插件的解析,先将插件进行排序,后执行。插件的执行有两种顺序:

(1)首先会根据enforce(取值范围和顺序:pre, normal, post)对插件排序得到 userPlugins;

const [prePlugins, normalPlugins, postPlugins] = sortUserPlugins(rawUserPlugins)

(2)根据插件内部的config钩子对userPlugins进行排序得到完整的插件并执行。

*// run config hooks***
const userPlugins = [...prePlugins, ...normalPlugins, ...postPlugins]
for (const p of getSortedPluginsByHook('config', userPlugins)) {
    const hook = p.config
    const handler = hook && 'handler' in hook ? hook.handler : hook
    if (handler) {
      const res = await handler(config, configEnv)
      if (res) {
        config = mergeConfig(config, res)
      }
    }
}

插件的一些认识

  1. 插件的定义有两种方式:(1)对象;(2)函数,其中函数可接收参数。

  2. 插件的编写主要涉及两部分:(1)name:插件名称,用来定位;(2)钩子函数。按需应用:默认情况下插件在开发(serve)和构建(build)模式中都会调用,如果插件只需要在预览或构建期间有条件地引用,请使用apply属性指明它们仅在build或serve模式时调用,同时还可使用函数来进行更精准的控制。

  3. 插件的使用:在配置文件(通常是 vite.config.js 或 vite.config.ts)的plugins字段里插入。

  4. vite 插件常用钩子函数(可细看vite插件API文档):

    (1)config(config) {...} 修改配置参数;

    (2)configResolved(config) {...} 配置确认,这里不可修改配置参数;

    (3)configServer(server) {...} 可以获取到 sever 实例,用于配置 dev server;

    (4)buildStart(opt) {...} 每次构建的时候调用,用来获取打包的输入配置;

    (5)resolved(source) {...} 从哪里引用,用来自定义解析行为。入参:source(解析的模块的名称)。返回值:返回字符串:一般是报名,作为id,传给load钩子函数;返回null:不予处理;返回false:说明这个包配置了external参数,不进行打包;

    (6)load(id, opt) {...} 怎么加载,返回值:字符串(作为模块的code,可以用来在构建时修改模块的代码);null(什么都不做,延顺给下一步);对象({code,map,...})

    (7)transform(code, id, opt) {...} 怎么转换,入参 code 是JSON 字符串(load钩子函数返回的值,默认情况下是模块的原始文本),返回值:字符串(代替code,传递给下一个);null(什么都不做,给下一个函数);对象({code, ast, ...});

    上面入参id是请求的资源路径或导入第三方模块名称.

  5. vite 官方维护了四个插件: plugin-legacyplugin-reactplugin-vueplugin-vue-jsx,下面我们来看看插件是如何实现和引用的,以及钩子函数是如何使用的~

plugin-vue

首先来看 plugin-vue,看到这个插件,首先考虑它实现了什么功能?如何实现的?带着问题去学习会事半功倍。

在真实 vue3 项目中,配置文件plugins中引入了该插件:

import { defineConfig } from 'vite'
import vuePlugin from '@vitejs/plugin-vue'
export default defineConfig({
    ...,
    plugins: [
        vuePlugin, // 使用plugin-vue插件
        ...
    ]
})

小测一下,在配置文件的plugins注释掉vuePlugin,看到终端报错:

图片1.png

需要插件 plugin-vue 来处理 .vue 文件,因为无法识别 .vue 文件里的 template 和s cript 标签(如果有样式还会有 style 标签),得知该插件是来解析 .vue 文件里的 template 和 script ,以及 style 的,从而让 vite 能够识别。

那么问题来了,plugin-vue 是如何处理 .vue 文件的呢?


在看这个问题的过程中,发现用户开发的插件有两种形式

(1)处理文件的,例如plugin-vue处理 .vue 文件,使用时只需一步:

  • 在配置文件的plugins字段中插入该插件,这种情况下在插件编写过程中的id是当前请求的文件所在的绝对路径,通过文件路径进行接管的筛选。

例如:识别App.vue自定义块i18n插件vite-plugin-i18n

App.vue代码如下:

<template>...</template>

<script>...</script>

<style>...</style>

<i18n>
  {
    "en": {"language": "Language", "hello": "hello, world!"},
    "zh": {"language": "语言","hello": "你好,世界!"},
  }
</i18n>

编写插件 vite-plugin-i18n 的主要代码:

export default {
  transform(*code*, *id*) { // id是请求的资源路径
      if(!/vue&type=i18n/.test(*id*)) { // 对资源文件进行筛选
        return null
      }
      return `export default Comp => {
        Comp.i18n = ${*code*}
      }`
    }
}

使用该插件,只需在配置文件中plugins插入即可

import vitePluginI18n from './plugins/vite-plugin-i18n'
export default defineConfig({
    ...,
    plugins: [..., vitePluginI18n,]
})

这样就可以识别App.vue自定义的块i18n,并且可通过getCurrentInstance获取当前实例从而拿到i18n里的值。

(2)处理第三方导入的插件,使用时需要二步:

  • 在配置文件的plugins字段中插入该插件;
  • 在需要使用该插件的文件中导入该插件,例如: import virtualModule from ‘virtual-module’,插件编写过程中的id是‘virtual-module’,据此来进行截关的筛选。

例如:自定义第三方模块my-module:首先根据create-vite创建一个vue项目,在项目根目录下创建plugins文件夹,并新建plugins/vite-plugin-example.js文件,代码如下:

const virtualModuleId = 'my-module-2'
export default function(*options*={}) {
  return {
    name: "my-module", // 插件名称, 在使用时不一定导入是这个name值
    resolveId(id) { // id是在使用插件导入的模块名称
      if(id === virtualModuleId) {
        return virtualModuleId  // 表示接管, vite不再询问其他插件处理该id请求
      }
      return null  // 返回null表明是其他id要继续处理
    },
    load(id) { // id是在使用插件导入的模块名称
      if(id === virtualModuleId) {
        return 'export default "哈喽, 露水晰!"' // 返回加载模块代码
      }
      return null  // 其他id继续处理
    },
  }
}

在配置文件中plugins插入该插件:

import vitePluginExample  from './plugins/vite-plugin-example'
export default defineConfig({
    ...,
    plugins: [..., vitePluginExample(),]
})

在main.js中引入并使用:

import myModule from 'my-module-2'
console.log("msg:", myModule) // msg: 哈喽, 露水晰!

注意:import的from后的值只要与插件中的virtualModuleId值保持一致即可。

针对这两种形式的插件,官方提供了两个示例:

网上一篇文章写的不错:segmentfault.com/a/119000004…

plugin-vue 解析 main.js 过程

入口文件:main.js,该文件导入了 App.vue(import App from '/src/App.vue'),因后缀是vue则进入插件plugin-vue:

(1)因当前文件(App.vue)的 query.vue不存在,进入transform 处理,通过 transformMain,使用vue/complile-sfc 根据 SFC 得到 SFCDescriptor(使用了compiler.parse方法)并存入 cache(new Map(),以便二次直接取用);

(2)根据 SFCDescriptor 将原 SFC 转为 JavaScript(编译 script、template、style 和 customBlock 使其各自转译为对应的 js,最后输出 App.vue 的内容顺序:scriptCode、templateCode、styleCode、customBlocksCode等),得到一个 JavaScript 模块:

// App.vue
...script-code...
...template-code(_sfc_render)...

import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css"
import block0 from "/src/App.vue?vue&type=i18n&index=0&lang.i18n"
if (typeof block0 === 'function') block0(_sfc_main)
...

(3)解析 App.vue,过程中有样式/自定义块的导入(例如:import "/src/App.vue?vue&type=style&index=0&scoped=7a7a37b1&lang.css"等),再次进入插件 plugin-vue 处理,通过 load 得到对应的请求资源:

// plugin-vue
load(id, opt) { // id是文件路径(包含文件名称)
      const ssr = opt?.ssr === true;
      if (id === EXPORT_HELPER_ID) {
        return helperCode;
      }
      const { filename, query } = parseVueRequest(id);
      if (query.vue) {  // 只有在编译后的vue资源请求后进来
        if (query.src) { // 如果是外部链接,则直接读取链接的内容
          return fs.readFileSync(filename, "utf-8");
        }
        // 这里是拿到第一次解析的App.vue的SFCDescriptor,第一次拿到后存入到了cache
        const descriptor = getDescriptor(filename, options); 
        let block;
        if (query.type === "script") {
          block = getResolvedScript(descriptor, ssr);
        } else if (query.type === "template") {
          block = descriptor.template;
        } else if (query.type === "style") {
          block = descriptor.styles[query.index];
        } else if (query.index != null) {
          block = descriptor.customBlocks[query.index];
        }
        if (block) {
          return {
            code: block.content,
            map: block.map
          };
        }
      }
},

未完待续~