按需加载更优雅的解决方案——vite-plugin-components

18,907 阅读6分钟

这是我参与更文挑战的第3天,活动详情查看 更文挑战

按需加载最早的使用方式

antd按需加载

插件配置

在vite.config.ts

import usePluginImport from "vite-plugin-importer";

export default defineConfig({
   plugins: [
      usePluginImport({
           libraryName: "ant-design-vue",
           libraryDirectory: "es",
           style: "css",
      }),
   ]
 })

使用

import { Button, List, Checkbox, Popconfirm, Input } from "ant-design-vue";

 components: {
    [Checkbox.name]: Checkbox,
    [Input.name]: Input,
    [List.name]: List,
    [List.Item.name]: List.Item,
     AListItemMeta: List.Item.Meta, //这里用框架List.Item.Meta.name注册不上只能写死,可能使用displayName可以
    [Button.name]: Button,
    [Popconfirm.name]: Popconfirm,
 },

痛点:

  • 由于antd组件库使用了很多子组件,比如List下的组件ListItem,如果少注册了一个都会造成模板解析不了
  • 需要引入一遍,然后注册时候key写一遍,值又写一遍,同样的代码需要写3遍,并要关注子组件和父组件的关系
  • 部分组件并未提供name属性,比如AListItemMeta 所以需要写死,导致风格不一致

element-plus按需加载

插件配置

在vite.config.ts

import styleImport from "vite-plugin-style-import";

export default defineConfig({
   plugins: [
      styleImport({
      libs: [
        {
          libraryName: "element-plus",
          esModule: true,
          ensureStyleFile: true,
          resolveStyle: name => {
            return `element-plus/lib/theme-chalk/${name}.css`;
          },
          resolveComponent: name => {
            return `element-plus/lib/${name}`;
          },
        },
      ],
    }),
   ]
 })

使用方式

import { ElForm, ElButton, ElFormItem, ElInput } from "element-plus";
 
components: {
   ElForm,
   ElButton,
   ElInput,
   ElFormItem,
},

痛点:

  • 同样是父子组件,要引入两遍,比如FormFormItem,与antd不同的是element需要分别引入父子组件,并且只能使用components的注册方式,但是antd除此还支持插件方式注册,用app.use(xxx)

改进

为了解决antd父子组件引入的父子组件造成困扰,antd是提供app.use(xx)这种插件注册的方式,antd内部自动解决了子组件依赖的问题,比如要使用List组件,只需要app.use(List)即可使用List和ListItem,ListItemMeta,这样方便了不少

于是写了一个useComp的方法使用 app.use(comp);进行注册 在vue3中首先要获取到app实例,vue3提供了getCurrentInstance这个方法可以获取到当前组件的实例,然后在通过当前实例获取到全局上下文中的app对象,instance?.appContext.app;,这样就可以使用app.use进行注册了,还要注意的是,同一个组件避免重复注册,需要记录一下已经注册过的组件

代码useAntd.ts如下:

import { Plugin, getCurrentInstance } from "vue";

interface Registed {
  [key: string]: boolean;
}
let registed: Registed = {};

type Comp = {
  displayName?: string;
  name?: string;
} & Plugin;

type RegisteComps = (...comps: Comp[]) => void;

export const useComp: RegisteComps = (...comps) => {
  comps.forEach(comp => {
    const name = comp.displayName || comp.name;

    if (name && !registed[name]) {
      const instance = getCurrentInstance();
     
      const app = instance?.appContext.app;

      if (app) {
        app.use(comp);
        registed[name] = true;
      }
    }
  });
};


使用方式:


 import { List, Table, Button, Space } from "ant-design-vue";
 import { useComp } from "@/hooks/useAntd";
 //...略
 setup() {
     useComp(List, Table, Button, Space);
     return {} 
 }

解决痛点:

  • 无需关系父子组件依赖关系
  • 减少components注册的代码
  • 用到哪些组件,直接将import 括号中的组件名称,复制粘贴到useComp方法中即可,属于无脑操作,

遗留痛点:

  • element,naive还是需要用components进行一一注册
  • 相较tsx在setup中还是多了一行注册的代码

理想形态:

所用组件无需关心引入和注册,就像全量引入一样,直接在模板使用即可,既可以写起来舒服,又无需关注忘记注册组件带来的烦恼

长时间一直在寻找类似的方法,直到逛社区,发现大佬antfu开源了一个叫vite-plugin-components的插件,这正是我想要寻找的。 它实现的功能就是:自动解析模板中所用到的组件,然后自动按需引入,再也不需要手动进行注册了。

但是理想很丰满,现实很骨感,踩到了一些坑

插件配置:

import ViteComponents, {
  AntDesignVueResolver,
  ElementPlusResolver,
  ImportInfo,
  kebabCase,
  NaiveUiResolver,
  pascalCase,
} from "vite-plugin-components";

 
export default defineConfig({
  plugins: [
    ViteComponents({
      customComponentResolvers: [
        AntDesignVueResolver(),//官方插件提供
        ElementPlusResolver(),//官方插件提供
        NaiveUiResolver(),//官方插件提供
      ]
    })
  ]
})

尝试结果:

  • naiveui完美支持,赞!
  • elementui官方用的scss,需要安装scss依赖才行
  • antdv 只有少部分组件可以解析,像layout,list table这种常用组件不能解析

解决:

element-plus重写resolver:

重写的理由:由于项目没有使用scss只用了less,所以把scss转成css加载样式的方式

官方的写法如下:

 const { importStyle = true } = options
  if (name.startsWith('El')) {
    const partialName = name[2].toLowerCase() + name.substring(3).replace(/[A-Z]/g, l => `-${l.toLowerCase()}`)
    return {
      path: `element-plus/es/el-${partialName}`,
      sideEffects: importStyle ? `element-plus/packages/theme-chalk/src/${partialName}.scss` : undefined,
    }
  }

重写之后:改为从lib目录引入的路径,直接使用组件名称作为文件目录名引入,不在做复杂的组件名转换了。 代码如下:

  customComponentResolvers: [
        // AntDesignVueResolver(),
        // ElementPlusResolver(),
        NaiveUiResolver(),
        name => {
          if (name.startsWith("El")) {
            // Element UI
            const partialName = kebabCase(name); //ElButton->el-button
            return {
              path: `element-plus/lib/${partialName}`,
              sideEffects: `element-plus/lib/theme-chalk/${partialName}.css`,
            };
          } 
        }
     ]


antdv

官方的做法是:

export const AntDesignVueResolver = (): ComponentResolver => (name: string) => {
  if (name.match(/^A[A-Z]/))
    return { importName: name.slice(1), path: 'ant-design-vue/es' }
}

存在的问题:

  • <a-list-item>这种组件就没法到ant-design-vue/es/list-item这个目录找,并不存在这样的目录,实际上他的真实目录是ant-design-vue/es/list/Item.js

  • <a-layout-content组件也没有对应的路径,他是通过layout中的生成器generator方法生成的,并绑定到layout对象上,他的实际路径应该是ant-design-vue/es/layout/layout.js的Content属性

  • <a-select-option>这个组件也是绑定到select上的,但是实际上他引入是引入的vc-select/Option.js属于基层组件

  • <a-menu-item>组件是属于menu的子组件,目录是ant-design-vue/es/menu/MenuItem.js,这个和之前规则不一样,我以为应该叫Item才对,但是这里去不同,所以需要特殊处理,

  • 还有<a-tab-pane>这种组件,他所需要的样式目录是在tabs,但是实际上它的文件目录是在vc-tabs/src下,也需要特殊处理

以上问题都是官方的写法无法正常加载到对应组件的原因,因此为了解决以上问题,我针对不同的情况写了一大坨判断逻辑,来修正组件的引入路径,但是依旧有部分组件无法引入到,因为有些组件是functional的,或者是generator生成的,并不具备独立的子组件文件,暂时也没有找到合适的方法引入对应的子组件属性

解析组件路径的getCompPath方法,代码如下:

function getCompPath(
  compName: string
): {
  dirName: string;
  compName: string;
  styleDir: string;
  importName?: string;
  sideEffects?: ImportInfo;
} {
  const hasSubComp = [
    "Menu",
    "Layout",
    "Form",
    "Table",
    "Modal",
    "Radio",
    "Button",
    "Checkbox",
    "List",
    "Collapse",
    "Descriptions",
    "Tabs",
    "Mentions",
    "Select",
    "Anchor",
    "Typography",
    // "TreeSelect",
  ]; //包含子组件的组件
  const keepSelf = [
    "MenuItem",
    "SubMenu",
    "FormItem",
    "RadioButton",
    "CollapsePanel",
    "TabPane",
    "AnchorLink",
  ]; //保留原子组件名称
  const keepFather = [
    "LayoutHeader",
    "LayoutContent",
    "LayoutFooter",
    "DescriptionsItem",
  ]; //需要使用父组件名称的子组件  LayoutFooter->''  之所以转成空是因为最后拼接的结果是dirName+compName,避免重复
  const rootName = hasSubComp.find((name: string) => compName.startsWith(name));
  const usePrevLevelName = ["ListItemMeta"]; //使用当前组件的上一级名称  ListItemMeta->Item
  const getPrevLevelName = () => {
    const split = kebabCase(compName).split("-");
    return pascalCase(split[split.length - 2]);
  };

  const fatherAlias = {
    TabPane: "vc-tabs/src",
    MentionsOption: "vc-mentions/src",
    SelectOption: "vc-select",
    TreeSelectNode: "vc-tree-select/src",
  };

  const compAlias = {
    TreeSelectNode: "SelectNode",
  };

  const styleMap = {
    TabPane: "tabs",
    MentionsOption: "mentions",
    SelectOption: "select",
    TreeSelectNode: "tree-select",
  };
  // const importNameMap = {
  //   LayoutContent: "Content",
  //   LayoutHeader: "Header",
  //   LayoutFooter: "Footer",
  // };

  let dirName = rootName?.toLowerCase() ?? kebabCase(compName);

  if (fatherAlias[compName]) {
    dirName = fatherAlias[compName];
  }

  let compNameStr = "";
  if (keepSelf.includes(compName)) {
    compNameStr = compName;
  } else if (keepFather.includes(compName)) {
    compNameStr = "";
  } else if (usePrevLevelName.includes(compName)) {
    compNameStr = getPrevLevelName();
  } else if (rootName) {
    compNameStr = compName.replace(rootName, "");
  }
  const compRequired = {
    TypographyTitle: "ant-design-vue/es/" + dirName + "/Base",
    TypographyText: "ant-design-vue/es/" + dirName + "/Base",
  };

  return {
    // importName: importNameMap[compName],
    dirName: fatherAlias[compName] ?? dirName,
    styleDir: `${styleMap[compName] ?? dirName}`,
    compName: compAlias[compName] ?? compNameStr,
    sideEffects: compRequired[compName]
      ? {
          path: compRequired[compName],
        }
      : undefined,
  };
}


自定义resolver,代码如下

 ViteComponents({
      customComponentResolvers: [
    
        name => {
          if (name.match(/^A[A-Z]/)) {
            //ant-design-vue

            const importName = name.slice(1);
            const dirName = kebabCase(importName);
            const compName = pascalCase(importName); //AListItem->ListItem
            const compPath = getCompPath(compName);//这里解析组件的真实路径

            const sideEffects = [
              {
                path: `ant-design-vue/es/${compPath.styleDir}/style`,
              },
            ];
            if (compPath.sideEffects) {
              sideEffects.push(compPath.sideEffects);
            }
            return {
              path: `ant-design-vue/es/${compPath.dirName}/${compPath.compName}`,
              sideEffects,
            };
          }
          return null;
        },
      ],
      globalComponentsDeclaration: true,
    }),

经过解析,绝大部分组件可以使用,还有遗留的部分组件不能正常使用

插件生成部分组件的声明文件如下:


declare module 'vue' {
  export interface GlobalComponents {
    AMenuItem: typeof import('ant-design-vue/es/menu/MenuItem')['default']
    AMenu: typeof import('ant-design-vue/es/menu/')['default']
    ALayoutHeader: typeof import('ant-design-vue/es/layout/')['default']
    ALayoutContent: typeof import('ant-design-vue/es/layout/')['default']
    ALayoutFooter: typeof import('ant-design-vue/es/layout/')['default']
    ALayout: typeof import('ant-design-vue/es/layout/')['default']
    AButton: typeof import('ant-design-vue/es/button/')['default']
    ADivider: typeof import('ant-design-vue/es/divider/')['default']
    AInput: typeof import('ant-design-vue/es/input/')['default']
    AFormItem: typeof import('ant-design-vue/es/form/FormItem')['default']
    ASpace: typeof import('ant-design-vue/es/space/')['default']
    AForm: typeof import('ant-design-vue/es/form/')['default']
    ACheckbox: typeof import('ant-design-vue/es/checkbox/')['default']
    AListItemMeta: typeof import('ant-design-vue/es/list/Item')['default']
    APopconfirm: typeof import('ant-design-vue/es/popconfirm/')['default']
    AListItem: typeof import('ant-design-vue/es/list/Item')['default']
    AList: typeof import('ant-design-vue/es/list/')['default']
    ATable: typeof import('ant-design-vue/es/table/')['default']
  }
}

具体问题如下:

  • layout下的组件,Content,Header,Footer,由于默认只引入default导出的,如果引入类似['default']['Content'],应该就可以正常,现在问题是把Content等子组件都当做了layout进行解析,导致样式会有问题
  • ListItemMeta组件导入不正常,同样只能引入['default'],如果可以引入['default']['Meta']应该就可以解决了
  • Typography组件 引入title组件会报错:TypeError: baseProps is not a function但是实际上该组件使用相对路径引入了这个方法

希望大佬们可以帮忙一起看看上述问题该如何解决,感觉现在的代码写的有些麻烦,如果有更优雅的方法可以交流一下

-----------20210625更新------------

版本2.2.0-beta.5: 官方已经支持vite-plugin-components

待完善小问题,主要是名字拼写错误

  • LayoutContent错写成layouContent
  • PassWord导出为InputPassoword等组件名称重命名

备注:经过查看官方已经修复了,暂未发版,坐等beta.6发版~~~

经过更改node_modules源码后,发现可以正常加载了, 然后官方的vite-plugin-compoennts的resolver还是不好用,还是需要自定义一下,代码如下

       const importName = name.slice(1);
       return {
              importName: importName,
              path: `ant-design-vue/es`,
              sideEffects: "ant-design-vue/es/style",
       };

舒服多了,赞一下antd-vue团队唐老大的响应速度和支持力度!!!

更新

---------20210626更新-----------

版本2.2.0-beta.6: 官方修复了导入拼写错误的问题,按照上面的导入方式可以正常使用了,

建议:style样式上面的方式是全量导入,不知有没有按需导入的方式,使用vite-plugin提供的resolver导入之后没有样式,但也没有报错

--------20210627更新-------------

vite-plugin-components:已经合并了pr,可以用了,就是style是全量引入

全量引入style还是太慢,加载很多没有用的style,开始尝试按需加载style,

由于antdv官方解决了js导入的问题,剩下style导入似乎没有那么麻烦,只要找对了目录就可以了, 参考之前的写法,还是写了一个配置表,用于匹配组件的目录,思路如下:

  • 子组件或者需要特殊处理的组件,根据正则匹配,如果匹配到则返回配置好的目录
  • 如果没有匹配到的组件,直接转换kebabCase(烤肉串形式)形式

接口interface如下:

interface IMatcher {
  pattern: RegExp;
  styleDir: string;
}

经过测试后需要配置的组件信息如下:

const matchComponents: IMatcher[] = [
  {
    pattern: /^Avatar/,
    styleDir: 'avatar',
  },
  {
    pattern: /^AutoComplete/,
    styleDir: 'auto-complete',
  },
  {
    pattern: /^Anchor/,
    styleDir: 'anchor',
  },

  {
    pattern: /^Badge/,
    styleDir: 'badge',
  },
  {
    pattern: /^Breadcrumb/,
    styleDir: 'breadcrumb',
  },
  {
    pattern: /^Button/,
    styleDir: 'button',
  },
  {
    pattern: /^Checkbox/,
    styleDir: 'checkbox',
  },
  {
    pattern: /^Card/,
    styleDir: 'card',
  },
  {
    pattern: /^Collapse/,
    styleDir: 'collapse',
  },
  {
    pattern: /^Descriptions/,
    styleDir: 'descriptions',
  },
  {
    pattern: /^RangePicker|^WeekPicker|^MonthPicker/,
    styleDir: 'date-picker',
  },
  {
    pattern: /^Dropdown/,
    styleDir: 'dropdown',
  },

  {
    pattern: /^Form/,
    styleDir: 'form',
  },
  {
    pattern: /^InputNumber/,
    styleDir: 'input-number',
  },

  {
    pattern: /^Input|^Textarea/,
    styleDir: 'input',
  },
  {
    pattern: /^Statistic/,
    styleDir: 'statistic',
  },
  {
    pattern: /^CheckableTag/,
    styleDir: 'tag',
  },
  {
    pattern: /^Layout/,
    styleDir: 'layout',
  },
  {
    pattern: /^Menu|^SubMenu/,
    styleDir: 'menu',
  },

  {
    pattern: /^Table/,
    styleDir: 'table',
  },
  {
    pattern: /^Radio/,
    styleDir: 'radio',
  },

  {
    pattern: /^Image/,
    styleDir: 'image',
  },

  {
    pattern: /^List/,
    styleDir: 'list',
  },

  {
    pattern: /^Tab/,
    styleDir: 'tabs',
  },
  {
    pattern: /^Mentions/,
    styleDir: 'mentions',
  },

  {
    pattern: /^Mentions/,
    styleDir: 'mentions',
  },

  {
    pattern: /^Step/,
    styleDir: 'steps',
  },
  {
    pattern: /^Skeleton/,
    styleDir: 'skeleton',
  },

  {
    pattern: /^Select/,
    styleDir: 'select',
  },
  {
    pattern: /^TreeSelect/,
    styleDir: 'tree-select',
  },
  {
    pattern: /^Tree|^DirectoryTree/,
    styleDir: 'tree',
  },
  {
    pattern: /^Typography/,
    styleDir: 'typography',
  },
  {
    pattern: /^Timeline/,
    styleDir: 'timeline',
  },
]

写个循环进行遍历,得到样式的真实路径

          const importName = name.slice(1);
            let styleDir;
            const total = matchComponents.length;
            for (let i = 0; i < total; i++) {
              const matcher = matchComponents[i];
              if (importName.match(matcher.pattern)) {
                styleDir = matcher.styleDir;
                break;
              }
            }

            if (!styleDir) {
              styleDir = kebabCase(importName);
            }

            return {
              importName: importName,
              path: `ant-design-vue/es`,
              sideEffects: `ant-design-vue/es/${styleDir}/style`,
            };

提个pr,希望能够早日通过: PR 链接

---------20210629更新------------

vite-plugin-components: antfu已经发版了0.11.4 ,舒服啦~~~

增加功能: 增加css加载,也是看到了issue得到的启发,之前一直想着加载index.css却没有想到还可以直接加载css.js,之前一加载到popconfirm,找不到index.css就会报错,因为他本身是没有样式,全都是依赖另外两个组件的样式,现在直接加载css.js一下就解决这个问题了,在js中按需加载相应的样式就好了 代码更改如下:

const getSideEffects: (
  compName: string,
  opts: AntDesignVueResolverOptions
) => string | undefined = (compName, opts) => {
  const { importStyle = true, importCss = true, importLess = false } = opts

  if (importStyle) {
    if (importLess) {
      const styleDir = getStyleDir(compName)
      return `ant-design-vue/es/${styleDir}/style`
    }
    else if (importCss) {
      const styleDir = getStyleDir(compName)
      return `ant-design-vue/es/${styleDir}/style/css`
    }
  }
}

已经合了pr

插件使用配置方式如下:

一共加了三个参数,默认什么都不传,如果传参则通过对象的形式传 默认值:

  • importStyle: true
  • importCss:true
  • importLess:false
  1. 只加载css
AntDesignVueResolver()
  1. 只加载less
AntDesignVueResolver({
    importLess: true
})

说明:需要开启less的 javascriptEnabled: true,

  1. 不加载组件样式:
AntDesignVueResolver({
    importStyle: false
})

插件参数定义如下:

export interface AntDesignVueResolverOptions {
  /**
   * import style along with components
   *
   * @default true
   */
  importStyle?: boolean
  /**
   * import css along with components
   *
   * @default true
   */
  importCss?: boolean
  /**
   * import less along with components
   *
   * @default false
   */
  importLess?: boolean
}

性能测试

有同学建议我的优化循环算法,将时间复杂度O(n)优化为O(1)使用map进行映射, 但是使用正则匹配我也是有我自己的取舍的,因为如果用map存储组件和样式路径的映射,有以下缺点

  • 需要写大量的映射配置, 比如ListItem和ListItemMeta就要配置成两项
var map = {
ListItem: 'list',
ListItemMeta: 'list'
}

而使用正则,只需要配置一行,而且无论List再加子组件,一行正则也能匹配上,而用上面的map就需要再追加配置,可维护性降低

var reg = {
pattern: /^List/
dir: 'list'
}
  • 性能差别,为了彻底搞明白,映射和正则在这种小数据量到底有多大差别,特意写了一个性能测试的方法,查看区别,代码地址:codesandbox.io/s/zhengzepi…

结果:基本上两者差别在0.1ms左右,而且互有胜负,map并不具备压倒性优势,也许数据量小,数据量大了可能就有区别了,但是我觉得在性能差别不大的情况下,选择可维护性更高的方案

相关issue和代码

样式按需加载的issue

antdv有关该问题的 issue

vite-plugin-components 有关该问题的issue

代码: github——todo项目地址

其他

最近支持了部分vue2组件库(view ui 和element ui) 总结了两篇文章如下: