Element Plus
Element Plus 是在 Element UI 的基础上的重构,所以也可以参考 Element UI 的笔记。
ElementPlus(以下简称 EP)是一个基于 Vue 3 的 UI 组件库,其立项初期就明确了要在保留 Element UI 的易用性等基础上,摆脱 Element UI 的历史遗留包袱。除了更多功能和更好的性能,我们还能在 EP 项目代码中看到许多现代的前端项目架构和技术实践。
尽管已经来到 2025 年,前端技术日新月异,但 EP 作为可能是国人最熟悉也最易上手的项目,其代码依然具有很高的参考价值。本文尝试从项目工程的角度入手,用问题的形式,引出近年来前端项目的常见实践和技术。由于 EP 代码量较大,本文可能会跳过对一些基础知识的讲解,并假设读者对 Vue3、TS 和 Element Plus 有基本了解,并对前端工程化有兴趣。本文基于 Element Plus v2.10.2,如有错误之处,欢迎指正。
杂谈
如何从零开始阅读 EP 的代码?
看 Element Plus 的项目源码大致就两个作用,一是了解现代前端工程化是怎么在开源组件库实践的,二是了解通用组件的实现方式。可以从这两个角度,取一个着重点入手。
首先肯定要熟悉 Element Plus 的使用,如果平时没用过,那么通读代码也没作用。其次是从点及面能更快地理解和掌握。具体而言,如果从组件实现的入手,最好从平常碰到问题的组件开始看起,先看具体组件实现,然后推广到相关工具函数,其次看大范围的组件组织,最后是构建、测试和其他项目流程。
从工程化入手则是先了解项目的整体结构和依赖关系,然后再深入到具体的实现细节。
目的不同也会影响阅读顺序,比如抱着开源贡献的目的去看代码,可能会先从贡献指南、代码规范等入手,然后再看具体的实现。不过,无论怎么开始,也有一些通用技巧可以遵守:
- 从通用测试用例开始,了解代码功能,到边缘测试用例结束,逐步深入
- 灵活使用 Git Graph,从代码变更历史、提交信息和 PR 中了解演变过程
- 复杂代码可以画图,比如用 XState 工具画状态图,同 Madge 分析依赖关系等
“前端工程化”的千人千面
前端工程化是一个广泛的概念,其涵盖的内容可以追溯到软件工程的定义和实践在前端领域的特化。但由于没有统一的标准,再加上各个团队、项目的主要目标不同,侧重的内容和实现方式也各不相同,所以实践上有很大差异。举例来说,开源项目可能更注重文档和社区建设,提供一定的开发流程保证代码质量,也通过门户和公共文档方便新手入门,而商业项目则可能更多关注稳定性与开发速度,所以会做数据监控,使用老旧但稳定的技术栈等。
这里尝试给出我的一些看法和总结。前端工程化泛指将自动化、标准化、流程化等工程领域思想应用于前端开发全过程,以达到提升开发效率、提高代码质量与可维护性、协同团队合作的目的。
项目结构
各子包的依赖关系是怎样的?
单仓多包的管理方式,通常叫做 monorepo。要先明确为什么使用 monorepo。假设我们在公司有营销前台项目、用户管理后台项目,这两个项目关系不大,那么放到一个仓库不仅不会带来共享配置和代码的便利,反而会引起权限等方面的问题。适合 monorepo 的项目,其子包一定要保证高度相关,最好能通过共享代码、工具和配置来提高开发效率。
子包的定义可以从 pnpm 工作区间声明中找到,也就是 pnpm-workspace.yaml 文件。
EP 子包详细分为以下几种:
packages:
# 各组件代码,如 alert、button
- packages/components
# 项目常量定义,如组件尺寸枚举(default、large、small)、WEEK_DAYS 枚举等
- packages/constants
# 组件指令工具,如 trap-focus 焦点管理器、click-outside 点击区域外部检测指令等
- packages/directives
# 编译入口,导出了源码的各部分,作为组件代码和编译结果的桥梁
- packages/element-plus
# 工具函数,有 use-namespace、use-z-index
- packages/hooks
# 多语言文件及入口
- packages/locale
# 测试工具
- packages/test-utils
# Chalk 主题相关代码,如主题变量等
- packages/theme-chalk
# 工具函数,如 raf、rand
- packages/utils
# 文档站点
- docs
# 本地开发环境
- play
# 编译工具
- internal/build
# 编译时常量
- internal/build-constants
# 编译时工具函数
- internal/build-utils
# 项目代码规范
- internal/eslint-config
# 项目元信息,目前包含贡献者计算脚本
- internal/metadata
这些包分为 packages/*
、internal/*
、playground
和 docs
四个大类,packages 主要包含组件源码,internal 则是工程化子包源码,playground 和 dev 分别对应开发时的本地环境和文档站点。
可以从 packages.json 找到这些子包间的互相依赖的关系,但是这种依赖关系仅仅意味着代码的直接引用。比如,packages/components 需要使用 packages/hooks 中的工具函数,这就算一种直接引用。如果用图绘制这种引用关系,会形成如下关系图:
这份关系图有许多不完善的地方,我们接下来会继续讨论。
项目代码如何消费子包?
打开 packages.json 可以看到如下定义,这代表着 packages/* 内的包对 internal/build 有着依赖。这种形似“workspace:^0.0.1”的依赖关系,由 pnpm 解析管理,详见 pnpm workspace。
{
"name": "element-plus",
"devDependencies": {
"@element-plus/build": "workspace:^0.0.1"
}
}
声明好 package.json 中的子包依赖,再 pnpm install,会在相应目录创建 symlink,指向子包的实际位置,这样一来 node 的模块查找算法就能找到对应子包的代码。
但是单配置子包为 package.json 的依赖是不够的,因为 Node 模块查找算法只对 webpack 等依赖 node 的工具生效,而 TypeScript 编译器及其他工具依赖在 tsconfig.json 中配置 paths 才能正确解析子包。所以打开 tsconfig.base.json 可以看到如下配置:
{
"compilerOptions": {
"paths": {
"@element-plus/components": ["packages/components"],
"@element-plus/components/*": ["packages/components/*"],
"@element-plus/utils": ["packages/utils"],
"@element-plus/utils/*": ["packages/utils/*"],
"@element-plus/hooks": ["packages/hooks"],
"@element-plus/directives": ["packages/directives"],
"@element-plus/constants": ["packages/constants"],
"@element-plus/locale": ["packages/locale"],
"@element-plus/locale/*": ["packages/locale/*"],
"element-plus": ["packages/element-plus"]
}
}
}
子包的另一种消费方式:bundless stub 模式
除了使用 TypeScript 将 TS 代码编译成 JS 供 NodeJS 执行,子包代码还可能通过“源码 -> 编译产物 -> 其他子包”这种方式被消费。比如,internal 目录下如 internal/build 等子包也打算用作公开发布,所以使用了 unbuild 这个打包工具打包(只是后来相关计划被推迟或放弃,见 Next Step of Element Plus)。
internal/build 子包的打包指令是 “unbuild --stub”,这是个啥?对于 TypeScript 项目,我需要在打包时生成 CJS、EMS 模块和类型声明文件,unbuild 就是用于此类用途的开箱即用工具。unbuild 基于 rollup 和 mkdist,将 TS、CSS 等代码转换为 Bundless 产物,此理念先进且符合开发趋势,尽管在 unbuild 引入 EP 的 2022 之后,我们见到了更多支持库打包且完成度更高的打包器如 Vite。
关于 bundless 的介绍见:Bundle-less 深入理解与实践。
至于 stub,没有找到一个惯用的翻译,单词直译叫“插桩”。stub 模式依赖 jiti 这个 NodeJS 模块加载器,以便在运行时支持 ESM 和 CJS 模块以及 TypeScript 源码的动态加载。插桩的意思很直白,unbuild 实际上并没有把 internal/build 的源码编译成 JS,而是在子包的 dist 目录生成了子包源码的入口文件,见 package.json:
{
"name": "@element-plus/build",
"description": "Build Toolchain for Element Plus",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
}
入口文件的结构很简单,以 index.cjs 为例。当 NodeJS 解释执行 index.cjs 时,会根据 esmResolve、alias 等参数加载 element-plus 依赖目录中的 jiti 模块,重写了 NodeJS 加载函数(jiti 的功能),再根据最后一个参数读取到入口文件对应的真实源文件地址执行。不编译而是动态加载带来的好处也很明显,实时修改源码就可以重新执行,节约了“watch+compile”的时间。
module.exports = require("/your-element-plus-in-computer/element-plus/node_modules/.pnpm/jiti@1.21.6/node_modules/jiti/lib/index.js")(null, {
"esmResolve": true,
"interopDefault": true,
"alias": {
"@element-plus/build": "/your-element-plus-in-computer/element-plus/internal/build"
}
})("/your-element-plus-in-computer/element-plus/internal/build/src/index.ts")
所以,stub 模式(插桩模式)就是指在不编译源码的情景中,通过创建 dist 目录下的入口文件,并使用 jiti 动态加载源码的方式来消费子包代码。stub 模式是一种临时的解决办法,如果 EP 的工程化工具链还会更新的话,还需要完善一下生产环境下的打包,见 element-plus-next。
最后,jiti 是 unjs 下的项目,掘金有文章介绍了 unjs 下其他好用的工具,可以围观一下:unjs工具介绍。关于 unbuild,项目作者似乎将精力放在了基于 oxc + rolldown 的新打包工具 obuild 上,见 obuild。
工程环境
package.json 的各个配置作用
鲜为人知的 IDE 配置
pnpm 相关知识
开发模式
“模块化”是如何代码结构体现的?
创建新组件不必手动复制粘贴
项目生态
按需引入插件的工作原理?
在 element-ui 时代,项目一般使用 babel-plugin-component 这个插件来实现按需引入。简单来说,babel-plugin-component 会自动把引入的内容作语法转换,以便正确处理引入的内容。
// 如下面这行代码:
import { Button } from "[libraryName]"
// 最终转化成为以下两行新代码:
const button = require("[libraryName]/lib/button")
require("[libraryName]/lib/[styleLibraryName]/button.css")
在 Vue3 时代则使用 unplugin-vue-components,在自动导入的基础上,实现了按需引入的功能。
unplugin-vue-components 的工作原理和 babel-plugin-component 类似。功能上而言,他会把 vue 模版编译结果中的 resolveComponent 函数替换:
// 如下面结构代码:
function _sfc_render() {
const _component_button = resolveComponent("el-button")
return xxx
}
// 最终转化成为以下结构新代码:
import { ElButton as __unplugin_components_0 } from "element-plus"
import "element-plus/lib/theme-chalk/button.css"
function _sfc_render() {
const _component_button = __unplugin_components_0
return xxx
}
考虑到不同组件库结构、功能特性、历史版本兼容性等不一致因素,不同组件库的 unplugin-vue-components 插件的技术实现细节不一样。代码详见:unplugin-vue-components/element-plus。