首屏优化之组件库按需加载与unplugin-vue-components插件源码分析

968 阅读8分钟

前言

最近我在尝试优化公司网站的首屏加载性能,因为去年和前年对于组件库的建设目标都在开发新的内容上,经过了两年的迭代之后,组件库的内容也变的稳定了,于是开始尝试在组件库的使用上做一些优化。

我们的项目主要是使用自己的业务组件库+vant,因为这个项目目前使用的技术栈是Vue2,因此vant的版本控制在2的大版本。

问题

对于vant的使用,我们一般是这样使用的:

import Vue from 'vue'
import 'vant/lib/index.css';
import { Toast, Field } from 'vant'

Vue.use(Toast)
Vue.use(Field)

对于我们团队自己的UI组件库,差不多也是这样的:

import Vue from 'vue'
import '@nb/component/lib/style.css';
import NbComponent from '@nb/component'

Vue.use(NbComponent);

对于,我们的业务组件库的用法肯定是存在问题的,因为全量导入肯定是增加了很大的打包体积的,这是一个毋庸置疑的点。

上面,全量加载仅仅是一方面的问题,还有一个方面的问题是我们在开发的过程中,使用体验非常差,比如我使用vant的某个组件,我要么全量导入,要么我就要到某个路径下去导入(我们自己的组件库要想解决按需加载同样也是这个问题):

import Vue from 'vue'
import 'vant/es/action-sheet/style'
import ActionSheet from 'vant/es/action-sheet';

Vue.use(ActionSheet)

关键问题就是vant,我们上面的写法是否存在问题?有没有问题还是得看一下它的源码。 image.png 不管你用不用,反正批量大赠送,为了方便大家观察,我在浏览器搜了一下含有vant的关键字,大家可以看到这么多含有vant关键字的请求: image.png 有些同学可能会有疑问,为什么第一种写法没有TreeShaking呢?可是,这种写法怎么会有TreeShaking呢,又没有被标记成dead code,因为install函数不能被消灭掉,那肯定只能全量加载了啊。 image.png 上面,全量加载仅仅是一方面的问题,还有一个方面的问题是我们在开发的过程中,使用体验非常差,比如我使用vant的某个组件,我要么全量导入,要么我就要到某个路径下去导入(我们自己的组件库要想解决按需加载同样也是这个问题):

import Vue from 'vue'
import 'vant/es/action-sheet/style'
import ActionSheet from 'vant/es/action-sheet';

Vue.use(ActionSheet)

这样写虽然减少了打包体积,但是真的让人很不舒服,如果我们能做到自动按需加载就好了。

解决问题

我一直坚信的道理就是遇事不决,Github+Chatgpt(Deepseek),看到了ElementPlus官网上有个自动加载的例子,好,我们就把这个东西搬过来用一用。 image.png 接下来,我们就要看一下这个ElementPlusResolver里面到底做了一些什么,我们把这两个插件的源码copy到本地看一下它这个Resolver里面的实现: image.png image.png (因为最后我发现,仅仅用unplugin-vue-components就可以了,所以这篇文章就只看这个插件就可以了)

到目前这个为止,大家对于这个ComponentInfo结构还是比较懵逼的,接下来我就向大家展示我的调研过程。

我们首先拉一个ElementPlus的项目起来试试,看一下它的按需加载在浏览器里面是如何加载资源的。 image.png 我根据我的经验,我的猜测,name应该代表的是组件库的名称,from应该表示的是从哪个包里面加载,sideEffects应该代表的是在加载组件的时候需要额外加载的内容(从名称上也可以看得出来,应该就是提供给我们加载有副作用的选项,比如css文件)。

好了,既然这样的话,那我们就依葫芦画瓢,自己搞一个Resolver

unplugin-vue-components已经不再接受新的Resolverimage.png 所以,我们就自己搞一个npm包,然后从我们自己的npm包导入就可以了:

import type { ComponentResolveResult, ComponentResolver } from "unplugin-vue-components/types";

function kebabCase(key: string) {
  const result = key.replace(/([A-Z])/g, " $1").trim();
  return result.split(" ").join("-").toLowerCase();
}

interface NbComponentResolverOptions {
  /**
   * import style along with components
   *
   * @default 'css'
   */
  importStyle?: boolean | "css" ;
  /**
   * auto import for directives
   *
   * @default true
   */
  directives?: boolean;
  /**
   * compatible with unplugin-auto-import
   *
   * @default false
   */
  autoImport?: boolean;
}

function getResolved(name: string, options: NbComponentResolverOptions): ComponentResolveResult {
  const { importStyle = "css" } = options;

  const path = "@nb/component";
  const sideEffects = [];
  if (importStyle) {
    let stylePath = `${path}/es/${kebabCase(name)}/style/index`;
    // NOTE: 修正@nb/component的导入,因为Modal导入的是Dialog的样式,而按需导入需要把Dialog的样式导入进来
    if (stylePath === "@nb/component/es/modal/style/index") {
      stylePath = "@nb/component/es/dialog/style/index";
    }
    sideEffects.push(stylePath);
  }
  return {
    from: path,
    name,
    sideEffects,
  };
}

/**
 * Resolver for NbComponent
 */
export function NbComponentResolver(options: NbComponentResolverOptions = {}): ComponentResolver[] {
  return [
    {
      type: "component",
      resolve: (name: string) => {
        if (name.startsWith("Cb")) {
          return getResolved(name.slice(2), options);
        }
      },
    },
    {
      type: "directive",
      resolve: (name: string) => {
        const { directives = true } = options;
        if (!directives) {
          return;
        }
        return getResolved(name, options);
      },
    },
  ];
}

目前看起来似乎是有点儿对了,但是好像又觉得哪儿不对: image.png 不对的点是什么呢,不知道大家有没有注意,虽然css已经完成了按需加载,但是整个组件库的JS仍然是全量导入的,于是,我猜测,这个from是不是还可以修改? image.png 现在看一下加载的内容是一个什么情况呢: image.png 看起来有点儿对味了,哈哈哈。

所以,现在我有点儿不太明白这些组件库的维护者们为什么在自动导入的时候,为什么要全量导入自己组件库的JS代码呢,希望有知道的读者可以给我解答一下疑惑。

经过了上面的优化处理,我们来看一下现在的项目的加载时间对比。

这些优化前的项目加载时间: image.png 这是优化后的项目加载时间: 优化后数据.png

以下是我的项目的分包策略:

import { defineConfig } from 'vite'

export default defineConfig({
	build: {
		rollupOptions: {
			output: {
				manualChunks(id) {
					const vendorModules = [
						'vue',
						'vuex',
						'vue-router',
						'axios',
						'lodash',
						'lodash-es',
						'@nb/sdk',
						'@nb/component',
						// 把vant也打包到vendor里面去
						'vant',
					]
					if (
						vendorModules.some((path) => {
							return id.includes('node_modules/' + path)
						})
					) {
						return 'vendor'
					}
				},
			},
		},
	},
})

可以看到,vendor的打包体积是肉眼可见的减少,这能优化多少加载时间,我暂时没有统计,不过从体积明显看到这个提升还是很大了。

unplugin-vue-components源码分析

接下来,我们就要分析一下这个插件的实现了,因为我是学会了之后再写的这篇文章,所以很多东西可能没有那么多为什么,这些东西的来源都是你的经验与你的巧思得到的。

unplugin-vue-components这样的插件是通用插件,即支持常见的打包工具,比如WebpackViteRollup),Rspackesbuild

对于我来说,我只需要关心VITE相关的处理就可以了。

首先是VITE的入口文件: image.png 没有什么好说的,应该unplugin是一个通用对象,不同的打包工具使用不同的对象。

接下来是它的核心实现: image.pngconfigResolved这个部分,它是去找插件的名称,决定当前是使用Vue2的转换器还是Vue3的转换器,因为Vue这两个版本之间的差异还是比较大的。

然后,我们就看关键的ctx.transform是怎么实现的了。

Context类在初始化的时候需要设置transformer也可以后面再设置: image.png 得继续看一下这个transformer函数是怎么实现的? image.png 到目前为止,我们还看不出来所以然,不过真相马上就水落石出了 image.png 如果你对Vue的编译结果足够熟悉的话,你现在应该已经开始暗自窃喜了,原来是这样啊,都不用解析AST了,真的太神奇了,果然重要的不是结果,是思考的过程。

我们先看Vue2的: image.png

我先拿一个组件来给大家举个例子就明白了: image.png 上面的_c是什么?它是Vue.prototype.$createElement,现在它正在创建组件,也就是说,现在我们的这个组件还没有,所以创建组件的过程中就会失败,那么解决这个问题就很简单了,我把这个组件导入,然后给它创建不就得了嘛。

所以,现在大家明白刚才那个位置的results变量代表的含义了吧,它就是当前单文件组件编译结果的code字符串上有多少处需要进行替换的标记

找到所有的标记之后,剩下的事儿就简单了,我们只需要把用到的这些组件全部都替换成预期的足迹即可。 image.png 接下来的问题就是如何替换。 image.png 上面这个图中,最重要的一句代码就是resolver.resolve这个函数的执行。 image.png 这个类型就是之前ElementPlusResolver函数返回的结构呀: image.png 这个位置就是在进行结果替换(关于MagicString这个类,我不再阐述,有兴趣的同学请自行查阅相关的资料): image.png 我们得看一下它是怎么处理导入的? image.png image.png 所以,现在大家明白了这个代码是怎么来的了吗? image.png 之前的那个ComponentInfo结构:

interface ImportInfo {
    as?: string;
    name?: string;
    from: string;
}

interface ComponentInfo extends ImportInfo {
    sideEffects?: SideEffectsInfo;
}

现在可以向大家解释一下它的结构的含义了,用下面的伪代码,就一目了然了,哈哈哈

import { [name] as [as] } from '[from]'
// 如果sideEffects存在的话
// import '[sideEffects[0]]'

最后,还有一个事儿,不要影响用户ESLint的配置,所以得增加一个ESLint的禁用标识: image.png

然后,现在,我们如果再回过头看一下Vue3的处理逻辑就大同小异了: image.png image.png

最后,再看一下指令的处理,指令的处理要稍微复杂一些,所以这儿源码中使用的是@babel/parser解析的AST。

先是得到解析源码,得到AST image.png 然后插入指令: image.png 这儿,我们不能直接拿着Vue的源码在https://astexplorer.net/这个工具上看,我们得先写一个Vue组件,通过VITE把它转成JS,然后再把这个JS用@babel/parser转过来,就是预期的结构了。

<template>
	<p v-demo.aaa="xxx"></p>
</template>

<script>
	export default {
		name: 'App',
	}
</script>

经过VITE编译之后:

/* unplugin-vue-components disabled */
var render = function() {
    var _vm = this;
    var _h = _vm.$createElement;
    var _c = _vm._self._c || _h;

    return _c('p', {
        directives: [{
            name: "demo",
            rawName: "v-demo.aaa",
            value: _vm.xxx,
            expression: "xxx",
            modifiers: {
                "aaa": true
            }
        }]
    });
};
var staticRenderFns = [];
render._withStripped = true
export {render, staticRenderFns}

image.png 这就是为什么经过这个AST处理之后,能够自动识别到自定义指令从而完成自动导入的原因了。

总结

经过这次组件库的按需加载处理之后,我们首屏加载的性能得到了一定的提升,目前可以保证的是首屏加载的资源就一定是渲染所需的内容了,哈哈哈,配合之前给大家展示的分包策略,如果对于频繁的修改也可以较大限度的利用缓存。

在这篇文章中,我通过学习和阅读源码,我明白了按需加载的处理流程。

我还同时编写了一个支持vant2的自定义resolver,对于其它任何组件库,我们都可以利用unplugin-vue-components插件的加载模式针对性的编写按需加载逻辑,但是学完unplugin-vue-components的源码之后,我留下了2个疑惑,通过学习,我的2个疑惑得到了解决。

一、既然unplugin-vue-components这个插件就可以完成自动识别组件和指令并导入,为什么ElementPlus这样的知名UI组件库的文档上还写了unplugin-auto-import一起配合使用(比如Varlet的文档也是这样写的)。

使用unplugin-auto-import的目的是为了解决我们是JS中直接调用ElementPlus的全局API,比如:

// 这两行代码将由unplugin-auto-import自动导入
// import { getCurrentInstance } from 'vue'
// import { ElNotification } from 'element-plus'

// 在你的 setup 方法中
const { appContext } = getCurrentInstance()!
ElNotification({}, appContext)

而为什么unplugin-auto-importunplugin-vue-componentsresolver具备一样的规格呢?它是为了避免在自动导入这样的全局API时遗漏相应的依赖。

二、ElementResolver在导入JS的时候是全量导入的,并没有实现真正的按需加载,仅仅只是在加载css时避免了全量加载,并没有完全得到优化?

其实这个问题,只是我们的错觉,因为在VITE的DEV模式下它是不会触发TreeShaking的。

我们打开ElementPlus的es包看一下,相比之前的vant2,这份代码是没有任何副作用的,所以能够触发TreeShaking从而得到优化。 image.png

一句话来总结今天的知识,纸上得来终觉浅,绝知此事要躬行,不要人云亦云!!!