组件库架构——Element Plus 如何实现按需导入

Element Plus 的按需导入是很常见的功能,但是对于整个过程都用了哪些插件、怎么实现的,可能大家就比较少了解。本文逐步剖析了按需导入的整体实现,相信大家在阅读后都能有所收获。

有较多源码+注释,因为代码块不能指定部分区域的背景色,阅读起来可能有点费眼,请见谅。

用法(Vite)

// vite.config.ts
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'


export default defineConfig({
  // ...
  plugins: [
    // ...
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})

可以看出核心就是引入了 unplugin-auto-importunplugin-vue-components 两个插件,并使用了同样的参数 ElementPlusResolver,并且它们都是基于 unplugin 开发的。整体架构如下:

流程图.jpg

接下来介绍各插件的功能和实现

unplugin

github.com/unjs/unplug…

功能介绍

提供统一构建工具的插件接口(基于 Rollup 插件 API 设计),换个说法就是让你的插件能够同时在 Vite、Webpack、Rollup 等主流构建工具中使用。使用方式如下:

import { createUnplugin } from 'unplugin'

export const unplugin = createUnplugin((options: UserOptions) => {
  return {
    name: 'unplugin-prefixed-name',
    // webpack's id filter is outside of loader logic,
    // an additional hook is needed for better perf on webpack
    transformInclude(id) {
      return id.endsWith('.vue')
    },
    // just like rollup transform
    // ******重点******
    transform(code) {
      return code.replace(/<template>/, '<template><div>Injected</div>')
    },
    // more hooks coming
  }
})


export const vitePlugin = unplugin.vite
export const rollupPlugin = unplugin.rollup
export const webpackPlugin = unplugin.webpack
export const rspackPlugin = unplugin.rspack
export const esbuildPlugin = unplugin.esbuild

transform 的作用就是对源码进行转换,本文主要关注插件中 transform 的处理过程,unplugin 的实现就不展开了。

unplugin-vue-components

github.com/antfu/unplu…

功能介绍

  1. 自动导入 import 和注册组件,提升开发效率,如下:
# 开发代码
<template>
  <div>
    <HelloWorld msg="Hello Vue 3.0 + Vite" />
  </div>
</template>

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


# 经过插件自动导入后
<template>
  <div>
    <HelloWorld msg="Hello Vue 3.0 + Vite" />
  </div>
</template>

<script>
import HelloWorld from './src/components/HelloWorld.vue'


export default {
  name: 'App',
  components: {
    HelloWorld
  }
}
</script>
  1. 对于 ts 项目,还会自动生成对应组件的声明 components.d.ts

源码定位

  1. 根据 import Components from 'unplugin-vue-components/vite' 在 unplugin-vue-components 的 package.json 找对应路径
    // package.json
    "./vite": {
      "types": "./dist/vite.d.ts",
      "require": "./dist/vite.js",
      "import": "./dist/vite.mjs"
    }
  1. 查看构建配置找到源码入口

其实就是把 /src 目录下的 .ts 文件分别构建输出,可以定位到 /src/vite.ts

  // package.json
  "scripts": {
    "build": "tsup && esno scripts/postbuild.ts",



  // tsup.config.ts
  export const tsup: Options = {
    entry: [
      'src/*.ts',
    ],

  1. 利用编辑器定位 transform

image.png

  1. 定位 transform 核心实现 /src/core/transformer.ts

    image.png

跳转至 transformer 可以看到有两个方法 transformComponent、transformDirectives ,其中 transformDirectives 是自动导入指令时运行的,先关注 transformComponent 即可。

image.png

源码分析

  1. 通过简单例子调试分析 transformComponent 方法的源码
// App.vue
<script setup>
</script>



<template>
  <el-button type="primary">
    我是一个按钮
  </el-button>
  <el-rate :model-value="5" />
</template>

<style scoped>
</style>
// src/core/transforms/component.ts

export default async function transformComponent(code: string, transformer: SupportedTransformer, s: MagicString, ctx: Context, sfcPath: string) {
  // code:.vue 文件经过编译后的字符串,这里只截取出其中一部分
  // "const _component_el_button = _resolveComponent("el-button")\n  const _component_el_rate = _resolveComponent("el-rate")"
  
  // no:计数
  let no = 0


  // results:调用 resolveVue3 获取代码中使用了哪些组件
  // [
  //   {
  //     rawName: "el-button",
  //     replace: (resolved) => s.overwrite(start, end, resolved),
  //   },
  //   {
  //     rawName: "el-rate",
  //     replace: (resolved) => s.overwrite(start, end, resolved),
  //   },
  // ]
  const results = transformer === 'vue2' ? resolveVue2(code, s) : resolveVue3(code, s)

  // 以 { rawName: 'el-button' } 为例
  for (const { rawName, replace } of results) {
    debug(`| ${rawName}`)
    
    // name = "ElButton"
    const name = pascalCase(rawName)
    ctx.updateUsageMap(sfcPath, [name])
    
    // component:获取对应组件的导入来源
    // {
    //   as: "ElButton",
    //   name: "ElButton",
    //   from: "element-plus/es",
    //   sideEffects: [
    //     "element-plus/es/components/base/style/css",
    //     "element-plus/es/components/button/style/css",
    //   ],
    // }
    const component = await ctx.findComponent(name, 'component', [sfcPath])
    if (component) {
      const varName = `__unplugin_components_${no}`
    
      // stringifyComponentImport() 返回需要导入的语句字符串
      // `
      // import { ElButton as __unplugin_components_0 } from 'element-plus/es';
      // import 'element-plus/es/components/base/style/css';
      // import 'element-plus/es/components/button/style/css';
      // `
      
      // s.prepend() 在源码最前面插入语句
      s.prepend(`${stringifyComponentImport({ ...component, as: varName }, ctx)};\n`)
      no += 1
      // 替换组件名:ElButton => __unplugin_components_0
      replace(varName)
    }
  }

  debug(`^ (${no})`)
}
  1. 接下来我们剖析 resolveVue3findComponent 的过程

resolveVue3

可以看到是用正则将代码中所有组件调用匹配出来,如 “_resolveComponent(“el-button”)”,解析成需要的格式

// src/core/transforms/component.ts

function resolveVue3(code: string, s: MagicString) {
  const results: ResolveResult[] = []



  for (const match of code.matchAll(/_resolveComponent[0-9]*("(.+?)")/g)) {
    const matchedName = match[1]
    if (match.index != null && matchedName && !matchedName.startsWith('_')) {
      const start = match.index
      const end = start + match[0].length
      results.push({
        rawName: matchedName,
        replace: resolved => s.overwrite(start, end, resolved),
      })
    }
  }

  // [
  //   {
  //     rawName: "el-button",
  //     replace: (resolved) => s.overwrite(start, end, resolved),
  //   },
  //   {
  //     rawName: "el-rate",
  //     replace: (resolved) => s.overwrite(start, end, resolved),
  //   },
  // ]
  return results
}

findComponent

先看折叠的代码

  // src/core/context.ts

  async findComponent(name: string, type: 'component' | 'directive', excludePaths: string[] = []): Promise<ComponentInfo | undefined> {
    let info = this._componentNameMap[name]
    
    // 如果之前有解析过,且不在排除路径中,就可以直接返回缓存的结果
    if (info && !excludePaths.includes(info.from) && !excludePaths.includes(info.from.slice(1)))
      return info


    // 如果插件有配置自定义解析器(ElementPlusResolver),则进行遍历
    for (const resolver of this.options.resolvers) {
      // ...
    }


    return undefined
  }

展开看遍历逻辑

  // src/core/context.ts

    for (const resolver of this.options.resolvers) {
      if (resolver.type !== type)
        continue
      // 调用自定义解析器(ElementPlusResolver)中的方法 resolver.resolve('ElButton') 获取导入组件所需信息
      // {
      //   name: "ElButton",
      //   from: "element-plus/es",
      //   sideEffects: [
      //     "element-plus/es/components/base/style/css",
      //     "element-plus/es/components/button/style/css",
      //   ],
      // }
      const result = await resolver.resolve(type === 'directive' ? name.slice(DIRECTIVE_IMPORT_PREFIX.length) : name)
      if (!result)
        continue

      if (typeof result === 'string') {
        info = {
          as: name,
          from: result,
        }
      }
      else {
        info = {
          as: name,
          ...normalizeComponetInfo(result),
        }
      }
      
      // 缓存结果
      if (type === 'component')
        this.addCustomComponents(info)
      else if (type === 'directive')
        this.addCustomDirectives(info)
      return info
    }

接下来看 ElementPlusResolver

// src\core\resolvers\element-plus.ts
export function ElementPlusResolver(
  options: ElementPlusResolverOptions = {},
): ComponentResolver[] {
  // ...


  return [
    {
      type: 'component',
      // 这里就是上个代码块中调用的 resolver.resolve
      resolve: async (name: string) => {
        // ...
        // 详情见下面
        return resolveComponent(name, options)
      },
    },
    {
      type: 'directive',
      // ...
    },
  ]
}


function resolveComponent(name: string, options: ElementPlusResolverOptionsResolved): ComponentInfo | undefined {
  // ...
  
  // icon 组件为单独项目,不同处理,继续往下看
  if (name.match(/^ElIcon.+/)) {
    return {
      name: name.replace(/^ElIcon/, ''),
      from: '@element-plus/icons-vue',
    }
  }

  // ...

  // >=1.1.0-beta.1
  if (compare(version, '1.1.0-beta.1', '>=')) {
    return {
      name,
      // 根据引入方式选择路径
      from: `element-plus/${ssr ? 'lib' : 'es'}`,
      // 组件样式需要另外导入,因此还需要进一步处理,继续看下面
      sideEffects: getSideEffects(partialName, options),
    }
  }
  // ... 旧版 element plus 的兼容
}

function getSideEffects(dirName: string, options: ElementPlusResolverOptionsResolved): SideEffectsInfo | undefined {
  const { importStyle, ssr } = options
  const themeFolder = 'element-plus/theme-chalk'
  const esComponentsFolder = 'element-plus/es/components'

  // 根据 importStyle 配置返回 sass / css 资源路径
  // sass 配置通常用于定制主题:
  // ElementPlusResolver({
  //   importStyle: "sass",
  //   ...
  // }),
  if (importStyle === 'sass') {
    return ssr
      ? [`${themeFolder}/src/base.scss`, `${themeFolder}/src/${dirName}.scss`]
      : [`${esComponentsFolder}/base/style/index`, `${esComponentsFolder}/${dirName}/style/index`]
  }
  else if (importStyle === true || importStyle === 'css') {
    // [
    //   "element-plus/es/components/base/style/css",
    //   "element-plus/es/components/button/style/css"
    // ]
    return ssr
      ? [`${themeFolder}/base.css`, `${themeFolder}/el-${dirName}.css`]
      : [`${esComponentsFolder}/base/style/css`, `${esComponentsFolder}/${dirName}/style/css`]
  }
}

unplugin-auto-import

github.com/antfu/unplu…

功能介绍

自动按需导入调用的API,如

// 处理前
<script setup>
ElMessage('自动导入');
</script>

// 处理后等同效果
import { ElMessage } from 'element-plus'
ElMessage('自动导入');

源码分析过程与 unplugin-vue-components 类似,大家可以自行探索。

结尾

感谢阅读,由于篇幅问题,只能尽量保证完整性,如有缺漏、错误的地方请指出,欢迎大家在评论区讨论。

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

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYuhRLfX' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片