最近打算写一个组件库,将平时业务中遇到的有意思的组件积累下来,于是去看了ElementPlus的源码。 我们看一个源码需要明确目的,我们需要从源码中获得什么,比如我希望了解到的是:
- 整个库的构建思路和过程;
- 组件代码的组织形式;
- 一些组件的实现方法;
我们看看目录结构:
element-plus
.circleci
.github
.husky
.vscode
docs
internal
packages
play
scripts
ssr-testing
typings
.editorconfig
.env
.eslintignore
.eslintrc.json
.gitattributes
.gitignore
.markdownlint.json
.npmrc
.nvmrc
.prettierignore
.prettierrc
babel.config.js
CHANGELOG.en-US.md
CODE_OF_CONDUCT.md
codecov.yml
commit-example.md
commitlint.config.js
CONTRIBUTING.md
DEV_FAQ.md
global.d.ts
jest.config.js
jest.setup.js
LICENSE
LOCAL_DEV.md
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
README.md
tsconfig.jest.json
tsconfig.json
tsconfig.vite-config.json
tsconfig.vitest.json
vitest.config.ts
在这里我先总结一下整个库的构建过程:
- 首先我们在packages下写好一个组件
- 然后在play下启动一个本地的Vue项目,引用我们写好的组件进行调试
- 写单元测试 Jest + vitest
- 调试好后进入internal/build,通过gulp+rollup进行打包
- 上传代码
- commitizon规范commit message
- 通过husky添加git hook,在上传代码前通过prittier + eslint进行格式化,并且检查单元测试
- 通过github workflows 进行 CI/CD
- 通过vitepress写文档,后期可以将构建过程添加到gulp和CI/CD
了解一个库,需要先从package.json文件开始了解。
整个库使用的是pnpm + monorepo的方式,其中docs internal/* packages/* play都是子项目;
// package.json
{
"packageManager": "pnpm@6.32.3",
"workspaces": [
"packages/*",
"play",
"docs"
],
}
// pnpm-workspace.yaml
packages:
- packages/*
- docs
- play
- internal/*
我们只需要管理各个子项目的package文件,然后在根目录通过pnpm进行操作即可,并且pnpm会将子项目重复下载的库提到根目录下的node_modules里,避免重复下载依赖的问题。
我们可以在项目中对各个项目进行引用:
// package.json
{
"dependencies": {
"@element-plus/components": "workspace:*",
"@element-plus/constants": "workspace:*",
"@element-plus/directives": "workspace:*",
"@element-plus/hooks": "workspace:*",
},
"devDependencies": {
"@element-plus/build": "workspace:*",
}
}
// internal/build/package.json
{
"name": "@element-plus/build",
"private": true,
"version": "0.0.1",
}
// play/vite.config.js
import {
epPackage,
epRoot,
getPackageDependencies,
pkgRoot,
projRoot,
} from '@element-plus/build'
我将整个文件的脚本分为5个部分
按照开发顺序分别是:
- 本地服务 dev
- 测试 test
- 文档 docs
- 打包 build
- git hooks
我们从本地服务dev看起,从package.json中可以看到dev脚本其实是进入到play文件夹下执行dev命令。从play的目录结构和play/package.json可以看到,整个play是由vite构建的一个Vue项目。
正如上面所说,play这个项目的主要作用是,我们可以通过引用子项目中的组件,达到调试组件的目的。例如我们可以在play/src文件夹下新建一个Button.vue,然后引入写好的Button组件,通过vite启动项目,输入url localhost:3000/Button就可以看到我们写好的组件了。当然,通过源码中vite.config.ts配置的按需加载,我们无需手动import组件。我们可以从play/main.ts和play/vite.config.ts看看这个本地服务具体做了什么。
// play/main.ts
import { createApp } from 'vue'
import '@element-plus/theme-chalk/src/index.scss'
;(async () => {
const apps = import.meta.glob('./src/*.vue')
const name = location.pathname.replace(/^\//, '') || 'App'
const file = apps[`./src/${name}.vue`]
if (!file) {
location.pathname = 'App'
return
}
const App = (await file()).default
const app = createApp(App)
app.mount('#play')
})()
可以看到,源码中是通过url去匹配src下的文件,如果在src下没有找到对应的文件,那么会打开App.vue(play/vite.init.ts会在一开始在src下创建App.vue文件) 。例如启动vite之后,浏览器打开localhost:3000就会引用App.vue,如果我们把url改成localhost:3000/Button,那么就会引用src/Button.vue文件。
再来看vite.config.js文件的配置,主要需要讲的是resolve和plugins; 其中resolve设置的别名和plugins中的unplugin-vue-components/vite有关,也就是上面说的实现按需引入的插件。
export default defineConfig(async ({ mode }) => {
...
return {
resolve: {
alias: [
{
find: /^element-plus(\/(es|lib))?$/,
replacement: path.resolve(epRoot, 'index.ts'),
},
{
find: /^element-plus\/(es|lib)\/(.*)$/,
replacement: `${pkgRoot}/$2`,
},
],
},
plugins: [
vue(),
esbuildPlugin(),
vueJsx(),
DefineOptions(),
Components({
include: `${__dirname}/**`,
resolvers: ElementPlusResolver({ importStyle: 'sass' }),
dts: false,
}),
mkcert(),
Inspect(),
],
esbuild: {
target: 'chrome64',
},
}
})
别名中element-plus/es || element-plus/lib的引用会指向element-plus/packages/element-plus/index.ts文件。也就是说import ‘element-plus/es’实际上import的是element-plus/packages/element-plus/index.ts文件。
其中plugins中的Componnets插件就是按需加载。unplugin-vue-components/resolvers中有可以直接使用的ElementPlusResolver,如果只是需要把代码跑起来,无需更改,如果需要自己写一个UI库,或者自己DIY代码,则需要自定义一个resolver。
// element-plus/node_modules/.pnpm/unplugin-vue-components@0.21.1_vite@2.9.14/node_modules/unplugin-vue-components/dist/resolvers.js
function ElementPlusResolver(options = {}) {
...
return [
{
type: "component",
resolve: async (name) => {
return resolveComponent(name, await resolveOptions());
}
},
{
type: "directive",
resolve: async (name) => {
return resolveDirective(name, await resolveOptions());
}
}
];
}
// element-plus/node_modules/.pnpm/unplugin-vue-components@0.21.1_vite@2.9.14/node_modules/unplugin-vue-components/dist/resolvers.js
function resolveComponent(name, options) {
if (options.exclude && name.match(options.exclude))
return;
if (!name.match(/^El[A-Z]/))
return;
if (name.match(/^ElIcon.+/)) {
return {
name: name.replace(/^ElIcon/, ""),
from: "@element-plus/icons-vue"
};
}
const partialName = _chunk2GXY7E6Xjs.kebabCase.call(void 0, name.slice(2));
const { version, ssr } = options;
if (compareVersions.compare(version, "1.1.0-beta.1", ">=")) {
return {
name,
from: `element-plus/${ssr ? "lib" : "es"}`,
sideEffects: getSideEffects2(partialName, options)
};
} else if (compareVersions.compare(version, "1.0.2-beta.28", ">=")) {
return {
from: `element-plus/es/el-${partialName}`,
sideEffects: getSideEffectsLegacy(partialName, options)
};
} else {
return {
from: `element-plus/lib/el-${partialName}`,
sideEffects: getSideEffectsLegacy(partialName, options)
};
}
}
可以看到resolveComponent函数最后返回的路径,与别名对应,最终引入的文件是element-plus/packages/element-plus/index.ts文件,并且sideEffects返回的是theme-chalk下对应的scss文件。
packages文件夹是组件的主要实现逻辑,它的目录结构是
packages
components
constants
directives
element-plus
hooks
locale
test-utils
theme-chalk
tokens
utils
packages下的每一个文件夹都是一个子项目,我们先从packages/element-plus/index.ts看起,这个文件是整个组件库的出口文件,它将需要的部分(例如每个组件和指令等)按模块导出,默认导出的是一个带install的对象,我们知道,Vue使用插件,插件的格式,要么是带install的对象,要么是函数,然后会将实例对象作为参数传入install方法并执行。
// element-plus/packages/element-plus/make-installer.ts
export const makeInstaller = (components: Plugin[] = []) => {
const install = (app: App, options?: ConfigProviderContext) => {
if (app[INSTALLED_KEY]) return
app[INSTALLED_KEY] = true
components.forEach((c) => app.use(c))
if (options) provideGlobalConfig(options, app, true)
}
return {
version,
install,
}
}
也就是说,我们在Vue中使用app.use(ElementPlus)实际上是执行的上面的install方法。除了缓存判断和合并opton外,主要的逻辑就是将传进来的Components循环使用app.use,在这里传进来的Components就是所有的Components对象。每个Component对象都通过withInstall方法挂载了一个install方法:
// element-plus/packages/utils/vue/install.ts
export const withInstall = <T, E extends Record<string, any>>(
main: T,
extra?: E
) => {
;(main as SFCWithInstall<T>).install = (app): void => {
for (const comp of [main, ...Object.values(extra ?? {})]) {
app.component(comp.name, comp)
}
}
if (extra) {
for (const [key, comp] of Object.entries(extra)) {
;(main as any)[key] = comp
}
}
return main as SFCWithInstall<T> & E
}
该方法最终会使用Vue实例的component方法注册一个组件。
以上就总结完了在本地服务下使用组件库的过程,下一节将会讲讲gulp+rollup的打包过程。
从Element-plus了解如何开发一个组件