likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 57

上一节讲了如何在本地环境调试写好的组件库内容。

这一节讲讲打包。

该库使用gulp+rollup来构建打包。gulp可以使用并联或者串联的方式来构建工作流;rollup负责打包。

首先从打包脚本看起 "build": "pnpm -C internal/build run start", 通过pnpm run build实际上是进入到子项目internal/build中执行start脚本,我们再具体看看/internal/build/package.json文件

{
  "name": "@element-plus/build",
  "private": true,
  "version": "0.0.1",
  "description": "Build Toolchain for Element Plus",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "scripts": {
    "start": "gulp --require sucrase/register/ts -f gulpfile.ts",
    "dev": "pnpm run stub",
    "stub": "unbuild --stub"
  },
  "dependencies": {
    "@pnpm/find-workspace-packages": "^4.0.0",
    "@rollup/plugin-commonjs": "^21.0.3",
    "@rollup/plugin-node-resolve": "^13.1.3",
    "@vitejs/plugin-vue": "^2.3.1",
    "@vitejs/plugin-vue-jsx": "^1.3.9",
    "chalk": "^4.1.2",
    "components-helper": "^2.0.0",
    "consola": "^2.15.3",
    "esbuild": "^0.14.29",
    "fast-glob": "^3.2.11",
    "fs-extra": "^10.0.1",
    "gulp": "^4.0.2",
    "lodash": "^4.17.21",
    "rollup": "^2.70.1",
    "rollup-plugin-esbuild": "^4.8.2",
    "ts-morph": "^14.0.0",
    "unplugin-vue-define-options": "^0.6.0",
    "vue": "^3.2.31"
  },
  "devDependencies": {
    "@pnpm/types": "^8.0.0",
    "unbuild": "^0.7.2"
  }
}

在看具体的start脚本之前,我们先看看声明的入口文件"main": "./dist/index.cjs", 刚把代码down下来的时候,dist这个文件夹是不存在的,那它是怎么来的呢?我们回到根目录下的package.json文件中可以看到下面的脚本: "postinstall": "pnpm gen:version && pnpm stub", "stub": "pnpm run -C internal/build stub",

这个脚本会在我们下载依赖(pnpm install)之后执行。也就是说在我们打包之前,会先执行internal/build项目的stub脚本:"stub": "unbuild --stub"

如果是自己手动从头开始创建一个组件库,并且借鉴了ElementPlus的目录结构和一些代码的小伙伴一定不要忘了添加这个脚本、或者自己写个index.ts入口文件,否则在引用项目@element-plus/build的时候将不会有任何导出。

这个脚本会根据项目目录下的build.config.ts配置文件去生成入口文件。

// element-plus-dev/internal/build/build.config.ts
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  entries: ['src/index'],
  clean: true,
  declaration: true,
  rollup: {
    emitCJS: true,
  },
})

上面配置文件的意思是:根据src/index去生成esm模块和cjs模块的入口文件以及声明文件。clean应该是更新文件前先删除旧文件;declaration是生成声明文件(我试了一下,注释掉也一样会生成声明文件);emitCJS是生成cjs文件。对这个配置感兴趣的可以去看看unbuild。下面是unbuild的Options

export interface BuildOptions {
  rootDir: string
  entries: BuildEntry[],
  clean: boolean
  declaration?: boolean
  outDir: string
  stub: boolean
  externals: string[]
  dependencies: string[]
  peerDependencies: string[]
  devDependencies: string[]
  alias: { [find: string]: string },
  replace: { [find: string]: string },
  rollup: RollupBuildOptions
}

紧接着我们继续看start脚本 "start": "gulp --require sucrase/register/ts -f gulpfile.ts",

其中-f --gulpfile 指定gulpfile文件;因为gulp只能解析js文件,因此需要sucrase/register/ts将gulpfile.ts解析成js文件。==注意,如果你的gulpfile.ts文件解析失败,那么运行的时候可能就会提示你找不到gulpfile文件==。

接下来看看整个打包流程

export default series(
  withTaskName('clean', () => run('pnpm run clean')), // 删除dist文件夹
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })), // 递归生成dist/element-plus

  parallel(
    runTask('buildModules'), // 单个组件打包 esm模块和cjs模块
    runTask('buildFullBundle'), // 合并打包 esm模块和umd模块
    runTask('generateTypesDefinitions'), // 生成对应的类型声明文件并放到dist/type文件夹下
    runTask('buildHelper'), // 生成vetur插件和webstorm的帮助文档
    series( // 将所有scss文件编译成css文件,并通过文件流放到合适的地方
      withTaskName('buildThemeChalk', () =>
        run('pnpm run --filter ./packages/** build --parallel')
      ),
      copyFullStyle // 将dist/element-plus/theme-chalk/index.css拷贝到dist/element-plus/dist/index.css
    )
  ),
  // 将dist/type下的声明文件分别拷贝到esm、cjs模块文件夹下
  // 将根目录下global.d.ts、README.md、packages/element-plus/package.json拷贝到dist/element-plus下
  parallel(copyTypesDefinitions, copyFiles)
)

上面代码注释部分我已经简要说明了每一个任务做的事,接下来我们逐一往下看。

最开始的clean和createOutput就不多介绍了,就如注释所描述的那样。

parallel函数包裹,表示里面的任务是并发的。一开始runTask('buildModules')

import { run } from './process'
export const runTask = (name: string) =>
  withTaskName(`shellTask:${name}`, () =>
    run(`pnpm run start ${name}`, buildRoot)
  )

runTask函数注册了任务的执行函数,实际上是执行pnpm run start的脚本,并且使用形参传递的任务的名字。

gulp --require sucrase/register/ts -f gulpfile.ts "buildModules

我们很容易忽视gulpfile.ts文件里的最后一段代码:export * from './src' ,我们可以试一试,将这段代码注释掉,再执行runTask('buildModules')是不成功的,这个就要说到gulp的私有任务和公有任务了。 只有公有任务可以被gulp命令直接调用,而注册公有任务的方式就是在gulpfile文件中export导出。 因此export * from './src'绝对不能漏。

buildModules

我们进入element-plus-dev/internal/build/src/tasks/modules.ts查看buildModules任务的具体打包过程:

// element-plus-dev/internal/build/src/tasks/modules.ts
export const buildModules = async () => {
  const input = excludeFiles(
    await glob('**/*.{js,ts,vue}', {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )
  const bundle = await rollup({
    input,
    plugins: [
      ElementPlusAlias(), // 将引用的@element-plus/*替换成element-plus/*(因为生产)
      DefineOptions(), // Vue3 defineOptions
      vue({
        isProduction: false,
      }),
      vueJsx(),
      // 帮助rollup在node_module中查找模块 比如import 'lodash/index' => import 'lodash/index.ts'
      nodeResolve({
        extensions: ['.mjs', '.js', '.json', '.ts'], // 解析顺序
      }),
      commonjs(), // rollup本身是不支持解析CommonJs模块的,需要将CommonJs转换成ES6模块
      esbuild({ // 使用esbuild打包,加快打包速度
        sourceMap: true,
        target,
        loaders: {
          '.vue': 'ts',
        },
      }),
    ],
    external: await generateExternal({ full: false }), // 排除第三方的包,不要将第三方的代码打进包里
    treeshake: false,
  })
  await writeBundles(
    bundle,
    // 打成esm模块和cjs模块
    buildConfigEntries.map(([module, config]): OutputOptions => {
      return {
        // amd, cjs, es, iife, umd, system
        format: config.format,
        // 打包到指定目录
        dir: config.output.path,
        // 导出方式,仅针对cjs
        exports: module === 'cjs' ? 'named' : undefined,
        // 保持原有目录结构
        preserveModules: true,
        // 将原有结构这个目录下的文件放到根目录dir下
        preserveModulesRoot: epRoot,
        // 是否生成sourceMap
        sourcemap: true,
        // 文件名
        entryFileNames: `[name].${config.ext}`,
      }
    })
  )
}

每一项配置具体的作用已经在代码中有所注释。简单的再缕一下:

  1. 首先需要打包的文件是element-plus/packages/下所有的js,ts,vue文件。
  2. rollup会解析这些input文件里的import导入,并通过plugins进行处理。
  • ElementPlusAlias插件将monorepo项目引用的@element-plus/xxx全部转化成正常项目引用element-plus/xxx;
  • vue解析,vueJsx解析,DefineOptions
  • nodeResolve 将文件导入按Node算法处理 Node resolution algorithm 例如'lodash/index' => import 'lodash/index.ts'
  • 将commonJs转成es6
  • 使用esbuild进行打包
  1. external 通过解析element-plus-dev/packages/element-plus/package.json的dependencies和peerDependencies,将第三方库从打包文件中移除。
export const generateExternal = async (options: { full: boolean }) => {
  const { dependencies, peerDependencies } = getPackageDependencies(epPackage)

  return (id: string) => {
    const packages: string[] = peerDependencies
    if (!options.full) {
      packages.push('@vue', ...dependencies)
    }

    return [...new Set(packages)].some(
      (pkg) => id === pkg || id.startsWith(`${pkg}/`)
    )
  }
}
  1. 输出 writeBundles(bundle, buildConfigEntries)
// element-plus-dev/internal/build/src/utils/rollup.ts
export function writeBundles(bundle: RollupBuild, options: OutputOptions[]) {
  return Promise.all(options.map((option) => bundle.write(option)))
}

// element-plus-dev/internal/build/src/build-info.ts
export const buildConfig: Record<Module, BuildInfo> = {
  esm: {
    module: 'ESNext',
    format: 'esm',
    ext: 'mjs',
    output: {
      name: 'es',
      path: path.resolve(epOutput, 'es'),
    },
    bundle: {
      path: `${EP_PKG}/es`,
    },
  },
  cjs: {
    module: 'CommonJS',
    format: 'cjs',
    ext: 'js',
    output: {
      name: 'lib',
      path: path.resolve(epOutput, 'lib'),
    },
    bundle: {
      path: `${EP_PKG}/lib`,
    },
  },
}
export const buildConfigEntries = Object.entries(
  buildConfig
) as BuildConfigEntries

这里分别输出两种格式的包,分别是esm模块和cjs模块。

具体的OutputOptions的说明注释上已经写了,可能exports选项需要再深入一点说明: 这是专门给cjs模块使用的,output.exports有四个值:auto、default、named、none(没有导出);如果input module仅使用export default,那么output.exports可以选default;如果input module使用具名导出 export const hellow = ‘world’,那么output.exports需要选named;output.exports: 'named' 后,可以接收具名变量 const { hello } = require('your-lib');

至此,真正意义上的第一个Task buildModules就完成了。总的来说这个Task做的就是将element-plus/packages里的js ts vue文件,保持原有目录结构(preserveModulesRoot目标目录提到根目录),分别打成esm模块和cjs模块,放到dist/element-plus/下,其中es文件夹下存放esm模块代码,lib文件夹下存放cjs模块代码。

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

buildFullBundle

export const buildFullBundle = parallel(
  withTaskName('buildFullMinified', buildFull(true)),
  withTaskName('buildFull', buildFull(false))
)

export const buildFull = (minify: boolean) => async () =>
  Promise.all([buildFullEntry(minify), buildFullLocale(minify)])

buildFullBundle并发的执行两个任务,执行的函数都是buildFull,从参数minify可以看出,表达的意思是是否压缩,所以buildFullBundle是并发生成压缩和不压缩的包。

buildFull执行两个函数:buildFullEntry和buildFullLocale

async function buildFullEntry(minify: boolean) {
  const bundle = await rollup({
    input: path.resolve(epRoot, 'index.ts'),
    plugins: [
      ElementPlusAlias(),
      DefineOptions(),
      vue({
        isProduction: true,
      }),
      vueJsx(),
      nodeResolve({
        extensions: ['.mjs', '.js', '.json', '.ts'],
      }),
      commonjs(),
      esbuild({
        exclude: [],
        minify,
        sourceMap: minify,
        target,
        loaders: {
          '.vue': 'ts',
        },
        define: {
          'process.env.NODE_ENV': JSON.stringify('production'),
        },
      }),
    ],
    external: await generateExternal({ full: true }),
  })
  await writeBundles(bundle, [
    {
      format: 'umd',
      file: path.resolve(
        epOutput,
        'dist',
        formatBundleFilename('index.full', minify, 'js')
      ),
      exports: 'named',
      // Necessary for iife/umd bundles
      // it is the global variable name representing your bundle
      // 引用后的全局变量 相当于引用后可以用ElementPlus访问
      name: 'ElementPlus',
      // necessary for external imports in umd/iife bundles
      // 告诉rollup vue是第三方的模块,而且这个模块的id等同于全局变量Vue
      // globals: {
      //   jquery: '$'
      // }
      globals: {
        vue: 'Vue',
      },
      sourcemap: minify,
      banner,
    },
    {
      format: 'esm',
      file: path.resolve(
        epOutput,
        'dist',
        formatBundleFilename('index.full', minify, 'mjs')
      ),
      sourcemap: minify,
      banner,
    },
  ])
}

相比于buildModules的inputOption和outputOption,buildFullEntry有以下几个不太一样的配置:

inputOption:

  1. input文件仅有element-plus-dev/packages/element-plus/index.ts。正如我在第一章时说到的,element-plus-dev/packages/element-plus/index.ts是整个库的入口文件,所以buildFullEntry仅仅将入口文件和入口文件需要import的文件进行打包即可。
  2. @vitejs/plugin-vue插件设置参数isProduction为true,设置该参数具体做了哪些操作,感兴趣的小伙伴可以去了解一下,想来应该是减少了一些体积。
  3. esbuild打包,根据传递的参数决定是否压缩
  4. external,不剔除@vue/开头的工具包,之前单个模块打包时,会把@vue/工具包也一并剔除。

outputOption

  1. 打两种类型的包,一个umd,一个esm
  2. iife和umd类型的包需要name选项,标识引入后的模块id,也就是require('element-plus')后可以通过全局变量ElementPlus访问。
  3. globals:告诉rollup vue是第三方的模块,而且这个模块的id等同于全局变量Vue
  4. banner:在打包文件的最上方标识
async function buildFullLocale(minify: boolean) {
  const files = await glob(`${path.resolve(localeRoot, 'lang')}/*.ts`, {
    absolute: true,
  })
  return Promise.all(
    files.map(async (file) => {
      const filename = path.basename(file, '.ts')
      // 连字符 => 驼峰 + 首字母大写
      const name = upperFirst(camelCase(filename))

      const bundle = await rollup({
        input: file,
        plugins: [
          esbuild({
            minify,
            sourceMap: minify,
            target,
          }),
        ],
      })
      await writeBundles(bundle, [
        {
          format: 'umd',
          file: path.resolve(
            epOutput,
            'dist/locale',
            formatBundleFilename(filename, minify, 'js')
          ),
          exports: 'default',
          name: `ElementPlusLocale${name}`,
          sourcemap: minify,
          banner,
        },
        {
          format: 'esm',
          file: path.resolve(
            epOutput,
            'dist/locale',
            formatBundleFilename(filename, minify, 'mjs')
          ),
          sourcemap: minify,
          banner,
        },
      ])
    })
  )
}

buildFullLocale具体的选项就不多说了,都已经在上面解释过,这个方法的作用是,将element-plus-dev/packages/locale/lang/*.ts的语言文件分别打包成umd模块和esm模块,放在element-plus-dev/dist/element-plus/dist/locale/目录下。

总的来说,buildFullBundle的任务是将element-plus/packages/element-plus/index.ts通过rollup打成umd和esm两种类型的包放在element-plus-dev/dist/element-plus/dist/目录下并且将语言文件打包。之所以将@vue/工具函数一同打成umd包,是为了给unpkgjsdelivr使用。

// element-plus-dev/packages/element-plus/package.json
  "main": "lib/index.js",
  "module": "es/index.mjs",
  "style": "dist/index.css",
  "unpkg": "dist/index.full.js",
  "jsdelivr": "dist/index.full.js",

generateTypesDefinitions

generateTypesDefinitions任务,根据element-plus/packages/下的文件生成对应的类型声明文件,并输出到element-plus-dev/dist/types/下。这部分内容就暂时不说了,后面如果有需要再单独讲生成类型声明文件这一块。

buildHelper

buildHelper的细节也不具体介绍了,这个任务的作用是在dist/element-plus/下面生成attribute.json、tags.json、web-types.json三个文件。这三个文件是通过解析element-plus-dev/docs/en-US/component/下的每一个组件的markdown文件生成的。介绍这三个文件的作用之前,首先看一下下面的一段话:

blog.jetbrains.com/webstorm/20… when developing a Vue application, I often had to switch back and forth between my editor and the online library documentation. I wasted a lot of time tracking down information, which was simply frustrating. What is web-types? Web-types is an open source standard for documenting various web frameworks. In its first iteration, it’s focused only on Vue support. The documentation consists of a single JSON file. The good news is that it’s already supported by some major Vue libraries – you can get detailed documentation for bootstrap-vue, quasar, vuetify, nuxt.js, and @ionic/vue while coding in WebStorm.

意思是说,这老哥用WebStorm写页面的时候需要来回的切换ide和UI库的官网,然后老哥觉得这很不程序员。于是就有了web-types.json这个东西,只要在package.json配置了"web-types": "web-types.json",,WebStorm就会去相应目录寻找这个文件并进行解析,最后达到的效果就是下面这张图,这样就不需要切换到官网看文档啦。

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

同理,attribute.json、tags.json做的也是相同的工作,不过这两个文件是给vetur插件做的,需要配置:

"vetur": {
    "tags": "tags.json",
    "attributes": "attributes.json"
  },

buildThemeChalk

这个任务是并发执行element-plus/packages/下的所有子项目的build脚本,就我目前的这个版本,只是有element-plus/packages/theme-chalk/有build脚本 "build": "gulp --require sucrase/register/ts"。加载目录下的gulpfile文件

function buildThemeChalk() {
  const sass = gulpSass(dartSass)
  const noElPrefixFile = /(index|base|display)/
  // autoprefixer插件作用
  // ::placeholder {
  //   color: gray;
  // }
  // 加前缀后
  // ::-moz-placeholder {
  //   color: gray;
  // }
  // :-ms-input-placeholder {
  //   color: gray;
  // }
  // ::placeholder {
  //   color: gray;
  // }
  return src(path.resolve(__dirname, 'src/*.scss'))
    .pipe(sass.sync())
    .pipe(autoprefixer({ cascade: false })) // 给css加前缀
    .pipe(
      cleanCSS({}, (details) => { // 压缩
        consola.success(
          `${chalk.cyan(details.name)}: ${chalk.yellow(
            details.stats.originalSize / 1000 // 原始css文件大小
          )} KB -> ${chalk.green(details.stats.minifiedSize / 1000)} KB` // 压缩后css文件大小
        )
      })
    )
    .pipe(
      rename((path) => {
        // 不是/(index|base|display)/的文件名 + 前缀 el-
        if (!noElPrefixFile.test(path.basename)) { 
          path.basename = `el-${path.basename}`
        }
      })
    )
    .pipe(dest(distFolder))
}

export function copyThemeChalkBundle() {
  return src(`${distFolder}/**`).pipe(dest(distBundle))
}

export function copyThemeChalkSource() {
  return src(path.resolve(__dirname, 'src/**')).pipe(
    dest(path.resolve(distBundle, 'src'))
  )
}

export const build = parallel(
  // 将./src/*的scss文件复制到 dist/element-plus/theme-chalk/src/*
  copyThemeChalkSource, 
  // 1. ./dist/下生成css文件 
  // 2. 将./dist/下的css文件拷贝到dist/element-plus/theme-chalk/*
  series(buildThemeChalk, copyThemeChalkBundle) 
)

export default build

具体的内容注释已经写清楚了。这个任务的作用是:

  1. 将./src/的scss文件复制到 dist/element-plus/theme-chalk/src/
  2. 将scss转成css放到当前目录下的dist目录
  3. 将当前目录dist下的css再拷贝到dist/element-plus/theme-chalk/*

copyFullStyle

将dist/element-plus/theme-chalk/index.css拷贝到dist/element-plus/dist/

copyTypesDefinitions

将dist/types/下的类型声明文件按目录结构拷贝分别拷贝到dist/element-plus/es和dist/element-plus/lib下

copyFiles

将element-plus/packages/element-plus/package.json、element-plus/README.md、element-plus/global.d.ts拷贝到element-plus-dev/dist/element-plus

至此,整个打包流程就介绍完了,可以看到,完成一个简单的打包,通常我们只需要buildModules任务和buildThemeChalk任务,也就是模块化打包和将scss打成css。如果项目需要支持unpkg和jsdelivr,那么就添加buildFullBundle任务打个umd的包。如果要上typescript,再添加generateTypesDefinitions任务。如果要给ide添加文档提示,再添加buildHelper任务。

从Element-plus了解如何开发一个组件

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