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

这是我参与更文挑战的第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但是实际上该组件使用相对路径引入了这个方法

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

相关issue和代码

antdv有关该问题的 issue

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

代码:
github——todo项目地址

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享