【源码】Element Plus项目构建分析

8/13/2024 Element Plus开源项目源码分析

# 前言

“读开源项目源码”听起来似乎是一个很困难、让人听着就退缩的事情,很多人可能更愿意去看官网介绍、或找一些源码分析的博客进行阅读。当然,实际上也是这样,特别对于跟我一样刚毕业入行不久的小前端来说,源码是那么的遥不可及、苦涩难懂。

但是,我不认为我们就应该惧怕读源码,所谓恐惧来源于未知,我们看源码前,怕的时遇到不熟悉的库、复杂的函数调用、难懂的逻辑设计、以及眼花缭乱的构建过程。随着AI不断强大,AI已经成长到能够大大帮助我们阅读源码,已经大大降低了源码阅读的门槛,让我们能更加轻松的汲取开源项目中的精髓。

先前我已经有做过Vue源码阅读的博客,到目前还只是停留在简单的分析构建过程,后续应该还会慢慢的更新,而本系列则记录一下我在阅读Element Plus源码时的过程,希望能对大家有所帮助。

# 项目概述

Element Plus项目采用的是monorepo的模式,即一个仓库中包含多个项目。后续我想再写一篇文章来详细整理下这个模式吧。

主要的目录和文件有这些:

  • docs:存放项目文档的文件夹,用的是VitePress
  • packages:存放各组件的文件夹,也是项目的核心文件夹
  • play:一个“舞台”,可以在线测试组件
  • internal:存放构建相关的代码

我们主要看的也就是packages文件夹下的内容了。

# 构建流程

# 命令分析

目前我读源码的顺序是习惯先分析项目的构建流程,也就是项目是如何组织起来的,这样有利于我们对整个项目的认识。

当然是先来看根目录下的package.json文件:

// package.json
  "scripts": {
    ...
    "dev": "pnpm -C play dev",
    "clean": "pnpm run clean:dist && pnpm run -r --parallel clean",
    "clean:dist": "rimraf dist",
    "build": "pnpm run -C internal/build start",
    "build:theme": "pnpm run -C packages/theme-chalk build",
    "format": "prettier --write --cache .",
    "lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx,.md,.json --max-warnings 0 --cache",
    ....
  }

命令有很多,我们看“build”命令。

pnpm run -C internal/build start

可以看到,当运行build命令后,会使用pnpm来执行internal/build文件下的start命令。过去看看这个命令:

// internal/build/package.json
  "scripts": {
    "start": "gulp --require @esbuild-kit/cjs-loader -f gulpfile.ts",
    "dev": "pnpm run stub",
    "stub": "unbuild --stub"
  }

这其实就是monorepo项目的特点了,internal/build文件夹下其实也是个项目,有自己的package.json文件,而这个项目负责的就是整个项目构建的任务。

它的指令也很简单,执行start命令后开始使用gulp进行打包,并用“--require @esbuild-kit/cjs-loader -f”指明要提前加载loader,这个loader的作用是将TS解析成JS。具体的打包命令则是执行gulpfile.ts文件

gulp跟webpack、vite一样,都是用来打包的工具。在JQuery的时代非常热门,等vue、reack等现代前端框架出来后,webpack则更加热门,gulp则退居幕后了。gulp主要用于定义和规范开发的流程,运用的是流式的思想,在组件库等项目中还非常常用,但在应用层则使用的比较少了。

# 构建分析

gulpfile就是构建的入口,可以直接看最后的构建命令:

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

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

  parallel(copyTypesDefinitions, copyFiles)
)

series是gulp的串行构建命令、parallel是并行构建命令。可以通过这两个方法,来搭建整个构建的流程,组织构建的任务。直接看任务命名也能大概猜出来每个任务干了什么了:

  1. 先进行清理工作
  2. 创建dist文件夹
  3. 定义一堆并行的构建任务,这些任务可以同时进行,提高构建效率
  4. 并行构建1:构建模块
  5. 并行构建2:构建全部的bundle
  6. 并行构建3:生成类型定义
  7. 并行构建4:构建辅助函数
  8. 并行构建5:定义两个串行的任务,先构建主题,然后拷贝样式代码
  9. 并行构建结束后,拷贝类型定义

用到的series和parallel都是gulp提供的构建方法,而withTaskName和runTask则是一个包装后的方法。看看具体的实现:

// internal/build/src/utils/gulp.ts
export const withTaskName = <T extends TaskFunction>(name: string, fn: T) =>
  Object.assign(fn, { displayName: name })

export const runTask = (name: string) =>
  withTaskName(`shellTask:${name}`, () =>
    run(`pnpm run start ${name}`, buildRoot)
  )

withTaskName实际上就是对普通函数的包装,通过Object.assign方法,将displayName属性设置到了传入的fn上。将包装后的方法直接交由gulp进行执行。

而runTask方法则返回withTaskName包装后的方法,这里的name统一命名成了shellTask:xxx,而要执行的方法,则是通过pnpm run start xxx来进行执行。同时给run方法传递第二个参数代表执行脚本的路径。buildRoot实际就是internal/build文件夹,当gulp执行到这个命令的时候,就会去internal/build下查找传入name的这个方法,并执行。

这就是整个项目的构建流程了,我们就可以以这个大纲为主线,分别看看各步的任务做了什么,一步步分析项目是如何进行构建的。

# 前期工作

前期工作指的就是构建的前两步了,而且具体的实现逻辑也已经列出来了。 通过gulp提供的withTaskName,直接定义并执行了clean和createOutput这两个任务。

  • clean:执行pnpm run clean,清理dist文件夹
  • createOutput:在epOutput(dist/element-plus)下,创建dist文件夹

epOutput是一个常量,定义在internal/build-utils/src/paths.ts中,指的是输出的路径。这里也顺便列出其他类似的定义: buildOutput:dist epOutput:dist/element-plus epPackage: packages/element-plus/package.json projRoot: 项目根目录

# 并行构建

中间这一堆的并行构建我看作是中期的构建任务,也是核心的逻辑。

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

这里不像前两步,直接用withTaskName来定义并执行构建任务了,而是使用runTask来,并传递名字来执行对应的构建任务,而这些任务的具体逻辑则定义在不同的文件中,且执行的方法名字跟传入的name对应。

# buildModules

从名字看的出来,这个任务的目的是构建各个模块,进一步说是package里面的各个组件。

看看具体实现:

export const buildModules = async () => {
  const input = excludeFiles(
    await glob('**/*.{js,ts,vue}', {
      ...
    })
  )
  const bundle = await rollup({
    input,
    plugins: [
      ...
    ],
    ...
  })
  await writeBundles(
    bundle,
    buildConfigEntries.map(([module, config]): OutputOptions => {
      return {
        ...
      }
    })
  )
}

代码分为三个部分:

  1. 定于input,即要打包的入口文件,并且排除不需要打包的文件
  2. 使用rollup构建,根据传入的input和plugins进行打包。
  3. 使用writeBundles方法,将构建好的bundle实例写入到各个模块的dist文件夹中。

先看看第一部分:

// internal/build/src/tasks/modules.ts
  const input = excludeFiles(
    await glob('**/*.{js,ts,vue}', {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )

// internal/build-utils/src/pkg.ts
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))
  })
}

这里利用了glob库进行了文件查找,目标文件夹:pkgRoot(packages目录)下的所有js、ts、vue文件均查找出来,并通过excludeFiles方法排除一些不需要打包进去的文件。查看excludeFiles方法的实现可以看到排除了'node_modules', 'test', 'mock', 'gulpfile', 'dist'这些文件或文件夹。

这里看excludeFiles方法的时候可能会有疑惑:直接进行路径比对不就好了吗?为什么还要加个position呢?

其实,这是为了防止路径中packages文件夹前面的路径进行干扰,如果不做这一步且路径名中,packages的任意上级目录和要查找的目录同名,那就会匹配失败、被意外排除了。

第二部分是执行rollup进行打包,本身只是传递配置项,并没有什么特别的。可以关注一下使用到的plugins:

ElementPlusAlias(),
      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',
        },
      }),

ElementPlusAlias是项目中定义的一个插件,用来给别名进行替换,也就是将项目中用到@element-plus/xxx的地方替换成packages/element-plus/xxx的实际路径。

后续还用到了VueMacros,Vue宏,值得注意的是将setup语法糖自动转换给关闭了、以及用到了vue的jsx。

最后执行writeBundles方法,将构建好的bundle实例写入到各个模块的dist文件夹中。

// internal/build/src/tasks/modules.ts
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}`,
      }
    })
  )

这里的逻辑是遍历buildConfigEntries数组,从ts定义看得出来数组是一个二位数组,遍历每一项时,二级数组的第一个参数代表模块,第二个代表配置。进去看看这个配置数组:

// internal/build/src/build-info.ts
export const buildConfigEntries = Object.entries(
  buildConfig
) as BuildConfigEntries

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`,
    },
  },
}

这里是将一个配置的对象转换成了配置数组,可能是为了方便遍历。也就是传到writeBundles方法时,第一个参数module分别是esm和cjs,代表es module和commonJS两种模块打包方式。第二个参数的配置项中指定了两种打包的输出路径。

这里看得出来,打包后的产物是分为es和lib两个文件夹,分别对应es module和commonJS两种模块化打包,也就是支持这两种模块化引入。

# buildFullBundle

这个任务的目的在于构建出element-plus的入口文件index.ts,以便可以被其他项目所引入。并且还构建出了压缩和未压缩两个版本,通过minifyPlugin这个插件进行实现。

async function buildFullEntry(minify: boolean) {
  const plugins: Plugin[] = [
    ...
  ]
  if (minify) {
    plugins.push(
      minifyPlugin({
        target,
        sourceMap: true,
      })
    )
  }

  const bundle = await rollup({
    input: path.resolve(epRoot, 'index.ts'),
    plugins,
    external: await generateExternal({ full: true }),
    treeshake: true,
  })
  await writeBundles(bundle, [
    {
      format: 'umd',
      ...
    },
    {
      format: 'esm',
      ...
    },
  ])
}

async function buildFullLocale(minify: boolean) {
  const files = await glob(`**/*.ts`, {
    cwd: path.resolve(localeRoot, 'lang'),
    absolute: true,
  })
  return Promise.all(
    files.map(async (file) => {
      ...
      const bundle = await rollup({
        ...
      })
      await writeBundles(bundle, [
        {
          format: 'umd',
          ...
        },
        {
          format: 'esm',
          ...
        },
      ])
    })
  )
}

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

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

可以看到,这个并行任务时开启另外两个并行任务,分别构建出压缩和未压缩两个版本。最核心的代码是buildFullEntry里面的

  if (minify) {
    plugins.push(
      minifyPlugin({
        target,
        sourceMap: true,
      })
    )
  }

通过传入minify,决定是否添加minifyPlugin插件进行代码压缩。

# generateTypesDefinitions

这一步是构建类型定义文件,通过typescript的tsc进行编译,生成d.ts文件。

// internal/build/src/tasks/types-definitions.ts
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)
}

具体做的事情如下:

  1. 生成类型定义文件

定义了一个名为 generateTypesDefinitions 的异步函数,该函数负责生成类型定义文件。

  1. 使用 vue-tsc 生成类型定义文件
  • 使用 run 函数执行 vue-tsc 命令,该命令根据 tsconfig.web.json 配置文件生成 .d.ts 文件。

参数说明:

  • -p tsconfig.web.json: 指定 TypeScript 的配置文件。
  • --declaration: 生成声明文件。
  • --emitDeclarationOnly: 只生成声明文件,不生成 JavaScript 文件。
  • --declarationDir dist/types: 指定声明文件的输出目录。
  1. 重写类型定义文件:
  • 使用 glob 获取 dist/types/packages 目录下的所有 .d.ts 文件。
  • 对每个文件使用 pathRewriter 函数重写内容,这通常涉及到修改类型定义文件中的路径引用,以便更好地适应实际的项目结构。
  • 使用 readFile 和 writeFile 异步读取和写入文件。
  1. 复制和清理:
  • 将 element-plus 子目录的内容复制到 types/packages 目录。
  • 删除 element-plus 子目录。

# buildHelper

这部分是构建用于自动化生成Element Plus组件的文档和类型定义,以便在开发过程中提供更好的编辑器支持,暂不分析。

# buildThemeChalk

到这一步,是一个串行的任务,目的是构建打包样式文件

    series(
      withTaskName('buildThemeChalk', () =>
        run('pnpm run -C packages/theme-chalk build')
      ),
      copyFullStyle
    )

    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')
      )
    }

可以看到,首先是执行了pnpm run -C packages/theme-chalk build 到package/theme-chalk目录去执行打包命令,然后是执行了copyFullStyle任务,将打包好的样式文件复制到dist之下。

# 构建样式

上面提到,到这一步会执行pnpm run -C packages/theme-chalk build命令去packages/theme-chalk目录下打包样式,我们去看看这个目录下的gulpfile打包文件。 直接看最后的步骤组织:

// packages/theme-chalk/gulpfile.js
const distFolder = path.resolve(__dirname, 'dist')
const distBundle = path.resolve(epOutput, 'theme-chalk')

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

这里同样用了parallel和series来组织起了构建任务。 看看并行构建的第一步copyThemeChalkSource:

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

第一步很直接,就是将当前项目根目录下src目录下的所有文件,通过gulp复制到dist/element-plus/theme-chalk的打包目录下。src目录下的文件就是组件库中所有的scss样式文件。

第二步是三个串行任务:

  series(buildThemeChalk, buildDarkCssVars, copyThemeChalkBundle)

看看buildThemeChalk:

function buildThemeChalk() {
  const sass = gulpSass(dartSass)
  const noElPrefixFile = /(index|base|display)/
  return src(path.resolve(__dirname, 'src/*.scss'))
    .pipe(sass.sync())
    .pipe(autoprefixer({ cascade: false }))
    .pipe(compressWithCssnano())
    .pipe(
      rename((path) => {
        if (!noElPrefixFile.test(path.basename)) {
          path.basename = `el-${path.basename}`
        }
      })
    )
    .pipe(dest(distFolder))
}

这步的目的是构建Chalk主题的CSS文件。首先利用gulp的gulpSass插件和dartSass的scss编译器生成了sass的编译方法,然后用了Node的src方法读取了根目录src目录下的所有scss文件,读取完毕后利用gulp的pipe方法组织了一系列的构建任务:

  1. 对读取到的文件,执行sass.sync()方法,将scss编译成css文件
  2. 执行autoprefixer方法,添加浏览器前缀
  3. 执行compressWithCssnano方法,压缩css文件
  4. 执行rename方法,重命名css文件,将需要的文件名前缀添加el-
  5. 执行dest方法,将编译好的css文件输出到dist目录下

主要是看看compressWithCssnano这个方法,它用来压缩css文件:

function compressWithCssnano() {
  const processor = postcss([
    cssnano({
      preset: [
        'default',
        {
          // avoid color transform
          colormin: false,
          // avoid font transform
          minifyFontValues: false,
        },
      ],
    }),
  ])
  return new Transform({
    objectMode: true,
    transform(chunk, _encoding, callback) {
      const file = chunk as Vinly
      if (file.isNull()) {
        callback(null, file)
        return
      }
      if (file.isStream()) {
        callback(new Error('Streaming not supported'))
        return
      }
      const cssString = file.contents!.toString()
      processor.process(cssString, { from: file.path }).then((result) => {
        const name = path.basename(file.path)
        file.contents = Buffer.from(result.css)
        consola.success(
          `${chalk.cyan(name)}: ${chalk.yellow(
            cssString.length / 1000
          )} KB -> ${chalk.green(result.css.length / 1000)} KB`
        )
        callback(null, file)
      })
    },
  })
}

这个方法利用到了postcss和cssnano来进行压缩任务。首先给postcss配置了cssnano插件,获得了processor实例,然后返回了一个Transform转换流,这个流能够读取对应文件,对文件进行对应的处理。

这里的处理当然是进行css压缩了,执行processor.process方法,传入从文件中获取的css字符串,传入带有from属性的配置对象(用于构建映射),处理完后将文件里的内容替换成压缩后的内容。

后面的buildDarkCssVars也是一样,只是构建了黑暗主题的样式:

function buildDarkCssVars() {
  const sass = gulpSass(dartSass)
  return src(path.resolve(__dirname, 'src/dark/css-vars.scss'))
    .pipe(sass.sync())
    .pipe(autoprefixer({ cascade: false }))
    .pipe(compressWithCssnano())
    .pipe(dest(`${distFolder}/dark`))
}

最后的copyThemeChalkBundle方法将dist目录下的文件复制到dist/element-plus/theme-chalk目录下。

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

由此完成了样式的打包构建

# 构建类型定义

构建的最后一步是构建类型定义,以便组件库能够支持TS类型检测。

parallel(copyTypesDefinitions, copyFiles)

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)
}

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, 'global.d.ts'),
      path.resolve(epOutput, 'global.d.ts')
    ),
  ])

到这一步也很简单了,只是把类型文件copy到打包输出的文件夹下,最后将package.json、READEME、global.d.ts文件都复制到打包输出的文件夹下,由此完成了打包构建任务。

# 总结

本文以Element Plus的gulp构建为主线,了解了整个项目是如何进行构建的,同时也能够大致的了解整个项目是如何进行开发运作的,这也符合gulp在项目里用于规范前端开发流程的作用。而且也能够发现,项目是分别打包esm和cmj的,并且组件和样式也是分开进行打包。后续我们就可以进一步的去看组件的实现以及样式文件的打包了。