element-plus 源码解析之编译原理

2,511 阅读7分钟

element-plus 代码一直在变, 找一个较旧的commit用于学习

旧的commit写的有点绕,还是用最新的吧😩

仓库地址 github

一 使用 pnpm 初始化 monorepo 环境

本地创建项目学习时,最好不要叫@element-plus, 因为 pnpm install @element-plus/components -w 会安装远程的,而不是本地

  1. 只发布packages文件夹下的 packages 所以设置 private:true

  2. packages 下的所有包都需要初始化

    cd packages/components && pnpm init -y

    {
         "name": "@element-plus/components",
         "main": "index.ts"`,
    }
    
    

QA:

  • .npmrc 中 shamefully-hoist = true 作用?

    使得依赖安装到 node_modules,因为 pnpm 默认使用软链来节省内存

  • typings 文件目的 ?

    .ts 文件中,import xxx form 'x.vue' 时,ts 报错:找不到模块“./app.vue”或其相应的类型声明, 会去当前项目查找 .d.ts 文件

  • 运行 play 项目时, 不想 cd play,而是在根目录下运行,如何设置?

    1. play/package.json 中 具有 scripts:dev:vite
    2. 根 package.json 中添加 pnpm -C play dev
  • 希望各 package 可以相互引用?

    1. cd 到根目录
    2. pnpm install @element-plus/components -w
    3. 此时,检查 node_modules 目录, 应该出现@element-plus 软链接

常见错误

  1. Preprocessor dependency "sass" not found. Did you install it?
  • pnpm install sass -w -D

二 gulp + rollup 打包

  1. pnpm install gulp @types/gulp sucrase -w -D

    • 默认只能使用 gulpfile.js,为了支持 gulpfile.ts,需要使用Sucrase, 下载安装即可,无需显式引用
  2. 添加打包命令

    "build": "gulp -f build/gulpfile.ts"

  3. 需要打包出哪些产物?

  • /dist:
    • umd全量包(全部整合到一个js文件里): /dist/index.full.js
    • esm全量包(全部整合到一个js文件里): /dist/index.full.mjs
    • .css全量包
  • /es:
    • esm包, 多个目录,有一个 index.js 作为出口,以及 .d.ts文件
  • /lib:
    • cmd包, 多个目录,有一个 index.js 作为出口,以及 .d.ts文件
  • /theme-chalk
    • css: 多个.css文件, 直接扁平放到 /theme-chalk
    • scss: scss源文件, 放在/theme-chalk/src目录下
  • ../types
    • 所有包的声明文件 @types , 为了给umd包使用
  1. 如何打包?

    使用gulp控制整个构建流程

    import { series, parallel } from 'gulp'
     series(
          withTaskName('clean', () => run('pnpm run clean')),
              
      parallel(
        runTask('buildModules'), // 构建 /es 和 /lib
        runTask('buildFullBundle'), // 构建 /dist
        runTask('generateTypesDefinitions'), // 构建 @types 声明文件
        runTask('buildHelper'),
        series( // 构建 /theme-chalk 
          withTaskName('buildThemeChalk', () =>
            run('pnpm run -C packages/theme-chalk build')
          ),
          copyFullStyle
        )
      ),
      
          parallel(copyTypesDefinitions, copyFiles)
    )
    

    ps: pnpm run --filter ./packages --parallel --stream build 会寻找 ./packages 下的子包的 build 命令, build 调用 gulp,gulp 寻找 gulpfile.js, gulpfile.js 里实现针对该包的打包细节

打包 esm 和 commonjs

要输出 esm, cjs 等模块类型, 因此这一块使用 rollup + esbuild 来打包

import commonjs from "@rollup/plugin-commonjs" // 将 CommonJS 转换成 ES2015 模块,应该在其他插件之前
import nodeResolve from "@rollup/plugin-node-resolve" // 识别 import,require 后面的模块名称,  补全.ts,.json 等后缀,并且返回文件路径给rollup

// 入口配置
  const inputOptions = {
    input, 
    // 插件有执行顺序要求,先左后右
    plugins: [
      nodeResolve(), 
      commonjs(),
      // .vue文件转成 .js 文件; script = defineComponent(),script.render = parse(template)
      vue({
        target: "browser",
      }),
      esbuild({ // rollup 只支持js文件内容, 识别ts内容并转成js
        target: "es2018",
      }),
    ],
    // Rollup 将把所有 imports 的模块打包在一起
    external: generateExternal(),
  }
  // 出口配置
      {
      /** ems */
      format: "esm",
      // 多出口时, 必须指定输出的目录
      dir: path.resolve(projRoot, "dist/element-plus", "es"),
      exports: "auto", // default
      preserveModules: true,

      preserveModulesRoot: path.resolve(projRoot, "packages"),
      sourcemap: true,
      entryFileNames: `[name].mjs`,
    },

处理模块时, rollup 会查找模块并尝试解析模块内容

比如, 读取到 components/icon/style/index.ts 文件时:

// @file  components/icon/style/index.ts
import "@y-element-plus/theme-chalk/src/icon.scss"

会去查找@y-element-plus/theme-chalk/src/icon.scss, 并调用相关的插件,解析scss文件,在这里, 因为我们在 rollup 里没有配置任何scss相关的插件,所以无法解析 @use "mixins/mixins" as *;

Error: Unexpected character '@' (Note that you need plugins to import files that are not JavaScript)

.scss 的处理放在 /theme-chalk/gulpfile.ts 里处理 , 所以这里的 rollup 不用来处理样式, 也就不需要配置scss相关插件,那怎么让rollup 跳过这个文件呢?

  1. @y-element-plus 替换为 y-element-plus
  2. y-element-plus/theme-chalk/** 定义成外部模块
//  修改 `@y-element-plus` 为 `y-element-plus`
export function ElementPlusAlias(): Plugin {
  const THEME_CHALK = `${EP_PREFIX}/theme-chalk`

  return {
    name: 'element-plus-alias-plugin',
    resolveId(id, importer, options) {
      if (!id.startsWith(EP_PREFIX)) return

      if (id.startsWith(THEME_CHALK)) {
        return {
          id: id.replaceAll(THEME_CHALK, `${EP_PKG}/theme-chalk`),
          external: 'absolute',
        }
      }

      return this.resolve(id, importer, { skipSelf: true, ...options })
    },
  }
}
// 定义成外部模块
export const generateExternal = async () => {
  return (id: string) => {
    const packages: string[] = ['vue']
  
      packages.push('element-plus/theme-chalk')
      // dependencies
      packages.push('@vue', ...getPackageDependencies(epPackage))
    }

    return [...new Set(packages)].some(
      (pkg) => id === pkg || id.startsWith(`${pkg}/`)
    )
  }
}


编译后变成:

import 'y-element-plus/theme-chalk/src/icon.scss'

对于其他的 @y-element-plus 导入, rollup 内部会转成相对路径, 无论是否开启outputOptions.preserveModules

import { withInstall } from "@y-element-plus/utils/with-install"
export default withInstall
import { withInstall } from './with-install.mjs';
export { withInstall as default } from './with-install.mjs';
//# sourceMappingURL=index3.mjs.map

如果无效, 检查nodeResolve的配置是否包含.ts:

 nodeResolve({
   extensions: [".ts"],
 }),

inputOptions.input

使用多入口,

outputOptions.preserveModules

默认:false, 扁平放置到出口目录,并且依赖模块会被内联到文件中

如:

// @file packages/components/base/index.ts
import lodash from "lodash"
console.log(lodash.debounce)

编译为:

image.png

true: 让他保持与入口目录 packages一样的目录结构,并且依赖模块会变成相对路径

image.png

import lodash from '../../../node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/lodash.mjs';

console.log(lodash.debounce);
//# sourceMappingURL=index.mjs.map

多入口时, 一般都想要保留目录结构, 但是lodash 意外引用了 node_modules, 这不是我们期望的, 我们期望的是 import lodash from 'lodash, 解决办法是 把lodash添加为外部模块

inputOptions.external

使用external来把 一些模块设置为外部模块

代码里不止会用到 lodash, 还会用到其他包, 如何确定该 external 哪些包? 根据经验, import 的模块一般来自 package.json 中的dependenciespeerDependencies字段, 所以就有

const {dependencies,peerDependencies} = await import('package.json')
external = [...new Set(dependencies,peerDependencies)]

当然也有例外,如 element-plus 使用了 import { isFunction } form @vue/shared, @vue/shared 包没有显式出现在 package.json 文件中, 他是通过: vue -> @vue/runtime-core -> @vue/shared来的, 那么这个库也是需要加进去的

总结: preserveModules 为true会将模块解析为相对路径, 为false会内联模块

outputOptions.preserveModulesRoot

某些时候, 入口的文件目录也不是我们想要的,可以使用 preserveModulesRoot 调整目录结构, 大致意思是

outDir = [...outDir, ...preserveModulesRoot]

以此可以减少 preserveModulesRoot 这个目录层级

打包 Dist 全量包

dist下的包:

  1. 一个全量样式的 index.css
  2. 一个全量js的umd包 index.full.js
  3. 一个全量js的esm包 index.full.mjs

对于index.css, 在theme-chalk里处理, 然后把处理结果拷贝到该目录下即可

根据 esbuild 的启发:

请注意打包与文件连接不同。在启用打包时向 esbuild 传递多个输入文件 将创建两个单独的 bundle 而不是将输入文件连接在一起。 为了使用 esbuild 将一系列文件打包在一起, 在一个入口起点文件中引入所有文件, 然后就像打包一个文件那样将它们打包。

思路: 构建这样一个入口 js, 又由于packages下的所有内容都是目录, 因此创建一个 叫 element-plus 的目录, 在 element-plus/index.ts 里引用所有模块即可

打包样式

  1. 样式全部在.scss 文件, .scss 文件全部在 them-chalk 文件夹里, components 下的组件不支持使用书写样式

  2. 存在两种引用方式

  • 全量引用
import "element-plus/dist/index.css";
  • 按需引用
// vite.config.ts
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

export default {
  plugins: [
    // ...
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
};

所以 build 后, 这两个地方需要有样式代码 全量: element-plus/dist/index.css, 按需: element-plus/es/components/icon/style/css

按需的路径参考 [unplugin-vue-components/resolvers](element-plus/es/components github.com/antfu/unplu…) 源码而来

// @filename: element-plus/es/components/icon/style/css.ts
import "@element-plus/components/base/style/css"; // 服务端渲染时,不加载这个文件
import "@element-plus/theme-chalk/el-icon.css";

// element-plus/es/components/icon/style/index.ts
import "@element-plus/components/base/style";
import "@element-plus/theme-chalk/src/icon.scss";

// @element-plus/components/base/style/css.ts
import "@element-plus/theme-chalk/base.css";

// @element-plus/components/base/style/index.ts
import "@element-plus/theme-chalk/src/base.scss";

生成声明文件

解决两件事:

  • 分别编译 .vue.ts 文件
  • packages/element-plus 文件夹下编译为 @types 的直接子元素

ts-morph 配置:

  const project = new Project({
   compilerOptions: {
     emitDeclarationOnly: true,
     outDir: resolve(projRoot, "dist/types"),
     baseUrl: projRoot,
     // 不要忘记加  /*
     paths: {
       "@y-element-plus/*": ["packages/*"],
     },
   },
   tsConfigFilePath: resolve(projRoot, "tsconfig.json"),
   skipAddingFilesFromTsConfig: true,
 })

.vue文件, 编译以后有两个问题:

  1. scss文件也被加入进来了,需要排除

image.png

  1. @y-element-plus 前缀没有像rollup那样被自动处理, 需要收到替换 前缀未处理

需要手动处理

// 获取输出文件
sourceFiles.map(async (sourceFile) => {
    sourceFile.getEmitOutput().getOutputFiles()
        const tasks = emitFiles.map(async (outputFile) => {
      const filepath = outputFile.getFilePath()
      await fs.mkdir(path.dirname(filepath), {
        recursive: true,
      })
        // 替换前缀
      await fs.writeFile(
        filepath,
        outputFile.getText()
        .replaceAll("@y-element-plus/", "y-element-plus/es/")
        .replaceAll('@y-element-plus/theme-chalk','element-plus/theme-chalk'),
        "utf8"
      )
    })
})

.vue 文件, 使用glob 读取所有文件, 借助vue/compiler-sfc 获取 script 标签里的内容, 然后使用project.createSourceFile 添加为 sourceFile,然后重复以上步骤, 替换前缀