从ElementPlus了解如何从头开发一个组件库(一)

2,590 阅读5分钟

最近打算写一个组件库,将平时业务中遇到的有意思的组件积累下来,于是去看了ElementPlus的源码。 我们看一个源码需要明确目的,我们需要从源码中获得什么,比如我希望了解到的是:

  1. 整个库的构建思路和过程;
  2. 组件代码的组织形式;
  3. 一些组件的实现方法;

我们看看目录结构:

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

在这里我先总结一下整个库的构建过程:

  1. 首先我们在packages下写好一个组件
  2. 然后在play下启动一个本地的Vue项目,引用我们写好的组件进行调试
  3. 写单元测试 Jest + vitest
  4. 调试好后进入internal/build,通过gulp+rollup进行打包
  5. 上传代码
    • commitizon规范commit message
    • 通过husky添加git hook,在上传代码前通过prittier + eslint进行格式化,并且检查单元测试
    • 通过github workflows 进行 CI/CD
  6. 通过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个部分

截屏2022-07-22 上午11.24.25.png

按照开发顺序分别是:

  1. 本地服务 dev
  2. 测试 test
  3. 文档 docs
  4. 打包 build
  5. 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了解如何开发一个组件

  1. 从ElementPlus了解如何从头开发一个组件库(一)
  2. 从ElementPlus了解如何开发一个组件库(二)
  3. 使用vitepress开发组件文档