likes
comments
collection
share

组件库实战——按需加载工程化

作者站长头像
站长
· 阅读数 14

哈喽大家好,这次笔者给大家带来组件库周边生态建设——「按需加载工程化」的文章分享。继上篇 BEM 后,本来是打算搞单元测试的,但收到了一些接入方的反馈所以要插队搞个按需加载,毕竟「用户就是上帝」嘛!

组件库系列文章:

  1. 快上车!从零开始搭建一个属于自己的组件库!
  2. 组件库建设——实现一个跨框架的「组件库文档」
  3. 完结篇!一步一步实现一个专业的前端组件库~

组件库周边生态建设看似锦上添花,但笔者却认为其对于组件库的推广发展却是不可或缺的。做基建的同学想让其他同学使用、接纳自己的产品,必须要做到简单且易用。毕竟不是任何产品都能成为业界顶端让其他开发者慕名而来,更多时候是远远到不了“店大欺客”的地步。所以搞基建的同时也要在易用性上下功夫,于是就有了本文!

源码了解

一、按需加载为什么需要工程化

笔者从来都不做无用功(其实因为懒)!所以事出必有 bug!大家听笔者娓娓道来,事情的起因是这样的...

1. 背景介绍

笔者搞了个二次封装 elementantd 的组件库,其实就是在原组件中包了一层,扩展了一些功能、样式等,然后在业务项目中通过自定义前缀 vc 来使用组件,如 vc-table

问题出现:

某个业务项目使用 element-plus 作为组件库框架,且采用了其“自动导入”的按需加载用法。用法如下(来自elp官方文档): 组件库实战——按需加载工程化

这两个 unplugin 的插件都是从工程层面来解决按需引入问题的,比如设置了如下插件:

Components({
  resolvers: [ElementPlusResolver()],
}),

在我们编写业务项目时可以达到这样的效果:

<template>
  <div>
    <el-table />
  </div>
</template>

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

等同于

<template>
  <div>
    <el-table />
  </div>
</template>

<script>
import { ElTable } from 'element-plus'
import 'element-plus/theme-chalk/el-table.css'

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

详情可以看 unplugin-vue-components 的插件用法效果。为了方便理解笔者所说的 bug 所在,其插件的实现原理可以简单理解成 template 中有 el-table 的标签,插件就会去解析名为 ElTable 的组件并将其和样式文件一起引入

所以!如果组件接入方在上述场景中直接将 el-table 替换成 vc-table 来使用就会出现一个问题:将会丢失 el-table 的样式文件加载,导致 table 的样式异常(因为插件不知道 vc-table 要去加载 el-table 的样式文件)。

比如现有 el-table 代码如下:

<el-table :data="tableData" border>
  <el-table-column prop="date" label="Date" width="180" />
  <el-table-column prop="name" label="Name" width="180" />
  <el-table-column prop="address" label="Address" />
</el-table>

其中在浏览器看是正常展示的: 组件库实战——按需加载工程化

现在接入方使用 vc-table 以引入自定列的功能,代码改为:

<template>
  <vc-table :data="tableData" border>
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </vc-table>
</template>

<script setup>
import {VcTable} from '@xxx/vc-element-plus'
</script>

如图可以发现,自定列功能是加入了,但表格样式出现异常: 组件库实战——按需加载工程化 而问题就是文章上述原因引起的。问题也很好解决,我们手动加入 el-table 的样式文件:

import 'element-plus/theme-chalk/el-table.css'

这时再看发现 table 样式恢复正常: 组件库实战——按需加载工程化

ok,大概就是这么一个 bug 吧。虽然不算致命,接入方自己手动加入样式即可解决,但是如果要求接入方在每使用一次 vc-xxx 组件的时候,都需要引入 el-xxx 的对应样式文件,怕是根本没人愿意接入。还可能会被骂成狗血(各种吐槽),接一个组件库如此难用...

再或者,有些简单的中台都是后端自己维护的,如果他们不理解、不知道前端按需加载的这种插件,在接入使用组件库的时候出现样式问题,他们一定会第一时间放弃使用该组件库(别问我为什么知道的),除非被产品死逼着要上,那他也许会来找开发者问原因以及解决方案。

二、实战按需加载工程化

哈哈哈,说是实战按需加载工程化(有水分!),但其实并不需要我们做特别多的事情,毕竟大佬们已经提供了 unplugin 这种自动引入的插件。我们需要做的,其实就是按照 plugin 的使用规范,搞一个类似 ElementPlusResolver 这个玩意出来就可以了。所以,我们先看看 ElementPlusResolver 的源码,看看它做了什么!

1. 了解自动导入插件原理

这是精简过的源码:

function ElementPlusResolver(options = {}) {
  let optionsResolved;
  async function resolveOptions() {
    if (optionsResolved)
      return optionsResolved;
    optionsResolved = ...
    return optionsResolved;
  }
  return [
    {
      type: "component",
      resolve: async (name) => {
        return resolveComponent(name, options2);
    },
    {
      type: "directive",
      resolve: async (name) => {
        return resolveDirective(name, await resolveOptions());
      }
    }
  ];
}

由此看出,执行 ElementPlusResolver 将返回一个数组,里面包含两个对象:[ {组件}, {指令} ],对象中分别有 typeresolve 属性,其中 resolve 是一个函数,执行后返回 resolveComponent() 的执行结果。因此,我们顺藤摸瓜下去,看看 resolveComponent 干了啥。其代码如下(精简过):

function resolveComponent(name, options) {
  ...
  // 转 kebab 后取后缀。如 el-table 为 table
  const partialName = kebabCase(name.slice(2));
  const { version, ssr } = options;
  if (compare(version, "1.1.0-beta.1", ">=")) {
    return {
      name,
      from: `element-plus/${ssr ? "lib" : "es"}`,
      sideEffects: getSideEffects2(partialName, options)
    };
  } else if (compare(version, "1.0.2-beta.28", ">=")) {
    return {
      from: `element-plus/es/el-${partialName}`,
      sideEffects: getSideEffectsLegacy(partialName, options)
    };
  } 
  ...
}

由上可以看到,resolveComponent 最终返回一个对象,可能有 namefromsideEffects 这些属性。再往细致点看,可以了解到其根据 版本、ssr场景 等不同可能会返回不同的内容,而这些内容的区别就在于一些文件路径的拼接中

上面代码中有一个 sideEffects 属性,其中是由 getSideEffects2 这类的函数返回值决定的,我们再把其中一个 getSideEffects2 的代码实现看看:

function getSideEffectsLegacy(partialName, options) {
  const { importStyle } = options;
  if (!importStyle)
    return;
  if (importStyle === "sass") {
    return [
      "element-plus/packages/theme-chalk/src/base.scss",
      `element-plus/packages/theme-chalk/src/${partialName}.scss`
    ];
  } else if (importStyle === true || importStyle === "css") {
    return [
      "element-plus/lib/theme-chalk/base.css",
      `element-plus/lib/theme-chalk/el-${partialName}.css`
    ];
  }
}

由上可知,这个方法返回一个数组,数组内容是 base.css当前组件.css 的路径字符串。也就是说样式的自动导入是通过这个 sideEffects 属性来完成的

看到这里,相信大家对自动导入的插件实现有一定的了解了,那我们接下来就看看如何实现一个自己组件库的自动导入功能吧!

2. 实现自定义 resolver

首先大概总结一下按需引入工程化中(自己实现一个VcElementPlusResolver),我们需要做什么:

  1. 自动导入 template 中用到的 Vc 组件(也就是 name 组件名、 from组件入口路径 )
  2. 自动导入 element 的样式文件(也就是 sideEffects 样式文件路径数组 )

根据上述 ElementPlusResolver 源码实现我们大概得知,element-plus 的不同版本,样式、组件的路径可能都不一样。也就是说,如果我们直接把 element-plus 相应的路径解析放到我们自己组件库的 resover 中实现(比如VcElementPlusResolver),就会很被动,如果 element-plus 的哪次改版更换了路径,我们也要跟着更换,然后更新我们自己的 resolver...

所以,最合适的方案应该是去复用当前业务项目中的 ElementPlusResolver,我们在这个的基础上进行一层包装是最为稳妥的。那我们应该从何开始下手呢?当然是从 unplugin 插件的文档开始!

在文档中可以找到自定义 resolver 的写法: 组件库实战——按需加载工程化

可以看到自定义写法非常简单,在 resolvers 数组中定义一个函数,这个函数直接返回一个对象,也就是上面源码分析中的 { name, from, sideEffects } 。所以笔者打算这样去用 resovler ,其中接收一个 ElementPlusResolver(以复用):

Components({
  resolvers: [
    VcElementPlusResolver(ElementPlusResolver),
    ElementPlusResolver()
  ],
})

到这一步,我们已经很清晰接下来要怎么做了。VcElementPlusResolver 接受一个函数参数,并返回 { name, from, sideEffects } 这么一个对象!接下来笔者就直接把代码贴上来了(主要 get 到思路就行了,源码感兴趣的可以细看注释):

export const VcElementPlusResolver = (ElementPlusResolver: ElementPlusResolver) => {
  return async(componentName: string) => {
    // 只处理 Vc 开头的组件
    if (!componentName.startsWith('Vc')) return
    // 只处理组件情况时只需要取下标 0 即可。回顾上文 ElementPlusResolver 返回一个数组,第一项是 component ,第二项是 directive
    const component = ElementPlusResolver()[0]
    // 上文源码解析可知这个 component 其实是: { type: 'componet', resolve: () => {} }
    // 这里我们按照 ElementPlusResolver 的规范取调用它的 resolve 函数
    const result = await component.resolve(componentName.replace('Vc', 'El'))
    // 最后我们能得到属于 element 的结果对象:{ name, from, sideEffects }
    
    // 这个是我们组件库自己的 组件名 和 组件地址
    const reResult = {
      name: `Vc${componentName.slice(2)}`,
      from: '@xxx/vc-element-plus/dist/vc-element-plus.es.js',
    }
    // 最终返回一个合并对象(仅保留原本的 sideEffects 属性,其他的被 Vc 的替换了)
    return {
      ...result,
      ...reResult,
    }
    // { name(Vc 的组件名), from(Vc 的组件地址), sideEffects(El的样式地址数组) }
  }
}

主要的实现逻辑就是拿到 ElementPlusResolver 的结果后,使用 Vc 的结果替换掉它的 namefrom 属性,但是保留 elementsideEffects 属性,这样一来就能让我们既引用了 Vc 的组件,又不会落下 element 组件的样式了!

好!好极了!但是...还是有点小问题,我们接着往下看!

三、问题 & 解决方案

上面已经介绍了如何实现组件库自动导入的功能,但是因为我们每次的自动导入、解析都是要基于 element-plus 的,所以就会出现一个问题:当我们使用了一个 element-plus 不存在的组件就会报错

怎么说呢,比如我们根据业务需求需要新增一个小组件,如 vc-back 来实现返回上级页面的按钮。那么此时插件的解析会把 back 切割下来去获取一系列的路径。当获取到 element-plus 的样式时就会出现这样的路径:element-plus/packages/theme-chalk/src/back.scss。好了,由于 elemetn-plus 没有 back 这个组件,所以这个 scss 文件当然是不存在的,直接去取这个路径插件会报一个大错给你...

所以,我们在上述的插件的实现逻辑中,应该去规避这种情况的发生,所以笔者还加上了一个 fsstat 来判断 element-plus 是否存在这个路径(随便找了个来用,因为路径不对有报错的话我在 catch 中处理掉了结果也是一样的)。关于 stat ,大概用法笔者也是查资料的,具体的就不在这里班门弄斧了,大家感兴趣的自行查阅吧。大概功能如下: 组件库实战——按需加载工程化

所以,笔者在上述代码中增加以下逻辑:

try {
  // 判断当前 style 路径是否存在,避免 unplugin 插件引入报错(比如 vc-back 组件,el中没有 el-back 组件就会报错)
  const directory = path.resolve(process.cwd(), `node_modules/${result.sideEffects}`, '../')
  // 这里传入的路径不存在就会走到 catch 逻辑了
  const stats = await stat(directory)
  stats.isDirectory()
}
catch (e) {
  // 报错了走到这里,仅返回对应的 vc 组件的 name 、from,不返回 sideEffects 属性了
  return reResult
}

这样我们在插件中 catch 了错误且返回了 Vc 组件的 namefrom 属性依然能实现自动导入功能,且不会在业务层中报错。所以我们可以通过这样方法来规避掉一些自定义而 element-plus 中不存在的组件的导入报错问题了。

当然啦,写到这里笔者也不是高枕无忧的,毕竟目前已知的还有一种场景可能会出现问题,那就是组合组件。比如我们打算自己封装一个 crud 组件为 vc-crud,里面会用到 el-tableel-inputel-select 等多种 element 的组件组合,此时我们不仅要考虑因不存在 vc-curd 而报错的问题,还要解决如何同时自动导入这么多个组合组件的样式问题...

虽然笔者目前有一个简单粗暴的解决方案(给组合组件配置一个映射关系表),但是因为!还没有出现问题且还没有这种组合组件的需求,所以笔者当然是选择先放着啦!上班多摸几条鱼不香吗?

写在最后

像笔者这么懒(要多摸鱼)的人,当然是不会主动去搞什么自动导入工程化的,还不是因为某些用了自动导入的项目接入组件库的时候出现了样式丢失的问题...但,你还别说,还真香定律了。其实要实现这个 resovler 也不难,简简单单几行代码就能提升极大的用户体验还真挺香的。毕竟对于接入方来说,如何更轻量化的接入将决定他们对你提供的组件库的好感度,心智负担越低,其他的开发者更愿意去接入,自然而然的我们作为基础的提供者在推广层面就更有优势。好吧,本文就先写到这里了,后续一定会接着把相关的组件库实战写成文章来进行分享,感谢阅读。