手写 Element Plus : 搭建 Element Plus 组件打包配置(三)

517 阅读9分钟

一、前言

本篇文章将详细介绍 Element Plus 中的模块打包类型打包组件样式打包。由于上一篇介绍了 rollup 和 gulp 在Element Plus 中的应用手写 Element Plus : 搭建 Element Plus 组件打包配置(二)这里就不在详细的介绍了。这篇文章主要介绍打包的核心流程。搞懂这篇文章,我相信你应该对 Element Plus 打包流程有个清晰的认识了,可以去构建自己搭建的组件库了。最好按顺序来看先看完第一第二篇文章再来看这个可能更容易理解。

二、模块打包的具体流程

一)、module.ts 中导入的函数介绍

excludeFiles函数用来过滤文件路径数组,排除掉指定的目录和文件类型 。

export const excludeFiles = (files: string[]) => {
  const excludes = ['node_modules', 'test', 'mock', 'gulpfile', 'dist']
  return files.filter((path) => {
    const position = path.startsWith(projRoot) ? projRoot.length : 0
    return !excludes.some((exclude) => path.includes(exclude, position))
  })
}

position 计算出如果 file 路径中包含 projRoot 则记录这个 projRoot 的长度否则记为0,这样做的目的是计算 file 文件是否包含排除 excludes 数组里的字符,且 path 从position 开始计算。

假设项目根路径 projRoot/project,输入的文件路径数组如下:

const files = [
  '/project/src/index.ts',
  '/project/node_modules/some-package/index.js',
  '/project/test/unit-test.js',
  '/project/mock/data.json',
  '/project/dist/bundle.js',
  '/project/src/gulpfile.ts'
]

const projRoot = '/project'
const filteredFiles = excludeFiles(files)

//path.include(exclude,position)  ===>
//path 将从'/node_modules/some-package/index.js' 排除
//而不是从 '/project/node_modules/some-package/index.js'排除

经过 excludeFiles 函数过滤后,结果将会是:

[  '/project/src/index.ts']

二)、module.ts 中 Rollup 主要打包配置

1、输入文件配置

1)、input 入口文件
 const input = excludeFiles(
    await glob('**/*.{js,vue,ts}', {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )

input 获得是 pkgRoot 目录下所有的除了 excludeFiles 函数排除的文件路径后缀是 js 、vue 、ts 的文件路径数组。

2)、plugins Rollup 插件
 plugins: [
      VueMacros({
        setupComponent: false,
        setupSFC: false,
        plugins: {
         vue: vue({
            isProduction: true,
            template: {
              compilerOptions: {
                hoistStatic: false,
                cacheHandlers: false,
              },
            },
          }),
          vueJsx: vueJsx(),
        },
      }),
      nodeResolve({
        extensions: ['.mjs', '.js', '.json', '.ts'],
      }),
      commonjs(),
      esbuild({
        sourceMap: true,
        target,
        loaders: {
          '.vue': 'ts',
        },
      }),
    ],

模块打包的 rollup 插件配置和主文件打包的插件配置差不多。


两者配置的主要不同是什么原因?

主文件打包需要针对线上环境做出一些优化处理,而模块打包是在开发环境构建其设计目的是为了保留开发模块中的调试信息 ,压缩代码会使得错误堆栈信息丧失很多有用的信息,因此开发环境通常会保留完整的代码和注释 ,而不是为了生产环境的性能和文件大小优化 。所以去除了 esbuild 中的部分优化配置。

3)、 external 打包排除文件
external: await generateExternal({ full: false }),

实现排除外部依赖打包,这个实现逻辑在上一篇已经解释过了,在这里就不在解释了。

4)、treeshake
treeshake:false

不开启按需打包优化。 在调试时,关闭 tree-shaking 可以保留所有导入的代码,有助于排查问题或调试依赖项的完整性。

2、输出文件配置

export const buildConfig: Record<Module, BuildInfo> = {
  esm: {
    module: 'ESNext',
    format: 'esm',
    ext: 'mjs',
    output: {
      name: 'es',
      path: path.resolve(epOutput, 'es'),
    },
    bundle: {
      path: `${PKG_NAME}/es`,
    },
  },
  cjs: {
    module: 'CommonJS',
    format: 'cjs',
    ext: 'js',
    output: {
      name: 'lib',
      path: path.resolve(epOutput, 'lib'),
    },
    bundle: {
      path: `${PKG_NAME}/lib`,
    },
  },
}
export const buildConfigEntries = Object.entries(
  buildConfig
) as BuildConfigEntries
 await writeBundles(
    bundle,
    buildConfigEntries.map(([module, config]): OutputOptions => {
      return {
        format: config.format,
        dir: config.output.path,
        exports: module === 'cjs' ? 'named' : undefined,
        preserveModules: true,
        preserveModulesRoot: epRoot,
        sourcemap: true,
        entryFileNames: `[name].${config.ext}`,
      }
    })
  )
1)、format

模块打包会打包两种格式的文件,一种打包后的文件支持 esm一种是支持 Node 环境下 CommonJS。这样可以兼容到多种环境下引用打包后的文件。

2)、perserveModules

preserveModules:true ,是为了在打包指定目标文件按照原来的目录结构进行打包,这样做是为了保留原来的模块化,方便使用者来按需导入所需要的模块而不需要导入整个模块

3)、perserveModulesRoot

preserveModulesRoot:epRoout ,preserveModulesRoot 用于指定保留的模块根目录。如果启用了 preserveModulespreserveModulesRoot 决定 Rollup 输出时保留的目录层级,从指定的目录开始输出结构。避免输出多层嵌套的目录。

这个 epRoot 为 fz-mini 所以在输出打包文件没有将这个fz-mini保留而是直接将这个目录下的文件打包输出在指定目录。

4)、entryFileNames

entryFileNames 自定义输出文件的命名格式。 [name] 是占位符,代表文件的原始名称。设置为 [name].${config.ext} 会输出文件名为原名称加上扩展名(如 .js.mjs)。

3、配置 gulp.ts 全局文件

export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),

  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
)

internal\build执行打包命令 pnpm run start 打包命令出现这个就已经成功了,在检查一下输出的目录是否一致。

三、类型打包具体流程

一)、初始化配置

PS:fz-mini-ui>pnpm i vue-tsc @types/fs-extra -D -w
PS:fz-mini-ui\internal\build>pnpm i fs-extra fast-glob -D

在项目中安装这几个依赖,在类型打包中将会使用到这些工具库。

vue-tsc 是一个 TypeScript 编译器的 CLI 工具,专门为 Vue 项目生成类型声明文件。它基于 TypeScript 官方编译器 (tsc) 进行扩展,能够识别 .vue 文件中的 TypeScript 代码,从而为 Vue 组件生成相应的 .d.ts 声明文件。

在项目跟目录下创建 tsconfig.web.json 文件。

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "jsx": "preserve",
    "lib": ["ES2018", "DOM", "DOM.Iterable"],
    "types": ["unplugin-vue-macros/macros-global"],
    "skipLibCheck": true
  },
  "include": ["packages", "typings/env.d.ts"],
  "exclude": [
    "node_modules",
    "**/dist",
    "**/__tests__/**/*",
    "**/gulpfile.ts",
    "**/test-helper",
    "packages/test-utils",
    "**/*.md"
  ]
}

引入 tsconfig.web.json 是为了控制 .d.ts 类型文件的生成方式,使其适配 web 环境的构建需求 。在类型打包配置中补充额外的类型文件生成方式。

declare global {
  const process: {
    env: {
      NODE_ENV: string
    }
  }

  namespace JSX {
    interface IntrinsicAttributes {
      class?: unknown
      style?: unknown
    }
  }
}

为项目定义全局类型,以确保在项目的任何地方使用这些类型时,TypeScript 能够正确识别。

二)、类型打包配置

import path from 'path'
import { readFile, writeFile } from 'fs/promises'
import glob from 'fast-glob'
import { copy, remove } from 'fs-extra'
import { buildOutput } from '@element-plus/build-utils'
import { pathRewriter, run } from '../utils'

export const generateTypesDefinitions = async () => {
  await run(
    'npx vue-tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --declarationDir dist/types'
  )
  const typesDir = path.join(buildOutput, 'types', 'packages')
  const filePaths = await glob(`**/*.d.ts`, {
    cwd: typesDir,
    absolute: true,
  })
  const rewriteTasks = filePaths.map(async (filePath) => {
    const content = await readFile(filePath, 'utf8')
    await writeFile(filePath, pathRewriter('esm')(content), 'utf8')
  })
  await Promise.all(rewriteTasks)
  const sourceDir = path.join(typesDir, 'element-plus')
  await copy(sourceDir, typesDir)
  await remove(sourceDir)
}

下面我将尽可能的给你讲述类型打包的具体逻辑。

await run(
  'npx vue-tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --declarationDir dist/types'
)
1. 生成 TypeScript 声明文件

使用 vue-tsc 命令生成项目的类型声明文件(.d.ts)。

  • -p tsconfig.web.json:指定配置文件 tsconfig.web.json
  • --declaration:生成类型声明。
  • --emitDeclarationOnly:只输出声明文件,而不生成其他 JavaScript 文件。
  • --declarationDir dist/types:将所有声明文件放在 dist/types 目录下。

执行完这一行命令后,dist/types 目录下将会生成所有组件和模块的 .d.ts 文件。

vue-tsc 如何识别指定文件中的 ts vue文件?

vue-tsc 会根据项目的 tsconfig.json 或指定的配置文件(如 tsconfig.web.json)读取编译选项。这个配置文件控制着哪些 .ts 文件会被编译,以及如何生成类型声明。

vue-tsc 会识别项目中的 .vue 文件,解析其中的 <script setup><script> 代码块(包括 TypeScript 代码),并将它们转换为 .d.ts 类型文件。它会提取和分析 .vue 文件中的 propsemitcomputedref 等内容,自动生成相应的类型。

2. 获取文件路径
const typesDir = path.join(buildOutput, 'types', 'packages')
const filePaths = await glob(`**/*.d.ts`, {
  cwd: typesDir,
  absolute: true,
})

filePaths:使用 glob 查找 dist\types\packages 目录中所有的 .d.ts 文件路径。

  • **/*.d.ts 匹配所有子目录中的 .d.ts 文件。
  • cwd: typesDir 设置根目录。
  • absolute: true 生成绝对路径,便于后续操作。
3. 重写模块路径
const rewriteTasks = filePaths.map(async (filePath) => {
  const content = await readFile(filePath, 'utf8')
  await writeFile(filePath, pathRewriter('esm')(content), 'utf8')
})
await Promise.all(rewriteTasks)

rewriteTasks:遍历所有文件路径,对 .d.ts 文件进行内容读取和路径重写。

  • 使用 pathRewriter('esm') 对内容中的模块路径进行重写,将路径改为适配 esm 格式的输出路径。

Promise.all(rewriteTasks) :并行执行所有重写任务,优化速度。

为什么需要重写模块路径?

假设我们有一个 Vue 组件库项目 element-plus,其中包含以下目录结构:

element-plus/
|—— packages/
│   ├── components/
│   │   ├── Button.ts
│   │   └── Input.ts
│   └── utils/
│       └── helpers.ts
└── fz-mini/
    ├── es/
    │   ├── components/
    │   │   ├── Button.d.ts
    │   │   └── Input.d.ts
    │   └── utils/
    │       └── helpers.d.ts
    └── lib/
        ├── components/
        │   ├── Button.d.ts
        │   └── Input.d.ts
        └── utils/
            └── helpers.d.ts

在生成 .d.ts 文件时,Button.d.ts 文件会被输出到 dst/es/components 目录中,并可能包含以下内容:

// fz-mini/es/components/Button.d.ts
import { helperFunction } from '../utils/helpers'
export declare const Button: () => void

路径 ../utils/helpers 是基于原始源代码的路径结构。但是,打包后,输出路径已经改变。 为确保 .d.ts 文件中引用的路径能与实际的输出结构保持一致,必须重写路径。

pathRewriter('esm') 函数中,我们可以将模块路径(如 ../utils/helpers)改写为适配 esm 构建的路径。pathRewriter('esm') 可以将引用路径改写为 fz-mini/es/utils/helpers

4. 复制和删除 fz-mini 目录
 const sourceDir = path.join(typesDir, 'fz-mini')
  await copy(sourceDir, typesDir)
  await remove(sourceDir)

sourceDir:拼接路径得到 fz-mini 目录(此目录可能包含额外的封装层级),位于 typesDir 下。

copy(sourceDir, typesDir) :将 sourceDir 目录内容复制到 typesDir 的父目录,以消除冗余层级。

假设类型定义文件生成后的目录结构为:

dist/
└── types/
    └── element-plus/
        ├── components/
        │   └── Button.d.ts
        └── utils/
            └── helpers.d.ts

执行这段代码后,目录结构将变成:

dist/
└── types/
    ├── components/
    │   └── Button.d.ts
    └── utils/
        └── helpers.d.ts

这样一来,所有类型文件不再嵌套在 element-plus 文件夹下,开发者在使用类型文件时,可以直接通过较短的路径引用这些文件。

remove(sourceDir) :删除原 element-plus 目录。

三)、配置 gulp.ts 文件

export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),

  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
    runTask('generateTypesDefinitions'),
  ),
)

export * from './src'
export const copyFiles = () =>
  Promise.all([
    copyFile(epPackage, path.join(epOutput, 'package.json')),
    copyFile(
      path.resolve(projRoot, 'README.md'),
      path.resolve(epOutput, 'README.md')
    ),
    copyFile(
      path.resolve(projRoot, 'typings', 'global.d.ts'),
      path.resolve(epOutput, 'global.d.ts')
    ),
  ])

copyFiles函数是将 从 epPackage 路径文件复制到 epOutput 目录下的 package.json ;将跟目录下的 README.md 复制到 epOutput 变量目录下;将跟目录下的 tyings\global.d.ts 复制到 epOutput目录中的 global.d.ts 文件。

export const copyTypesDefinitions: TaskFunction = (done) => {
  const src = path.resolve(buildOutput, 'types', 'packages')
  const copyTypes = (module: Module) =>
    withTaskName(`copyTypes:${module}`, () =>
      copy(src, buildConfig[module].output.path, { recursive: true })
    )

  return parallel(copyTypes('esm'), copyTypes('cjs'))(done)
}

将生成的dist/types/packages下的类型文件每个类型文件递归复制到 eslib文件下原来文件结构如图所示。

复制后:

复制前

export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),

  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
    runTask('generateTypesDefinitions'),
  ),

  parallel(copyTypesDefinitions, copyFiles)
)

export * from './src'

执行命令后会自动将这些任务执行打包,出现下面就说明打包成功。

四、样式打包具体流程

一)、初始化配置

1、安装打包样式依赖

PS:fz-mini-ui\packages\theme-chalk>pnpm i @esbuild-kit/cjs-loader @types/gulp-autoprefixer @fz-mini/build @types/gulp-rename @types/gulp-sass cssnano gulp-autoprefixer gulp-rename gulp-sass postcss -D 

2、初始化 Icon 组件样式文件

1)、初始化 theme-chalk/src/base.scss
@use 'icon.scss';
// @use 'var.scss';

base.scss 主要负责设置 Element Plus 中的基础样式,如通用的颜色、字体、排版、过渡效果等,通常是在整个项目中使用的共享变量 。icon.scss定义了与图标相关的样式和配置,因为其他的组件中也会使用到 icon组件所以它放在整个组件库的共享变量一起。

:root {
  --el-color-primary: #409eff;
  --el-font-size-base: 14px;
  --el-transition-duration: 0.3s;
}

这个var.scss文件就是将组件库的所有共享变量注册在root中。上面的只是一个简化的例子,实际代码后面会继续介绍,这个 icon组件样式没有使用到全局变量。

2)、初始化 packages/components/base/style

css.ts :

import '@fz-mini/theme-chalk/base.css'

导入 theme-chalk下的打包后的 base.css文件。方便用户在 css环境下导入组件库中的某个组件样式

为什么不是导入 '@fz-mini/theme-chalk/dist/base.css'

在 Node.js 环境中, @fz-mini/theme-chalk 可以解析为一个已安装的包,而不必指明到具体的 dist 目录。Webpack、Vite 等构建工具会通过其配置来自动解析并处理该路径。

index.ts :

import '@element-plus/theme-chalk/src/base.scss'

在一些项目中,可能需要使用未编译的 .scss 文件,以便自定义主题或覆盖变量。这时可以通过引入 index.ts 文件来实现。

3)、初始化 packages/components/icon/style

css.ts :

import '@element-plus/components/base/style/css'

导入 base/style/css 而不是直接导入打包后的 base.css 文件是方便维护代码,如果打包目录或者样式目录发生变化,只需要调整 packages/components/base/style/css.ts 文件

在组件中保持 style/css.ts这种目录目的是用户可以通过 style/css.ts 文件来按需引入 Button 的样式。

下面我拿 Element Plus 来举例:

import { ElButton } from '@element-plus/components/button'
import '@element-plus/components/button/style/css'

这里的 @element-plus/components/button/style/css 路径对应到 Button 组件的 style/css.ts 文件,它内部引入了 @element-plus/theme-chalk/el-button.css

index.ts :

import '@element-plus/components/base/style'

二)、样式打包配置

1、处理 css 文件

1)、压缩 css 文件

它使用 postcsscssnano 来压缩文件。

function compressWithCssnano() {
  const processor = postcss([
   cssnano({
      preset: [
        'default',                    // 使用默认压缩配置
        {
          colormin: false,            // 关闭颜色压缩,避免可能的颜色变化
          minifyFontValues: false,    // 关闭字体值压缩,避免字体缩写引起的问题
        },
      ],
    }),
  ])
}

通过 postcss 创建一个处理器,使用 cssnano 来压缩 CSS。

配置 cssnano禁用颜色和字体的最小化,以防止样式失真。

2)、检验流文件类型

return new Transform({
  objectMode: true,             // 设置为对象模式,允许在流中传递文件对象
  transform(chunk, _encoding, callback) {
    const file = chunk as Vinly// 将 `chunk` 转换为 Vinyl 类型,表示当前处理的文件
    if (file.isNull()) {       // 如果文件为空,直接调用回调函数并传入 `file`
      callback(null, file)
      return
    }
    if (file.isStream()) {     // 如果是流模式,则不支持,返回错误
      callback(new Error('Streaming not supported'))
      return
    }
  }
})

创建一个新的 Transform 流,用于逐个处理文件。

检查 file 是否为空或是流,如果是文件是流文件不支持。

3)、输出压缩前后 css 文件大小

const cssString = file.contents!.toString()  // 获取文件内容的字符串表示
processor.process(cssString, { from: file.path }).then((result) => {  // 使用 processor 对 CSS 内容进行压缩处理
  const name = path.basename(file.path)     // 获取文件名
  file.contents = Buffer.from(result.css)   // 将压缩后的 CSS 内容写回文件的 contents 属性
  consola.success(
    `${chalk.cyan(name)}: ${chalk.yellow(cssString.length / 1000)} KB -> ${chalk.green(result.css.length / 1000)} KB`
  )                                         // 在控制台输出文件压缩前后大小对比
  callback(null, file)                      // 传递文件到下一个处理流
})

将文件内容转成字符串,然后用 cssnano 进行压缩。打印压缩前后的文件大小。

2、gulp.ts 配置文件

const distFolder = path.resolve(__dirname, 'dist')
const distBundle = path.resolve(epOutput, 'theme-chalk')
function buildThemeChalk() {
  const sass = gulpSass(dartSass)  // 用 dartSass Scss 编译器作为 gulpSass 解析 scss 文件
  const noElPrefixFile = /(index|base|display)/ // 不需要添加fz- 前缀的正则表达式
  return src(path.resolve(__dirname, 'src/*.scss'))
    .pipe(sass.sync())
    .pipe(autoprefixer({ cascade: false })) //自动生成浏览器前缀
    .pipe(compressWithCssnano()) //压缩css 文件
    .pipe(                        // 根据正则表达式来判断是否需要给目标文件下的scss 文件添加前缀
      rename((path) => {
        if (!noElPrefixFile.test(path.basename)) {
          path.basename = `fz-${path.basename}` // --->fz-icon.css 	
        }
      })
    )
    .pipe(dest(distFolder)) // 将文件打包在 theme-chalk 下 dist 目录下 
}

//将 theme-chalk/dist 下的打包目录复制到 dist/fz-mini/theme-chalk 下
export function copyThemeChalkBundle() {
  return src(`${distFolder}/**`).pipe(dest(distBundle))
}

// 将 theme-chalk/src 下的样式源文件复制到 dist/fz-mini/theme-chalk/src 目录下
export function copyThemeChalkSource() {
  return src(path.resolve(__dirname, 'src/**')).pipe(
    dest(path.resolve(distBundle, 'src'))
  )
}

export const build: TaskFunction = parallel(
  copyThemeChalkSource,
  series(buildThemeChalk, copyThemeChalkBundle)
)
export default build

这个配置我就不在详细的讲述了,相信看过前面几章的应该能看懂注释了。

复制文件 gulp 任务打包成功的结果如图。

package.json 命令行配置。执行以下命令即可完成样式打包。

"scripts": {
    "clean": "rimraf dist",
    "build": "gulp --require @esbuild-kit/cjs-loader"
  },
PC:packages/theme-chalk>pnpm run build 

不过可以在 internal\build\gulpfile.ts 文件中配置以下配置项即可完成样式文件和其他任务一起自动打包。

//将dist/fz-mini/theme-chalk/index.css 文件复制到 dist/fz-mini/dist/index.css
export const copyFullStyle = async () => {
  await mkdir(path.resolve(epOutput, 'dist'), { recursive: true })
  await copyFile(
    path.resolve(epOutput, 'theme-chalk/index.css'),
    path.resolve(epOutput, 'dist/index.css')
  )
}
export default series(
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(epOutput, { recursive: true })),

  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
    runTask('generateTypesDefinitions'),
    series(
      withTaskName('buildThemeChalk', () =>
        run('pnpm run -C packages/theme-chalk build')
                  ),
      copyFullStyle
    )
  ),

  parallel(copyTypesDefinitions, copyFiles)
)

以上 gulp 任务就已经完成模块打包,主文件打包,类型打包和样式打包的全部流程。Element Plus 中还有一些打包配置可以自己去阅读了。

五、总结

整个 Element Plus 到此就全部介绍完了,码字实属不易,留个关注激励我继续前行。组件打包篇幅比较长,可能会存在一些错误还请多多指正。但我相信看完这些多多少少会有点帮助的。