稀土掘金技术社区

Vue3 业务组件库按需加载的实现原理

前言

最近在公司实现一个业务组件库按需加载的需求。简单来说,有两个需求,第一个是实现业务组件库的按需加载,第二,因为业务组件库里面有引用了类似 Element Plus 的第三方组件库,所以在实现业务组件库按需加载的同时,业务组件库里面的引用的第三方组件库也要实现按需加载。

作为一个编程技术人员,即便有了 AI,也需要研究底层的技术原理,甚至需要比没有 AI 的时代,需要更加深入研究,在 AI 时代,基础的都通过 AI 实现了,只有 AI 解决不了的问题,最终还得靠你自己的专业知识去解决,而这将是你的核心竞争力的体现,所以在 AI 时代对技术人员的技术素养要求将更加的高。

扯远了,我们回到业务组件库按需加载的实现原理的主题上来。

一般在项目中如果没有进行组件库按需加载配置,都是一开始就全量加载进行全局组件注册,这样就等于整个组件库在初始化的时候就全部加载了,如果在追求性能的项目中,这是不可接受的。这时我们就要实现组件库的按需加载,来提高性能。

按需加载的基本实现原理

首先什么是按需加载?

所谓按需加载,顾名思义就是有需要就加载,不需要就不加载,比如 Element Plus 组件库有几十个组件,可能在我们的项目只用到了到了其中一个组件 <el-button>,那么我们就希望只加载跟这个按钮组件相关的代码,从而达到减少打包体积的效果。

按需加载最简单的实现方式就是手动设置,实现如下:

<template>  <el-button>按钮</el-button></template>
<script>import { ElButton } from 'element-plus/es/components/button'import 'element-plus/es/components/button/style/index'
export default {  components: { ElButton },}</script>

我们像上述例子这样手动引用第三方组件库的话,在打包的时候就只会打包引用到的组件,因为目前的开源组件库基本都实现了利于 Tree Shaking 的 ESM 模块化实现。

如果每个业务组件都需要进行上述设置,其实还是挺繁琐的,所以我们希望只在 template 中直接调用就好,其他什么设置都不需要,就像全局注册组件那样使用。

<template>  <el-button>按钮</el-button></template>

而剩下部分的代码,我们希望在打包或者运行的时候自动设置上去。主要是以下部分的代码:

import { ElButton } from 'element-plus/es/components/button'import 'element-plus/es/components/button/style/index'

上述部分的代码,希望自动加载,而不需要手动设置。整个所谓按需加载所需要实现的就是上述的功能。

那么怎么实现呢?

首先上述模板代码的编译结果如下:

import { createTextVNode as _createTextVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {  const _component_el_button = _resolveComponent("el-button")
  return (_openBlock(), _createElementBlock("template", null, [    _createVNode(_component_el_button, null, {      default: _withCtx(() => [        _createTextVNode("按钮")      ], undefined, true),      _: 1 /* STABLE */    })  ]))}

我们只需要找到 Vue3 的内置函数 _resolveComponent("el-button") 部分,然后替换成对应的组件代码即可。例如:

+ import { ElButton } from 'element-plus/es/components/button'+ import 'element-plus/es/components/button/style/index'import { createTextVNode as _createTextVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {-  const _component_el_button = _resolveComponent("el-button")+ const _component_el_button = ElButton
  return (_openBlock(), _createElementBlock("template", null, [    _createVNode(_component_el_button, null, {      default: _withCtx(() => [        _createTextVNode("按钮")      ], undefined, true),      _: 1 /* STABLE */    })  ]))}

上述就是组件库按需加载的基本实现原理。

使用 Vite 打包组件库

为了更好还原实际场景,我们快速创建一个组件库项目并且通过 Vite 进行打包。 首先创建一个 cobyte-vite-ui 的组件库目录,在根目录下初始化 Node 项目,执行 pnpm init, 会自动生成 package.json 文件,内容如下:

{  "name": "cobyte-vite-ui",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "test": "echo \"Error: no test specified\" && exit 1"  },  "keywords": [],  "author": "",  "license": "ISC",  "packageManager": "[email protected]",}

在根目录新建 pnpm-workspace.yaml 文件进行 Monorepo 项目配置:

packages:  - packages/*  - play

总的目录结构如下:

├── packages│   ├── components│   ├── hooks│   └── utils├── play├── package.json└── pnpm-workspace.yaml

接着我们安装一些必要的依赖:

pnpm add vite typescript @vitejs/plugin-vue sass @types/node -D -w

接着我们安装一下 vue 依赖:

pnpm add vue -w

基础依赖安装完毕,我们设置一下 TS 的配置,因为我们这个项目是一个 TS 的项目,在根目录创建一个 tsconfig.json,配置内容可以简单设置如下:

{    "compilerOptions": {      "target": "ESNext",      "module": "NodeNext",      "sourceMap": true,  // 关键:启用源映射      "outDir": "./dist", // 可选:指定输出目录      "esModuleInterop": true    }}

接着我们就在 packages/components 目录下创建一个测试按钮组件

目录路径:packages/components/button/button.vue,内容如下:

<template>    <button>测试按钮</button></template><scriptsetuplang="ts">defineOptions({  name: 'co-button',});</script><stylelang="scss"scoped>button {  color: red;}</style>

目录路径:packages/components/button/index.ts,内容如下:

import button from "./button.vue";export const CoButton = button;export default CoButton;

目录路径:packages/components/components.ts,内容如下:

import { CoButton } from './button';export default [    CoButton]

将所有组件集中在一个数组中统一导出,方便批量管理和使用。

目录路径:packages/components/defaults.ts,内容如下:

import { App } from 'vue';import components from './components';
const install = function (app: App) {    components.forEach(component => {        app.component(component.name, component);    });};
export default {    install};

目录路径:packages/components/index.ts,内容如下:

export * from './button';
import install from  './defaults';export default install;

我们再配置一个测试文件,目录路径:packages/utils/index.ts,内容如下:

export function testUtils() {    console.log('testUtils');}

如果大家对创建组件库比较有经验的话,就知道上述步骤,是 Vue3 组件库的基础设置,各大组件库的实现虽然差异很大,但最核心机制都可以简单归纳为上述设置内容。 大家如果想详细了解更多也可以看看本栏目前面章节的内容。

接着我们就到了我们最核心的组件库打包的环节了,我们在根本目录创建一个 vite.config.ts,设置内容如下:

import { defineConfig } from "vite";import vue from "@vitejs/plugin-vue";import path, { resolve } from "path";import fs from "fs";
// 动态获取组件目录列表const componentsDir = resolve(__dirname, "./packages/components");const modules = fs.readdirSync(componentsDir).filter((name) => {    const fullPath = path.join(componentsDir, name);    // 只获取目录,排除文件    return fs.statSync(fullPath).isDirectory();});
const entryArr = {    // 主入口    index: resolve(__dirname, "./packages/components/index.ts"),
    // 工具入口    utils: resolve(__dirname, "./packages/utils/index.ts"),};
// 为每个组件创建独立入口modules.forEach((name) => {    entryArr[`components/${name}/index`] = resolve(__dirname, `./packages/components/${name}/index.ts`);});
export default defineConfig(({ command, mode }) => {    // 主构建配置    return {        plugins: [            vue(),        ],        build: {            lib: {                entry: entryArr,                formats: ["es"], // 只构建 ES 模块                cssFileName: "style",            },            rollupOptions: {                external: [                    "vue",                ],                output: {                    format: "es",                    preserveModules: true,                },            },        },    };});

设置完 Vite 配置文件后,我们还要设置 packages.json 中的打包命令脚本配置,设置如下:

  "scripts": {    "build": "vite build"  },

这样我们就可以在根目录运行打包命令了:pnpm build。

运行结果如下,我们成功打包了我们的组件库。

Image

通过 pnpm 安装本地 npm 包

接着我们在根目录下创建一个测试项目:

pnpm create vite play --template vue-ts

上述 play 就是测试项目目录,我们原本就建了一个 play 目录,现在这条命令会直接在 play 目录中生成一个使用 Vite 创建的 Vue 项目。

接着我们修改根目录的 package.json 文件:

- "main": "index.js",+ "module": "/dist/index.mjs",

接着我们进入 play 目录,通过 pnpm 安装本地 npm 包,命令如下:

pnpm add ../

运行完上述命令,我们可以看到 ./play/packages.json 文件变化如下:

Image

可以看到我们成功把我们本地的 npm 包安装到 play 测试项目中了。

接着修改 ./play/main.ts 内容如下:

import { createApp } from 'vue'import App from './App.vue'import CobyteViteUI from 'cobyte-vite-ui'import 'cobyte-vite-ui/dist/style.css'
const app = createApp(App)app.use(CobyteViteUI)app.mount('#app')

我们直接引用我们本地创建的 npm 包。

接着修改 ./play/App.vue 内容如下:

<template>  <co-button></co-button></template><scriptsetuplang="ts">
</script>

最后我们运行 play 测试项目,结果如下:

Image

我们可以看到成功运行了本地组件库的 npm 包。

接下来我们希望不进行完整引入组件库:

import { createApp } from 'vue'import App from './App.vue'- import CobyteViteUI from 'cobyte-vite-ui'- import 'cobyte-vite-ui/dist/style.css'
const app = createApp(App)- app.use(CobyteViteUI)app.mount('#app')

即便这样我们同样可以在测试项目中使用我们的测试组件。

通过静态分析实现按需加载

根据上文我们知道 App.vue 的模板内容会被编译成:

import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {  const _component_co_button = _resolveComponent("co-button")
  return (_openBlock(), _createElementBlock("template", null, [    _createVNode(_component_co_button)  ]))}

那么根据上文我们知道需要把 _resolveComponent("co-button") 部分替换成对应的组件对象,内容如下:

+ import CoButton from 'cobyte-vite-ui/dist/components/button'+ import 'cobyte-vite-ui/dist/style.css'import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {-  const _component_co_button = _resolveComponent("co-button")+  const _component_co_button = CoButton
  return (_openBlock(), _createElementBlock("template", null, [    _createVNode(_component_co_button)  ]))}

那么要实现上述功能,我们得通过 Vite 插件[1]来实现,我们在上面安装了一个 @vitejs/plugin-vue 插件,这个 Vite 插件的主要功能就是把 .vue 文件编译成上述的 js 内容。那么我们这样在它的后面继续添加一个插件在编译后的 js 内容中去实现上述替换功能即可。

我们在 ./packages/utils/index.ts 文件中实现这个自动加载组件的 Vite 插件,实现如下:

import MagicString from 'magic-string';
export default function VitePluginAutoComponents() {  return {    // 插件名称,用于调试和错误信息    name: 'vite-plugin-auto-component',
    // transform 钩子函数,在转换模块时调用    // code: 文件内容,id: 文件路径    transform(code, id) {      // 使用正则表达式检查文件是否为.vue文件      // 如果不是.vue文件,不进行处理      if(/\.vue$/.test(id)) {          // 创建 MagicString 实例,用于高效地修改字符串并生成 source map          const s = new MagicString(code)          // 初始化结果数组,用于存储匹配到的组件信息          const results = []
          // 使用 matchAll 方法查找所有匹配的 resolveComponent 调用          // 正则表达式解释:          // _?resolveComponent\d* - 匹配可能的函数名变体(可能带下划线或数字后缀)          // \("(.+?)"\) - 匹配括号内的字符串参数          // g - 全局匹配          for (const match of code.matchAll(/_?resolveComponent\d*\("(.+?)"\)/g)) {              // match[1] 是第一个捕获组,即组件名称字符串              const matchedName = match[1]              // 检查匹配是否有效:              // match.index != null - 确保有匹配位置              // matchedName - 确保捕获到组件名              // !matchedName.startsWith('_') - 确保组件名不以_开头(可能是内部组件)              if (match.index != null && matchedName && !matchedName.startsWith('_')) {                  // 计算匹配字符串的起始位置                  const start = match.index                  // 计算匹配字符串的结束位置                  const end = start + match[0].length                  // 将匹配信息存入结果数组                  results.push({                      rawName: matchedName,  // 原始组件名称                      // 创建替换函数,使用 MagicString 的 overwrite 方法替换指定范围的文本                      replace: resolved => s.overwrite(start, end, resolved),                  })              }          }
          // 遍历所有匹配结果进行处理          for (const { rawName, replace } of results) {              // 定义要替换的变量名(这里暂时编码为 CoButton)              const varName = `CoButton`              // 在代码开头添加导入语句:              // 1. 导入 CoButton 组件              // 2. 导入样式文件              s.prepend(`import CoButton from 'cobyte-vite-ui/dist/components/button';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
              // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名              replace(varName)          }
          // 返回转换后的代码          return {              code: s.toString(),  // 转换后的代码字符串              map: null,           }      }    },  }}

我们在上述 Vite 插件中使用到了一个新工具库 magic-string,我们需要安装一下它的依赖:

pnpm add magic-string -D -w

magic-string 是一个专注于字符串操作,主要作用是对源代码可以进行「精准的插入、删除、替换等操作」。

上述编写的 Vite 的插件主要是实现在.vue 文件中查找所有形如 resolveComponent("xxx") 的函数调用,对于每一个找到的调用,它会在文件顶部添加一个固定的导入语句,例如导入 CoButton 组件和样式。最后把找到的resolveComponent("xxx") 替换成对应的组件,例如 CoButton。

然后我们在根目录重新打包,接着在 play 目录中的 vite.config.ts 文件中进行以下修改:

import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'+ import AutoComponents from 'cobyte-vite-ui/dist/utils'
// https://vite.dev/config/export default defineConfig({-  plugins: [vue()],+  plugins: [vue(), AutoComponents()],})

接着我们再次重启 play 测试项目,我们可以看到即便我们不导入任何我们编写的组件库设置,我们依然可以在 play 项目中成功使用 CoButton 组件。

Image

同时我们在网络窗口可以查看到 App.vue 文件的内容变化如下:

Image

可以看到「我们通过静态分析代码,识别并替换 Vue3 的组件解析函数,成功实现了组件的自动导入功能」。但上述实现为了快速验证功能,无论匹配到的组件名是什么,都导入 CoButton 组件,并替换为 CoButton。这显然是不正确的,应该根据匹配到的组件名动态导入对应的组件。

自动化路径解析

因为我们的组件编译后的调用变成 _resolveComponent("co-button"),组件名称变成了 co-button,而我们在导入的语句是这样的 import CoButton from 'cobyte-vite-ui/dist/components/button',组件名称又需要变成 CoButton,所以我们需要把匹配到的 co-button 变成 CoButton。

代码迭代如下:

+ // 将字符串转换为帕斯卡命名(即大驼峰,每个单词首字母大写)+ export function pascalCase(str: string) {+    return capitalize(camelCase(str))+ }+ // 将字符串转换为驼峰命名  + export function camelCase(str: string) {+    return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''))+ }+ // 将字符串的首字母大写,使用 charAt(0) 获取第一个字符并转换为大写,然后加上剩余字符串(从索引1开始)+ export function capitalize(str: string) {+    return str.charAt(0).toUpperCase() + str.slice(1)+ }
export default function VitePluginAutoComponents() {    return {      // 插件名称,用于调试和错误信息      name: 'vite-plugin-auto-component',
      // transform 钩子函数,在转换模块时调用      // code: 文件内容,id: 文件路径      transform(code, id) {        // 使用正则表达式检查文件是否为.vue文件        // 如果不是.vue文件,不进行处理        if(/\.vue$/.test(id)) {            // 省略...
            // 遍历所有匹配结果进行处理            for (const { rawName, replace } of results) {+                // 将字符串转换为大驼峰+                const name = pascalCase(rawName)+                // 只处理 Co 开头的组件+                if (!name.match(/^Co[A-Z]/)) return                // 定义要替换的变量名-                const varName = `CoButton`+                const varName = name                // 在代码开头添加导入语句:                // 1. 导入 CoButton 组件                // 2. 导入样式文件                s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/button';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
                // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名                replace(varName)            }
            // 返回转换后的代码            return {                code: s.toString(),  // 转换后的代码字符串                map: null,             }        }      },    }}

经过上述实现还是存在以下问题,无论 rawName 是什么,组件都是从 'cobyte-vite-ui/dist/components/button' 这个固定路径导入。这意味着即使使用了 resolveComponent("CoTable"),插件依然会尝试从 button 文件导入,这显然是不正确的。理想情况下,导入路径应根据组件名动态生成。所以我们继续实现动态组件路径,例如 CoTableColumn 组件映射到 'cobyte-vite-ui/dist/components/table-column'。

我们上述的组件是 "CoButton",那么转换过程则是:
"CoButton" -> 去掉 "Co" -> "Button" -> kebabCase -> "button"。

我们通过实现一个 kebabCase 函数进行组件路径转换解析,实现如下:

// 省略...
+ // 将驼峰命名的字符串转换为短横线分隔的字符串(即kebab-case)+ export function kebabCase(key: string) {+    const result = key.replace(/([A-Z])/g, ' $1').trim()+    return result.split(' ').join('-').toLowerCase()+ }
export default function VitePluginAutoComponents() {    return {      // 插件名称,用于调试和错误信息      name: 'vite-plugin-auto-component',
      // transform 钩子函数,在转换模块时调用      // code: 文件内容,id: 文件路径      transform(code, id) {        // 使用正则表达式检查文件是否为.vue文件        // 如果不是.vue文件,不进行处理        if(/\.vue$/.test(id)) {            // 省略...
            // 遍历所有匹配结果进行处理            for (const { rawName, replace } of results) {                // 将字符串转换为大驼峰                const name = pascalCase(rawName)                // 只处理 Co 开头的组件                if (!name.match(/^Co[A-Z]/)) return+                // 组件路径转换+                const partialName = kebabCase(name.slice(2))                // 定义要替换的变量名                const varName = name                // 在代码开头添加导入语句:                // 1. 导入 CoButton 组件                // 2. 导入样式文件-                s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/button';\nimport 'cobyte-vite-ui/dist/style.css';\n`)+                s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/${partialName}';\nimport 'cobyte-vite-ui/dist/style.css';\n`)
                // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名                replace(varName)            }
            // 返回转换后的代码            return {                code: s.toString(),  // 转换后的代码字符串                map: null,             }        }      },    }}

经过上述迭代后,我们重新打包,重新启动 play 测试项目,我们发现我们的代码是能够正常运行的,说明我们上述的迭代是没有问题的。「至此我们为组件自动导入提供了核心的路径解析能力」。

引入解析器 (Resolver) 概念

当前插件硬编码了组件库的路径和样式文件,只能用于特定的组件库(cobyte-vite-ui)。我们可以通过引入解析器 (Resolver),让插件支持不同的组件库,用户可以根据需要配置不同的解析器。

解析器的作用是根据组件名返回一个解析结果,包括组件的导入路径和样式文件路径以及组件原始名称。这样,插件就可以通过解析器返回的对象信息动态获取组件的导入信息,而不是固定写死。

在实现解析器之前,我们先设计解析器返回的对象结构如下:

const component = {    name, // 组件原始名称    from: `cobyte-vite-ui/dist/components/${partialName}`, // 组件的导入路径    sideEffects: ['cobyte-vite-ui/dist/style.css'] // 组件的样式文件路径}

为什么要这样设计?

  1. 组件名 (name):
    用于在导入语句中作为标识符。这里使用的是帕斯卡命名,因为它在 Vue 中通常用于组件注册和模板中。
  2. 导入路径 (from):
    这里使用模板字符串动态构建导入路径。其中,partialName 是通过将组件名去掉前两个字符(即去掉 "Co")并转换为 kebab-case 得到的。
    例如,组件名 "CoTableColumn" 转换为 "table-column",然后拼接成路径'cobyte-vite-ui/dist/components/table-column'。
    这样设计是因为组件库的目录结构可能是按照 kebab-case 命名的,而组件在代码中是以帕斯卡命名使用的。
  3. 副作用 (sideEffects):
    这是一个数组,指定在导入组件时需要同时导入的样式文件或其他资源。这里指定了组件库的全局样式文件。
    注意:这个样式文件是全局的,也就是说,不管导入哪个组件,都会导入整个组件库的样式。这可能会造成样式冗余。
    更精细的做法是为每个组件指定其对应的样式文件,例如:
    sideEffects: [cobyte-vite-ui/dist/components/${partialName}/style.css]

但是,我们当前组件库没有为每个组件单独提供样式文件,我们只提供了固定的全局样式文件。

上面设计解析器返回的对象封装了组件的完整导入信息,作为数据载体传递给后续处理函数,我们可以基于此进行迭代:

// 省略...
+ // 根据传入的信息生成对应的导入语句字符串+ export function stringifyImport(info) {+    if (typeof info === 'string')+      return `import '${info}'`+    if (!info.as)+      return `import '${info.from}'`+    else if (info.name)+      return `import { ${info.name} as ${info.as} } from '${info.from}'`+    else+      return `import ${info.as} from '${info.from}'`+ }+ // 根据组件的导入信息生成完整的导入语句,包括组件本身的导入和其副作用(如样式文件)的导入。+ export function stringifyComponentImport({ as: name, from: path, name: importName, sideEffects }) {+    const imports = [+      // 生成组件导入语句+      stringifyImport({ as: name, from: path, name: importName }),+    ]
+    if (sideEffects) {+      // 生成副作用导入语句+      sideEffects.forEach(i => imports.push(stringifyImport(i)))+    }
+    return imports.join(';')+ }
  export default function VitePluginAutoComponents() {    return {      // 插件名称,用于调试和错误信息      name: 'vite-plugin-auto-component',
      // transform 钩子函数,在转换模块时调用      // code: 文件内容,id: 文件路径      transform(code, id) {        // 使用正则表达式检查文件是否为.vue文件        // 如果不是.vue文件,不进行处理        if(/\.vue$/.test(id)) {            // 省略...+            let no = 0            // 遍历所有匹配结果进行处理            for (const { rawName, replace } of results) {                // 将字符串转换为大驼峰                const name = pascalCase(rawName)                // 只处理 Co 开头的组件                if (!name.match(/^Co[A-Z]/)) return                // 组件路径转换                const partialName = kebabCase(name.slice(2))+                // 封装了组件的完整导入信息,作为数据载体传递给后续处理函数+                const component = {+                    name,+                    from: `cobyte-vite-ui/dist/components/${partialName}`,+                    sideEffects: ['cobyte-vite-ui/dist/style.css']+                }-                // 定义要替换的变量名(这里暂时编码为 CoButton)-                const varName = name+                // 使用特殊前缀减少与用户变量的冲突,以及使用递增的序号,保证唯一性,避免变量名冲突+                const varName = `__unplugin_components_${no}`                // 在代码开头添加导入语句:                // 1. 导入 CoButton 组件                // 2. 导入样式文件-                 s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/${partialName}';\nimport 'cobyte-vite-ui/dist/style.css';\n`)+                // 这里将 component 对象展开,并添加 as: varName 参数,形成完整的导入配置+                s.prepend(`${stringifyComponentImport({ ...component, as: varName })};\n`)+                no += 1                // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名                replace(varName)            }
            // 返回转换后的代码            return {                code: s.toString(),  // 转换后的代码字符串                map: null,             }        }      },    }}

我们添加了根据传入的信息生成对应的导入语句字符串的 stringifyImport 函数和根据组件的导入信息生成完整的导入语句,包括组件本身的导入和其副作用(如样式文件)的导入的 stringifyComponentImport 函数。其中 stringifyImport 处理单一导入语句,stringifyComponentImport 处理组合多个相关导入,实现了职责分离和配置灵活的设计优势。这两个函数共同构成了一个灵活的导入语句生成系统,为自动导入插件提供了强大的代码生成能力。

我们设计了一个解析结果包括:name(组件名)、from(导入路径)、sideEffects(样式等副作用导入)的数据结构对象 component 作为数据载体传递给后续处理函数,后续程序基于此来生成导入语句和替换代码。

其中变量名生成策略使用特殊前缀减少与用户变量的冲突从而「避免污染」,同时使用递增序号来「保证唯一性」。

最终我们实现了一个基于数据驱动的架构,将来解析器只负责识别组件和返回路径的数据信息,然后导入生成器函数,也就是上述的 stringifyComponentImport 和 stringifyImport 负责根据配置生成导入代码,我们整体的 Vite 插件就只负责协调流程和代码修改。

这种架构为后续引入真正的多解析器支持奠定了良好基础,只需要将硬编码的解析逻辑替换为可配置的解析器数组即可。

实现解析器 (Resolver)

我们引入解析器是为了提高插件的灵活性和可扩展性。当前插件硬编码了组件库的路径和样式文件,只能用于特定的组件库(cobyte-vite-ui)。通过引入解析器,我们可以让插件支持不同的组件库,用户可以根据需要配置不同的解析器。

解析器的作用是根据组件名返回一个解析结果,包括组件的导入路径和样式文件路径等。这样,插件就可以通过解析器来动态获取组件的导入信息,而不是固定写死。

改造步骤:

  1. 修改插件函数,使其可以接受一个选项对象,选项中包含解析器数组。
  2. 在插件内部,遍历解析器数组,对每个组件名尝试使用解析器进行解析。
  3. 如果某个解析器返回了结果,则使用该结果来生成导入语句。
  4. 如果没有解析器匹配,可以跳过该组件,也可以根据需求做其他处理。

参考 NaiveUi 基于 unplugin-vue-components 实现的解析器结构:

export function NaiveUiResolver(): ComponentResolver {  return {    type: 'component',    resolve: (name: string) => {      if (name.match(/^(N[A-Z]|n-[a-z])/))        return { name, from: 'naive-ui', sideEffects: [] }    },  }}
  • 解析器是一个对象,包含一个resolve方法,该方法接收组件名,返回一个解析结果对象或 undefined。
  • 解析结果对象包括:name(组件名,可选,默认使用原始名),from(导入路径),sideEffects(样式文件路径等,可选)

我们还可以支持多种解析器,这样插件可以同时支持多个组件库。

下面我们按照这个思路改造插件代码。首先基于上述 NaiveUi 的解析器实现我们的测试组件的解析器,./packages/utils/index.ts 新增代码如下:

// 解析器函数export function CobyteViteUiResolver() {  return {    type: 'component',    resolve: (name: string) => {      // 只处理 Co 开头的组件      if (name.match(/^Co[A-Z]/)) {        const partialName = kebabCase(name.slice(2)) // CoTableColumn -> table-column        return {           name,           from: `cobyte-vite-ui/dist/components/${partialName}`,           sideEffects: ['cobyte-vite-ui/dist/style.css']         }      }    },  }}

接下来修改插件函数,使其可以接受一个选项对象,选项中包含解析器数组,采用上下文管理,因此我们引入 Context 类,创建 Context 类来管理插件配置和解析器,并缓存解析结果,接着在 transform 钩子中,使用 Context 实例来查找组件,而不是硬编码解析逻辑。

+ export class Context {+  options: any;+  private _componentNameMap = {} // 组件缓存+  constructor(privaterawOptions: any) {+    this.options = rawOptions+  }
+  async findComponent(name: string) {+    // 1. 检查缓存中是否有该组件的信息+    let info = this._componentNameMap[name]+    if (info) {+      return info // 缓存命中,直接返回+    }+    // 2. 遍历所有解析器+    for (const resolver of this.options.resolvers) {+      const result = await resolver.resolve(name)+      // 3. 判断解析器是否返回了结果+      if (!result) {+        continue+      }+      // 4. 构建完整组件信息+      info = {+        as: name, // 添加别名+        ...result,+      }+      // 5. 存入缓存+      this._componentNameMap[name] = info+      return info+    }+    // 所有解析器都不匹配,返回 undefined+  }+ }
-  export default function VitePluginAutoComponents() {+  export default function VitePluginAutoComponents(options = {}) {+    // 创建 Context 实例,用于存储插件配置和组件信息+    const ctx = new Context(options)    return {      // 插件名称,用于调试和错误信息      name: 'vite-plugin-auto-component',
      // transform 钩子函数,在转换模块时调用      // code: 文件内容,id: 文件路径      async transform(code, id) {        // 使用正则表达式检查文件是否为.vue文件        // 如果不是.vue文件,不进行处理        if(/\.vue$/.test(id)) {            // 省略...
            let no = 0            // 遍历所有匹配结果进行处理            for (const { rawName, replace } of results) {                // 将字符串转换为大驼峰                const name = pascalCase(rawName)-                // 只处理 Co 开头的组件-                 if (!name.match(/^Co[A-Z]/)) return-                // 组件路径转换-                 const partialName = kebabCase(name.slice(2))-                // 封装了组件的完整导入信息,作为数据载体传递给后续处理函数-                 const component = {-                     name,-                     from: `cobyte-vite-ui/dist/components/${partialName}`,-                     sideEffects: ['cobyte-vite-ui/dist/style.css']-                 }+                const component = await ctx.findComponent(name)+                if (component) {                  // 定义要替换的变量名(这里暂时编码为 CoButton)                  // const varName = name                  // 使用特殊前缀减少与用户变量的冲突,以及使用递增的序号,保证唯一性,避免变量名冲突                  const varName = `__unplugin_components_${no}`                  // 在代码开头添加导入语句:                  // 1. 导入 CoButton 组件                  // 2. 导入样式文件                  // s.prepend(`\nimport ${varName} from 'cobyte-vite-ui/dist/components/${partialName}';\nimport 'cobyte-vite-ui/dist/style.css';\n`)                  // 这里将 component 对象展开,并添加 as: varName 参数,形成完整的导入配置                  s.prepend(`${stringifyComponentImport({ ...component, as: varName })};\n`)                  no += 1                  // 执行替换:将 resolveComponent("xxx") 调用替换为组件变量名                  replace(varName)+                }            }
            // 返回转换后的代码            return {                code: s.toString(),  // 转换后的代码字符串                map: null,             }        }      },    }}

插件初始化时,创建 Context 实例,传入 options,其中包含解析器 resolvers。Context 类提供了一个 findComponent 方法,用于根据组件名查找组件信息。该方法会先查看缓存,如果缓存中没有,则依次调用每个解析器的 resolve 方法,直到有一个解析器返回结果。然后将结果缓存起来。在 transform 钩子中,使用 Context 实例的 findComponent 方法来查找组件信息,而不再是硬编码解析逻辑。这次迭代使插件从单一组件库的支持扩展到多组件库,使用缓存提高性能,通过解析器模式提高扩展性,并且通过异步解析查找组件信息、为未来异步解析预留接口。

经过此次的迭代,我们的插件实现了真正的解耦,插件核心只负责流程控制,解析逻辑则完全由解析器处理,配置管理则由 Context 统一管理,标准化了解析器的接口,这样所有解析器都遵循相同的接口,由此实现了强大的拓展性。

接着我们更新 play 项目中的 Vite 配置文件 vite.config.ts,更新如下:

import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'- import AutoComponents from 'cobyte-vite-ui/dist/utils'+ import AutoComponents, { CobyteViteUiResolver} from 'cobyte-vite-ui/dist/utils'
export default defineConfig({-  plugins: [vue(), AutoComponents()]+  plugins: [vue(), AutoComponents({+    resolvers: [CobyteViteUiResolver()]+  })],})

接着重新打包组件库,再重启 play 项目,我们发现依然正常,说明我们上述的改动是正确的。

多解析器配置

上文说了我们实现了插件从单一组件库的支持扩展到多组件库的按需加载解析,那么下面就让我们来测试一下。 首先我们往 packages/utils/index.ts 文件添加 Naive UI 的解析器,代码如下:

/** * Resolver for Naive UI * * @link https://www.naiveui.com/ */export function NaiveUiResolver() {  return {    type: 'component',    resolve: (name: string) => {      console.log('NaiveUiResolver', name, name.match(/^(N[A-Z]|n-[a-z])/));      if (name.match(/^(N[A-Z]|n-[a-z])/))        return { name, from: 'naive-ui' }    },  }}

这个解析器是完全从 unplugin-vue-components 插件中搬过来的,我们测试一下是否能够在我们实现的插件中使用。

接着我们在 play 项目中安装 Naive UI 的依赖:

pnpm add naive-ui

然后在 App.vue 文件中引用 Naive UI 的组件:

<template>  <co-button></co-button>  <n-buttontype="primary">naive-ui</n-button></template>

接着修改 ./play/vite.config.ts 文件中的配置。

import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'- import AutoComponents, { CobyteViteUiResolver } from 'cobyte-vite-ui/dist/utils'+ import AutoComponents, { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'
// https://vite.dev/config/export default defineConfig({  plugins: [vue(), AutoComponents({-   resolvers: [CobyteViteUiResolver()]+    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]  })],})

接着我们重新打包我们的测试组件库,再重启 play 测试项目,测试结果如下:

Image

我们可以看到成功验证了我们上述的结论:「我们实现了插件从单一组件库的支持扩展到多组件库的按需加载解」。

我们上面所实现的插件其实就是 unplugin-vue-components 库的实现原理,在 Vue 技术栈中都是通过这个库来实现组件按需加载的。

业务组件库按需加载实践

我们在 play 项目中安装 unplugin-vue-components 库来替换我们手写的插件。安装命令如下:

pnpm add unplugin-vue-components -D 

接着修改 play 项目中的 vite.config.ts 文件。

import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'- import AutoComponent, { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'+ import { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'+ import AutoComponents from 'unplugin-vue-components/vite';
// https://vite.dev/config/export default defineConfig({  plugins: [vue(), AutoComponents({    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]  })],})

然后重启 play 项目,我们发现我们的测试例子依然是正常运行的。所以我们一般的组件库或者业务组件库想要实现按需加载,只需要参考 unplugin-vue-components 库中提供的解析器,写一个符合自己的组件库的解析器再配合 unplugin-vue-components 即可。当然还有一个重要的前提,你的组件库得设计成模块化,即一个组件一个模块,互不关联或者弱关联。

业务组件库引用第三方组件库的按需加载

我们知道所谓业务组件库,就是一些基于第三方组件库开发的组件库,比如基于 Element Plus、Naive UI 开发的组件库。那么我们修改一下我们的测试组件库 ./packages/components/button,让它使用 Naive UI 的 button 组件,修改如下:

<template>  <n-buttontype="warning">    Warning  </n-button></template><scriptsetuplang="ts">defineOptions({  name: 'co-button',});</script><stylelang="scss"scoped>button {  color: red;}</style>

接着我们重新打包测试组件库,然后重启 play 项目。我们发现测试组件库中 Naive UI 的按钮 Button 并没有生效。并且在浏览器的控制台报以下警报:

Failed to resolve component: n-button

这是因为我们使用 Vite 来打包组件库,Vite 默认会把代码进行压缩混淆。我们可以看一下打包后的测试 button 组件的代码。可以看到原本应该是 _resolveComponent("n-button") 的代码,因为 Vite 进行了压缩混淆而变成了 o("n-button")。

Image

而我们的插件是基于 _resolveComponent 为前缀进行匹配的,现在前缀被压缩了也就肯定匹配到不到了。所以简单的处理方法就是修改 Vite 打包配置,让其不进行压缩混淆,毕竟 Element Plus、Naive UI 这些开源组件库打包后的产物也没有进行压缩混淆。所以我们修改 Vite 打包配置禁止构建压缩混淆。修改根目录下的 vite.config.ts 如下:

// 省略...
export default defineConfig(({ command, mode }) => {    // 主构建配置    return {        // 省略...        build: {+            minify: false, // 禁止压缩混淆            // 省略...        },    };});

我们发现打包后的组件代码不压缩混淆了,但还是不生效,这是因为我们写的插件只解析 .vue 文件,而我们打包后的文件变成了 .mjs 了,所以我们要修改一下 play 项目的 Vite 配置让 .mjs 文件也可以被解析。修改 ./play/vite.config.ts 文件如下:

  // 省略...export default defineConfig({  plugins: [vue(), AutoComponents({+    include: [+      /\.vue$/,+      /\.mjs$/+    ],    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]  })],})

经过上述修改后我们重启 play 项目,发现基于 Naive UI 二次开发的组件可以成功加载了。

Image

依赖预构建配置

我们知道 Vite 会在第一次启动的时候把依赖预构建并缓存到 node_modules/.vite 目录中。主要有以下几个原因:

  1. 模块格式转换

许多 npm 包使用的是 CommonJS 或 UMD 格式,而 Vite 在开发环境中使用的是原生 ES 模块(ESM)。预构建会将这些包转换为 ESM 格式,使其能够在浏览器中直接运行。

  1. 性能优化 - 减少 HTTP 请求

某些包会有很多内部模块,如果不预构建,浏览器可能需要发起数百个 HTTP 请求。预构建会将这些模块打包成一个或少数几个文件。

典型例子:

  • lodash-es 有超过 600 个内置模块
  • 如果不预构建,会导致 600+ 个 HTTP 请求
  • 预构建后只需要 1-2 个请求
  1. 提升页面加载速度

预构建使用 esbuild(用 Go 编写),速度比传统 JavaScript 打包工具快 10-100 倍。通过将依赖预先打包并缓存,可以显著提升开发服务器的启动速度和模块热更新(HMR)的响应速度。

默认的时候 Vite 是通过 import 语句进行分析需要预构建的依赖的,但我们使用按需加载的插件之后,在代码中就有些 npm 包不存在 import 语句了,所以需要我们手动通过 optimizeDeps.include 选项设置预构建。

我们对 ./play/vite.config.ts 设置如下:

import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'import { CobyteViteUiResolver, NaiveUiResolver } from 'cobyte-vite-ui/dist/utils'import AutoComponents from 'unplugin-vue-components/vite';+ import pkg from './package.json';+ const dependencies = Object.keys(pkg.dependencies);
export default defineConfig({  plugins: [vue(), AutoComponents({    include: [      /\.vue$/,      /\.mjs$/    ],    resolvers: [CobyteViteUiResolver(), NaiveUiResolver()]  })],+  optimizeDeps: {+    include: [...dependencies]+  }})

由于 Node 不处理虚拟链接,同时为了更真实验证真实场景,我们把测试组件库改成更加真实,首先修改 ./play/packages.json

{  // 省略...  "dependencies": {-    "cobyte-vite-ui": "link:..",    "cobyte-vite-ui": "^1.0.0",    "naive-ui": "^2.43.1",    "vue": "^3.5.22"  },  // 省略...}

同时删掉 ./play/node_modules 目录中的 cobyte-vite-ui 虚拟目录,再重新创建一个 cobyte-vite-ui 目录,同时把根目录下的 ./dist 目录中的内容和根目录下的 packages.json 文件复制到刚刚新创建的 cobyte-vite-ui 目录中,这相当于手动安装我们创建的测试组件库的依赖了。之后我们再删掉 ./play/node_modules/.vite 目录的预构建缓存,再重启 play 项目。这时我们发现 cobyte-vite-ui 组件库中引用的 Naive UI 的 button 组件不生效了。这是因为我们把 cobyte-vite-ui 进行预构建后,它的内容就会被预构建后缓存到 ./play/node_modules/.vite 目录中了,而 unplugin-vue-components 插件默认是不解析 node_modules 目录中的文件的,所以我们可以修改 unplugin-vue-components 插件的配置让其可以解析 node_modules 目录中的文件,但这不是最优的方案。最优的方案是在打包 cobyte-vite-ui 组件库的时候就 进行按需打包。我们在根目录下安装 unplugin-vue-components 依赖。

pnpm add unplugin-vue-components -D -w

我们在安装上述依赖的时候,可能会报以下错误:

Image

这是因为我们刚刚把 play 目录中的测试组件库 cobyte-vite-ui 改了正式库一样的依赖,我们可以暂时把它改回虚拟依赖。

"dependencies": {-    "cobyte-vite-ui": "^1.0.0",+    "cobyte-vite-ui": "link:..",    "naive-ui": "^2.43.1",    "vue": "^3.5.22"},

再进行安装即可。

然后新增根目录下的 vite.config.ts 文件的配置:

// 省略...+ import AutoComponents from 'unplugin-vue-components/vite';+ import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
// 省略...
export default defineConfig(({ command, mode }) => {    // 主构建配置    return {        plugins: [            vue(),+            AutoComponents({+                resolvers: [NaiveUiResolver()]+            })        ],        build: {            // 省略...            rollupOptions: {                external: [                    "vue", +                    "naive-ui", // 排除打包                ],            // 省略..            },        },    };});

配置完后,重新打包我们的测试组件库,打包完后,重新删掉 ./play/node_modules/.vite 中的缓存,和 ./play/node_modules/cobyte-vite-ui 中的内容,重新把刚刚新打包的根目录下的 ./dist 目录中的内容和根目录下的 packages.json 文件复制到 ./play/node_modules/cobyte-vite-ui 中,同时恢复修改的 ./play/packages.json 文件,然后重启 play 项目。

这时我们就发现测试项目可以正常渲染了。

至此我们业务组件库按需加载的实现原理就都讲得差不多了,有什么可以在评论区交流。

总结

看完了全篇文章相信你会觉得,所谓组件库按需加载或者业务组件库按需加载其实很简单,首先组件库的每一个组件都得设计成独立的模块,并且可以按模块导入,也就是 ESM 化,可以进行 Tree Shaking,只有这样按需加载才有意义,才能达到减小包体积的作用。

全局组件在模板中使用被编译后会通过一个内置函数 「resolveComponent」 来调用组件,按需加载的实现原理就是通过插件进行正则匹配查找编译后的模板代码中的 「resolveComponent」 函数的相关代码来找到需要按需加载的组件,然后自动按编译后的代码的头部添加需要加载的组件的导入语句代码以及替换掉 「resolveComponent」 函数的相关代码为对应的组件对象。

而业务组件实现按需加载的关键是需要在业务组件库打包的时候也进行按需加载配置。虽然这个关键步骤很简单,但由于这是一个低频且跨项目的需求,所以 AI 对低频的需求的实现和给的解决方案都不尽人意,至少本人解决上述问题时,AI 提供方案没有一个可以实现的,虽然最后的实现其实很简单。

最后,再说说个人对 AI 的一些感悟吧,个人觉得在 AI 时代,就编程这个领域而言对个人的专业要求会比以前更加的高,至少你得有能力去解决 AI 不会的问题。

上述组件库测试代码地址:GitHub - amebyte/cobyte-vite-ui

欢迎关注本专栏,了解更多 Element Plus 组件库知识