Monorepo基于gulp+rollup打包简析

1,414 阅读7分钟

前言

本篇是 工欲善其事必先利其器(搭建组件库) 篇关于打包模块的补充介绍,介绍 Monorepo 项目基于 gulp + rollup 打包过程

基于 rollup 打包

详细文档请参考rollup 文档

一份简单的 rollup.config.js 如下

// rollup.config.js
import { defineConfig } from 'rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve';

const libName = 'libName'
export default defineConfig({
  input: 'index.js',
  output: [
    {
      file: `dist/${libName}.cjs.js`,
      format: 'cjs',
    },
    {
      file: `dist/${libName}.es.js`,
      format: 'es',
    },
    {
      file: `dist/${libName}.umd.js`,
      format: 'umd',
      globals: {
        'vue': 'Vue',
      },
      name: libName,
    },
  ],
  external: ['vue'],
  plugins: [
    nodeResolve(),
  ],
});

核心是入口input、输出output、插件plugins,对于不需要包含的模块可以结合 external 配置项屏蔽打包

其中 output 还可以指定输出规范,umd规范的 name 是必须的也就是全局的var变量名

plugins

rollup/plugins 中有许多插件可供选择,下面挑一些简单讲讲

@rollup/plugin-babel

如果需要 babel 转换 ES6/7 代码可以使用 @rollup/plugin-babel 插件

@rollup/plugin-node-resolve

@rollup/plugin-node-resolve 可以辅助解析省略拓展名的引用文件

@rollup/plugin-commonjs

一些依赖模块可能是遗留的 CommonJS,这就需要 @rollup/plugin-commonjs 来解析

vue SFC 支持

@vitejs plugin-vue

如果是 typescript 环境,需要补充 .vuetypescript 支持,可以在根目录下新建 .d.ts 配置以下内容支持

declare module '*.vue' {
  import { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

jsx 支持

React 官网JSX 定义是

JSX 是 JavaScript 的一种语法扩展,它和模板语言很接近,但是它充分具备 JavaScript 的能力。

JSX 会被编译为 React.createElement(), React.createElement() 将返回一个叫作“React Element”的 JS 对象。

为了更好的理解 JSX 我们用 babel 编译看看。新建一个项目,安装以下工具

pnpm i -g @babel-cli
pnpm i -D @babel/core @babel/preset-react

package.json 中加入以下配置

"scripts": {
    "build": "babel index.js"
}

新建 index.js.babelrc 文件,分别写入以下内容

// index.js
function Comp(props) {
  return <span>子组件{props.count}</span>;
}

export default function () {
  const handleClick = () => {
    console.log("Comp click");
  };
  return (
    <div>
      JSX: <Comp count="1" onClick={handleClick}></Comp>
    </div>
  );
}


// .babelrc
{
  "presets": [
    [
      "@babel/preset-react"
    ]
  ]
}

运行 pnpm build 命令,编译结果如下

function Comp(props) {
  return /*#__PURE__*/React.createElement("span", null, "\u5B50\u7EC4\u4EF6", props.count);
}

export default function () {
  const handleClick = () => {
    console.log("Comp click");
  };

  return /*#__PURE__*/React.createElement("div", null, "JSX\uFF1A ", /*#__PURE__*/React.createElement(Comp, {
    count: "1",
    onClick: handleClick
  }));
}

JSX 转换成 React.createElement 函数,函数第一个参数为节点类型,第二个参数属性以及事件,第三个参数往后是子节点

将 jsx 转换成自定义的函数

修改 .babelrc 文件如下

{
  "presets": [
    [
      "@babel/preset-react",
      {
        "pragma": "dom"
      }
    ]
  ]
}

运行 pnpm build 命令,编译结果如下,

function Comp(props) {
  return dom("span", null, "\u5B50\u7EC4\u4EF6", props.count);
}

export default function () {
  const handleClick = () => {
    console.log("Comp click");
  };

  return dom("div", null, "JSX\uFF1A ", dom(Comp, {
    count: "1",
    onClick: handleClick
  }));
}

此时 React.createElement 被替换成了 .babelrc 配置里的 dom 函数,也就是说 JSX 最终被编译成了 JavaScript 函数

vue3 的 jsx 支持

vue3 项目中使用 JSX 可以使用 @vitejs/plugin-vue-jsx 插件,该插件会把 JSX 编译为 vue3create函数,如:vue.createVNodevue.createTextVNode

vue3 关于 jsx 的使用请参考 render-functions-jsx

unplugin-vue-macros

如果使用 JSX 语法进行 Vue3 组件开发也推荐使用 unplugin-vue-macros 插件,当然非 JSX 语法以及 vue2 也可以使用,详细说明请参考 Vue Macros 文档

typescript 支持

支持 typescript 的插件有 rollup-plugin-typescript2@rollup/plugin-typescript

@rollup/plugin-typescript

假设一个 Monorepo 项目,关键目录结构大致如下

    ├─packages
    |  ├─components
    |  |  ├─index.ts
    |  |  ├─rollup.config.js
    |  ├─utils
    |  |  ├─index.ts
    ├─tsconfig.json

components 模块需要独立打包,在该模块下独立配置 rollup.config.js

import { defineConfig } from 'rollup'
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
const libName = 'libName'
export default defineConfig({
  input: 'index.ts',
  output: [
    {
      file: `dist/${libName}.cjs.js`,
      format: 'cjs',
    },
    {
      file: `dist/${libName}.es.js`,
      format: 'es',
    },
    {
      file: `dist/${libName}.umd.js`,
      format: 'umd',
      globals: {
        'vue': 'Vue',
      },
      name: libName,
    },
  ],
  external: ['vue'],
  plugins: [
    typescript({
      sourceMap: false,
      target: 'es2018'
    }),
    nodeResolve({
      extensions: ['.js', '.ts']
    })
  ],
});

components 执行 pnpm rollup --config 可能会报 Unexpected token (Note that you need plugins to import files that are not JavaScript) 的错误,就需要引用根目录的 tsconfig.json

  typescript({
      tsconfig: '../../tsconfig.json',
      sourceMap: false,
      target: 'es2018'
  }),

同时 tsconfig.jsoncompilerOptions 配置项下增加 "rootDir": "." 指定根目录配置,这样Monorepo 内部引用才能被正确解析

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "allowSyntheticDefaultImports": true,
    "strictFunctionTypes": false,
    "rootDir": ".",
    "jsx": "preserve",
    "baseUrl": ".",
    "allowJs": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "experimentalDecorators": true,
    "lib": ["dom", "esnext"],
    "noImplicitAny": false,
    "skipLibCheck": true,
    "paths": {
      "@main-ui/*": ["packages/*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx", "**/*.vue"],
  "exclude": ["node_modules"]
}

rollup-plugin-esbuild

不过为了支持typescript的话还是推荐使用 rollup-plugin-esbuild

esbuild is by far one of the fastest TS/ESNext to ES6 compilers and minifier, this plugin replaces rollup-plugin-typescript2@rollup/plugin-typescript and rollup-plugin-terser for you.

插件集成

将以上插件集成到一起,配置大致如下

// rollup.config.js
import { defineConfig } from 'rollup'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import VueMacros from 'unplugin-vue-macros/rollup'
import esbuild from 'rollup-plugin-esbuild'
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs'
const libName = 'libName'
export default defineConfig({
  input: 'index.ts',
  output: [
    {
      file: `dist/${libName}.cjs.js`,
      format: 'cjs',
    },
    {
      file: `dist/${libName}.es.js`,
      format: 'es',
    },
    {
      file: `dist/${libName}.umd.js`,
      format: 'umd',
      globals: {
        'vue': 'Vue',
      },
      name: libName,
    },
  ],
  external: ['vue'],
  plugins: [
    VueMacros({
      setupComponent: false,
      setupSFC: false,
      plugins: {
        vue: vue({
          isProduction: false,
        }),
        vueJsx: vueJsx(),
      },
    }),
    nodeResolve({
      extensions: ['.js', '.ts', '.tsx']
    }),
    commonjs(),
    esbuild({
      target: 'es2018',
      loaders: {
        '.vue': 'ts',
      },
      tsconfig: '../../tsconfig.json',
    }),
  ],
});

这样一个同时支持 .vue.tsx 的配置就完成了

用 cursor 来生成 rollup 配置

cursor 官网

提示语:配置rollup,基于esbuild打包,支持vue3、typescript、vuejsx,输出cmd、umd、esm规范

得到以下结果

import vue from 'rollup-plugin-vue';
import esbuild from 'rollup-plugin-esbuild';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';

export default {
  input: 'src/main.js',
  output: [
    {
      file: 'dist/my-library.cjs.js',
      format: 'cjs',
      exports: 'auto',
    },
    {
      file: 'dist/my-library.esm.js',
      format: 'esm',
    },
    {
      name: 'MyLibrary',
      file: 'dist/my-library.umd.js',
      format: 'umd',
      exports: 'auto',
      globals: {
        vue: 'Vue',
      },
    },
  ],
  plugins: [
    vue(),
    esbuild({
      include: /\.[jt]s$/,
      minify: process.env.NODE_ENV === 'production',
      target: 'es2015',
    }),
    nodeResolve(),
    commonjs(),
    terser(),
  ],
  external: ['vue'],
}

看起来差不多

rollup 优化配置

优化场景非常多,根据各自项目情况进行即可,这里就不详细说明了,以 cursor 给的结果作为简单的参考

提示语:rollup 打包优化配置

它给出了说明以及配置,当然还可以不断的引导AI以提供更多的优化方向建议

// To optimize the rollup configuration for production build, we can add the following changes:
// 1. Use the `terser` plugin with `compress` and `mangle` options to minify the output code.
// 2. Use the `esbuild` plugin with `minify` option to minify the input code.
// 3. Use the `terser` plugin with `ecma` option set to `2015` to ensure compatibility with older browsers.
// 4. Use the `node-resolve` plugin with `browser` option set to `true` to resolve browser-specific modules.
// 5. Use the `commonjs` plugin with `transformMixedEsModules` option set to `true` to transform mixed ESM/CJS modules.
export default {
      input: 'src/main.js',
      output: [
        {
          file: 'dist/my-library.cjs.js',
          format: 'cjs',
          exports: 'auto',
        },
        {
          file: 'dist/my-library.esm.js',
          format: 'esm',
        },
        {
          name: 'MyLibrary',
          file: 'dist/my-library.umd.js',
          format: 'umd',
          exports: 'auto',
          globals: {
            vue: 'Vue',
          },
        },
      ],
      plugins: [
        vue(),
        esbuild({
          include: /\.[jt]s$/,
          minify: process.env.NODE_ENV === 'production',
          target: 'es2015',
        }),
        nodeResolve({
          browser: true,
        }),
        commonjs({
          transformMixedEsModules: true,
        }),
        terser({
          compress: {
            ecma: 2015,
          },
          mangle: {
            module: true,
          },
        }),
      ],
      external: ['vue'],
    }

gulp

自动化和增强工作流程的工具包

利用gump和JavaScript的灵活性来自动化缓慢、重复的工作流,并将其组成高效的构建管道。

gulp 有四个核心函数

  • task() : 执行的任务
  • src() : 输入的文件
  • pipe() : 执行的管道方法,接在源后面或者其他管道后面
  • dest() : 输出的位置

基于 gulp 打包 scss 的配置

cursor 给生成一份配置

    const gulp = require('gulp');
    const sass = require('gulp-sass');
    const autoprefixer = require('gulp-autoprefixer');

    gulp.task('sass', function() {
      return gulp.src('src/scss/**/*.scss')
        .pipe(sass().on('error', sass.logError))
        .pipe(autoprefixer())
        .pipe(gulp.dest('dist/css'));
    });

    gulp.task('default', gulp.series('sass'));

自定义任务组合

gulp 提供了 将任务函数和/或组合操作组合成更大的操作,这些操作将按顺序依次执行 的函数series()parallel()

基于 seriesparallel 我们可以很轻松的定义工作流,文档 也有例子说明

const { series, parallel } = require('gulp'); 
function clean(cb) { // body omitted cb(); } 
function css(cb) { // body omitted cb(); } 
function javascript(cb) { // body omitted cb(); } 
exports.build = series(clean, parallel(css, javascript));

自定义构建流程

了解完 rollup 打包 jsx.vuetypescript 项目的配置流程以及 gulp 打包 scss 的配置,我们来看看基于 gulp 如何把各模块打包流程集成到一起

打包需要考虑以下几点:

  • 默认js模块需要支持ESMCommonJS 规范
  • components 各个组件需要单独打包以支持按需引入
  • 需要打全量包以支持 <script> 等方式直接全部引用,同时全量包需要支持 UMD 规范
  • 样式分离打包以支持自定义样式等

各模块各自配置构建脚本

假设 workspace 有以下类似模块结构,其中 main-ui 为总包模块

    packages
    ├─utils
    ├─main-ui
    ├─theme
    ├─locale
    ├─hooks
    ├─directives
    ├─components

在各自模块里的 package.json 定义好打包脚本,如:

  // javacript 模块 
  "scripts": {
    "build": "pnpm rollup --config"
  }
  
  // css 模块
  "scripts": {
    "build": "pnpm gulp"
  }

如果没有特殊要求,此时在 workspaceRootpackage.json 可以配置统一构建命令

  "scripts": {
    "build": "pnpm --filter=@name/* run build",
  }

当然这种方式和 changesets 有点类似

统一构建

接下来再看看类似 element-plus 的方式

workspaceRoot 下新建 build 模块统一处理

构建工具

处理 gulp 工具

// build/utils.ts
import { spawn } from 'child_process'
import chalk from 'chalk'
import consola from 'consola'
import { PKG_PREFIX, PKG_NAME, configs } from './constants'
import { projRoot, mainPackage } from './paths'
import type { ProjectManifest } from '@pnpm/types'

/**
 * 定义任务函数名称
 * @param {string} name 任务名称
 * @param {Function} fn 任务函数
 * @returns {Function}
 */
export function withTaskName<T extends Function>(name: string, fn: T): T {
  return Object.assign(fn, { displayName: name })
}

/**
 * 启一个子进程来运行脚本
 * @param {string} command 命令
 * @param {string} cwd cwd
 * @returns
 */
export async function run(command: string, cwd: string = projRoot): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    const [cmd, ...args] = command.split(' ')
    consola.info(`run: ${chalk.green(`${cmd} ${args.join(' ')}`)}`)
    const app = spawn(cmd, args, {
      cwd,
      stdio: 'inherit',
      shell: process.platform === 'win32'
    })

    const onProcessExit = () => app.kill('SIGHUP')

    app.on('close', code => {
      process.removeListener('exit', onProcessExit)

      if (code === 0) resolve()
      else reject(new Error(`Command failed. \n Command: ${command} \n Code: ${code}`))
    })
    process.on('exit', onProcessExit)
  })
}
/**
 * 执行任务
 * @param {string} name 执行任务名称
 * @returns {Function}
 */
export function runTask(name: string) {
  return withTaskName(`shellTask:${name}`, () => run(`pnpm run build ${name}`))
}

export function excludeFiles(files) {
  // 屏蔽 node_modules、测试、dist,样式模块下有 gulpfile.ts也要屏蔽
  const excludes = ['node_modules', 'gulpfile', '__tests__', 'dist']
  const input = files.filter(path => !excludes.some(exclude => path.includes(exclude)))
  return input
}
export const getPackageManifest = (pkgPath: string) => {
  return require(pkgPath) as ProjectManifest
}

export const getPackageDependencies = (pkgPath: string): Record<'dependencies' | 'peerDependencies', string[]> => {
  const manifest = getPackageManifest(pkgPath)
  const { dependencies = {}, peerDependencies = {} } = manifest

  return {
    dependencies: Object.keys(dependencies),
    peerDependencies: Object.keys(peerDependencies)
  }
}

export const generateExternal = async (options: { full: boolean }) => {
  const { dependencies, peerDependencies } = getPackageDependencies(mainPackage)

  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}/`))
  }
}
export const pathRewriter = module => {
  const config = configs[module]

  return (id: string) => {
    id = id.replaceAll(`${PKG_PREFIX}/theme-chalk`, `${PKG_NAME}/theme-chalk`)
    id = id.replaceAll(`${PKG_PREFIX}/`, `${config.bundle.path}/`)
    return id
  }
}

这里用子进程来执行任务的一个好处是:一些任务可以并发执行

注意:这里 runTask 函数内部的 pnpm run build ${name} 需要根据自己 package.json 配置的 scripts 对齐,我这里配置的是 "build": "gulp --require @esbuild-kit/cjs-loader -f build/gulpfile.ts",,执行 pnpm run build 脚本,gulp 相当于给 pnpm run build 注册了任务,所以运行任务的命令是 pnpm run build ${name},如果配置的是 start 则需改为 start,否则是找不到对应命令的

变量

// build/paths.ts
import { resolve } from 'path'

export const projRoot = resolve(__dirname, '..')
export const outputRoot = resolve(projRoot, 'dist')
export const workspace = resolve(projRoot, 'packages')
export const mainRoot = resolve(workspace, 'main-ui')
export const mainPackage = resolve(mainRoot, 'package.json')
export const localeRoot = resolve(workspace, 'locale')

// build/constants.ts
import { outputRoot } from './paths'
import { resolve } from 'path'
export const target = 'es2018'

export const PKG_PREFIX = '@main-ui'
export const PKG_NAME = 'main-ui'
export const PKG_CAMELCASE_NAME = 'MainUi'
export const PKG_CAMELCASE_LOCAL_NAME = 'MainUiLocale'
export const PKG_BRAND_NAME = 'Main Ui'

export const configs = [
  {
    format: 'cjs',
    ext: 'js',
    output: resolve(outputRoot, 'lib'),
    bundle: {
      path: `${PKG_NAME}/lib`
    }
  },
  {
    format: 'esm',
    ext: 'mjs',
    output: resolve(outputRoot, 'es'),
    bundle: {
      path: `${PKG_NAME}/es`
    }
  }
] as const

样式构建

样式构建比较简单,就是把样式打包输出到各种规范指定目录下,这里就不详细介绍了

需要注意的是路径映射问题,element-plus 是提供插件 element-plus-alias 进行路径转换处理

import { PKG_NAME, PKG_PREFIX } from '@element-plus/build-constants'

import type { Plugin } from 'rollup'

export function ElementPlusAlias(): Plugin {
  const themeChalk = 'theme-chalk'
  const sourceThemeChalk = `${PKG_PREFIX}/${themeChalk}` as const
  const bundleThemeChalk = `${PKG_NAME}/${themeChalk}` as const

  return {
    name: 'element-plus-alias-plugin',
    resolveId(id) {
      if (!id.startsWith(sourceThemeChalk)) return
      return {
        id: id.replaceAll(sourceThemeChalk, bundleThemeChalk),
        external: 'absolute',
      }
    },
  }
}

各 js 子模块统一构建

因为是动态加载入口了,改为非 rollup.config.js 配置方式

// build/build-modules.ts
import type { InputOption } from 'rollup'
import glob from 'fast-glob'
import { rollup } from 'rollup'
import { workspace } from './paths'
import vue from '@vitejs/plugin-vue'
import url from '@rollup/plugin-url'
import { excludeFiles } from './utils'
import json from '@rollup/plugin-json'
import vueJsx from '@vitejs/plugin-vue-jsx'
import esbuild from 'rollup-plugin-esbuild'
import { target, configs } from './constants'
import commonjs from '@rollup/plugin-commonjs'
import VueMacros from 'unplugin-vue-macros/rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve'

async function build(input: InputOption): Promise<void> {
  const bundle = await rollup({
    input,
    plugins: [
      json(),
      url(),
      VueMacros({
        setupComponent: false,
        setupSFC: false,
        plugins: {
          vue: vue({
            isProduction: false
          }),
          vueJsx: vueJsx()
        }
      }),
      nodeResolve({
        extensions: ['.tsx', '.json', '.js', '.ts']
      }),
      commonjs(),
      esbuild({
        sourceMap: true,
        minify: true,
        define: {
          'process.env.NODE_ENV': JSON.stringify('production')
        },
        target,
        loaders: {
          '.vue': 'ts'
        },
        treeShaking: true
      })
    ],
    external: ['vue'],
    treeshake: true
  })

  configs.forEach(config => {
    bundle.write({
      format: config.format,
      exports: config.format === 'cjs' ? 'named' : undefined,
      // 输出路径,不同规范不一样,这个对应主包 package.json 的 exports 配置
      dir: config.output,
      // 保持目录结构
      preserveModules: true,
      // 保持目录结构的最高位置,这里设置为 packages
      preserveModulesRoot: workspace,
      sourcemap: true,
      entryFileNames: `[name].${config.ext}`
    })
  })
  return
}

export const buildModules = async function () {
  const input = excludeFiles(
    await glob(['**/*.{js,ts,tsx,vue}', '!main-ui/**/*'], {
      cwd: workspace,
      absolute: true,
      onlyFiles: true
    })
  )
  await build(input)
}

全量构建

全量构建是把组件、hooks等都打包到一起,需要注意的是多语言可能是非必须的,所以单独处理

// build/build-full-bundle.ts
import path from 'path'
import type { OutputOptions, RollupBuild } from 'rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import { rollup } from 'rollup'
import commonjs from '@rollup/plugin-commonjs'
import vue from '@vitejs/plugin-vue'
import VueMacros from 'unplugin-vue-macros/rollup'
import vueJsx from '@vitejs/plugin-vue-jsx'
import esbuild, { minify as minifyPlugin } from 'rollup-plugin-esbuild'
import { parallel } from 'gulp'
import glob from 'fast-glob'
import { camelCase, upperFirst } from 'lodash'
import { target, PKG_BRAND_NAME, PKG_CAMELCASE_LOCAL_NAME } from './constants'
import { outputRoot, localeRoot, mainRoot } from './paths'
import { version } from '../packages/main-ui/package.json'
import { generateExternal, withTaskName } from './utils'
import json from '@rollup/plugin-json'
const banner = `/*! ${PKG_BRAND_NAME} v${version} */\n`
export function writeBundles(bundle: RollupBuild, options: OutputOptions[]) {
  return Promise.all(options.map(option => bundle.write(option)))
}
export function formatBundleFilename(name: string, minify: boolean, ext: string) {
  return `${name}${minify ? '.min' : ''}.${ext}`
}

async function buildFullEntry(minify: boolean) {
  const plugins = [
    json(),
    VueMacros({
      setupComponent: false,
      setupSFC: false,
      plugins: {
        vue: vue({
          isProduction: true
        }),
        vueJsx: vueJsx()
      }
    }),
    nodeResolve({
      extensions: ['.mjs', '.js', '.json', '.ts']
    }),
    commonjs(),
    esbuild({
      exclude: [],
      sourceMap: minify,
      target,
      loaders: {
        '.vue': 'ts'
      },
      define: {
        'process.env.NODE_ENV': JSON.stringify('production')
      },
      treeShaking: true,
      legalComments: 'eof'
    })
  ]
  if (minify) {
    plugins.push(
      minifyPlugin({
        target,
        sourceMap: true
      })
    )
  }

  const bundle = await rollup({
    input: path.resolve(mainRoot, 'index.ts'),
    plugins,
    external: await generateExternal({ full: true }),
    treeshake: true
  })
  await writeBundles(bundle, [
    {
      format: 'umd',
      file: path.resolve(outputRoot, 'dist', formatBundleFilename('index.full', minify, 'js')),
      exports: 'named',
      name: 'MainUi',
      globals: {
        vue: 'Vue'
      },
      sourcemap: minify,
      banner
    },
    {
      format: 'esm',
      file: path.resolve(outputRoot, 'dist', formatBundleFilename('index.full', minify, 'mjs')),
      sourcemap: minify,
      banner
    }
  ])
}

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 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(outputRoot, 'locale', formatBundleFilename(filename, minify, 'js')),
          exports: 'default',
          name: `${PKG_CAMELCASE_LOCAL_NAME}${name}`,
          sourcemap: minify,
          banner
        },
        {
          format: 'esm',
          file: path.resolve(outputRoot, 'locale', formatBundleFilename(filename, minify, 'mjs')),
          sourcemap: minify,
          banner
        }
      ])
    })
  )
}

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

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


typescript 类型处理

主要是输出 .d.ts 类型声明文件,如果项目本身就是用 ts 写的,在一些场景比如公司内部使用的组件库可以考虑直接发布 ts 源码无需另外打包 .d.ts,这种方式可以更方便定位问题,不过相对的使用方就需要打包编译了

使用这种方式需要改变构建 esm 模块改为复制项目源码本身到打包输出路径的 es 目录(需要调整路径映射),然后直接配置 package.jsontypes 为总的./es/index.ts入口

下面简单介绍两种 .d.ts 打包插件

npm: rollup-plugin-dts

简单的库可以考虑使用 rollup-plugin-dts,配合rollup的使用方式非常简单,rollup.config.js 配置如下

import dts from "rollup-plugin-dts";

const config = [
  // …
  {
    input: "./my-input/index.d.ts",
    output: [{ file: "dist/my-library.d.ts", format: "es" }],
    plugins: [dts()],
  },
];

export default config;
ts-morph

另外一个方案是 ts-morph,详细文档请参考 ts-morph 文档

下面以 element-plus types-definitions.ts 文件进行解释,内容如下

import process from 'process'
import path from 'path'
import { mkdir, readFile, writeFile } from 'fs/promises'
import consola from 'consola'
import * as vueCompiler from 'vue/compiler-sfc'
import glob from 'fast-glob'
import chalk from 'chalk'
import { Project } from 'ts-morph'
import {
  buildOutput,
  epRoot,
  excludeFiles,
  pkgRoot,
  projRoot,
} from '@element-plus/build-utils'
import { pathRewriter } from '../utils'
import type { CompilerOptions, SourceFile } from 'ts-morph'

const TSCONFIG_PATH = path.resolve(projRoot, 'tsconfig.web.json')
const outDir = path.resolve(buildOutput, 'types')

/**
 * fork = require( https://github.com/egoist/vue-dts-gen/blob/main/src/index.ts
 */
export const generateTypesDefinitions = async () => {
  const compilerOptions: CompilerOptions = {
    emitDeclarationOnly: true,
    outDir,
    baseUrl: projRoot,
    preserveSymlinks: true,
    skipLibCheck: true,
    noImplicitAny: false,
  }
  const project = new Project({
    compilerOptions,
    tsConfigFilePath: TSCONFIG_PATH,
    skipAddingFilesFromTsConfig: true,
  })

  const sourceFiles = await addSourceFiles(project)
  consola.success('Added source files')

  typeCheck(project)
  consola.success('Type check passed!')

  await project.emit({
    emitOnlyDtsFiles: true,
  })

  const tasks = sourceFiles.map(async (sourceFile) => {
    const relativePath = path.relative(pkgRoot, sourceFile.getFilePath())
    consola.trace(
      chalk.yellow(
        `Generating definition for file: ${chalk.bold(relativePath)}`
      )
    )

    const emitOutput = sourceFile.getEmitOutput()
    const emitFiles = emitOutput.getOutputFiles()
    if (emitFiles.length === 0) {
      throw new Error(`Emit no file: ${chalk.bold(relativePath)}`)
    }

    const subTasks = emitFiles.map(async (outputFile) => {
      const filepath = outputFile.getFilePath()
      await mkdir(path.dirname(filepath), {
        recursive: true,
      })

      await writeFile(
        filepath,
        pathRewriter('esm')(outputFile.getText()),
        'utf8'
      )

      consola.success(
        chalk.green(
          `Definition for file: ${chalk.bold(relativePath)} generated`
        )
      )
    })

    await Promise.all(subTasks)
  })

  await Promise.all(tasks)
}

async function addSourceFiles(project: Project) {
  project.addSourceFileAtPath(path.resolve(projRoot, 'typings/env.d.ts'))

  const globSourceFile = '**/*.{js?(x),ts?(x),vue}'
  const filePaths = excludeFiles(
    await glob([globSourceFile, '!element-plus/**/*'], {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )
  const epPaths = excludeFiles(
    await glob(globSourceFile, {
      cwd: epRoot,
      onlyFiles: true,
    })
  )

  const sourceFiles: SourceFile[] = []
  await Promise.all([
    ...filePaths.map(async (file) => {
      if (file.endsWith('.vue')) {
        const content = await readFile(file, 'utf-8')
        const hasTsNoCheck = content.includes('@ts-nocheck')

        const sfc = vueCompiler.parse(content)
        const { script, scriptSetup } = sfc.descriptor
        if (script || scriptSetup) {
          let content =
            (hasTsNoCheck ? '// @ts-nocheck\n' : '') + (script?.content ?? '')

          if (scriptSetup) {
            const compiled = vueCompiler.compileScript(sfc.descriptor, {
              id: 'xxx',
            })
            content += compiled.content
          }

          const lang = scriptSetup?.lang || script?.lang || 'js'
          const sourceFile = project.createSourceFile(
            `${path.relative(process.cwd(), file)}.${lang}`,
            content
          )
          sourceFiles.push(sourceFile)
        }
      } else {
        const sourceFile = project.addSourceFileAtPath(file)
        sourceFiles.push(sourceFile)
      }
    }),
    ...epPaths.map(async (file) => {
      const content = await readFile(path.resolve(epRoot, file), 'utf-8')
      sourceFiles.push(
        project.createSourceFile(path.resolve(pkgRoot, file), content)
      )
    }),
  ])

  return sourceFiles
}

function typeCheck(project: Project) {
  const diagnostics = project.getPreEmitDiagnostics()
  if (diagnostics.length > 0) {
    consola.error(project.formatDiagnosticsWithColorAndContext(diagnostics))
    const err = new Error('Failed to generate dts.')
    consola.error(err)
    throw err
  }
}

这里做了以下几件事:

  • 初始化 Project并设置 tsconfig.json 以及输出路径
// 设置 Project 要使用的 tsconfig.json
const TSCONFIG_PATH = path.resolve(projRoot, 'tsconfig.web.json')
const outDir = path.resolve(buildOutput, 'types')
/**
 * fork = require( https://github.com/egoist/vue-dts-gen/blob/main/src/index.ts
 */
export const generateTypesDefinitions = async () => {
  const compilerOptions: CompilerOptions = {
    emitDeclarationOnly: true,
    outDir,
    baseUrl: projRoot,
    preserveSymlinks: true,
    skipLibCheck: true,
    noImplicitAny: false,
  }
  const project = new Project({
    compilerOptions,
    tsConfigFilePath: TSCONFIG_PATH,
    skipAddingFilesFromTsConfig: true,
  })
  // ...
}

  • 添加要编译 .d.ts 的源文件
async function addSourceFiles(project: Project) {
  // 补充类型
  project.addSourceFileAtPath(path.resolve(projRoot, 'typings/env.d.ts'))
  // 源文件路径
  const globSourceFile = '**/*.{js?(x),ts?(x),vue}'
  const filePaths = excludeFiles(
    await glob([globSourceFile, '!element-plus/**/*'], {
      cwd: pkgRoot,
      absolute: true,
      onlyFiles: true,
    })
  )
  const epPaths = excludeFiles(
    await glob(globSourceFile, {
      cwd: epRoot,
      onlyFiles: true,
    })
  )

  const sourceFiles: SourceFile[] = []
  // ...
  return sourceFiles
}
  • 处理不同的文件类型,如 .vue 文件要用解析器 vue/compiler-sfc 进行解析得到对应 script 的内容,因为只有 script 的内容需要转换成.d.ts
async function addSourceFiles(project: Project) {
  // ...

  const sourceFiles: SourceFile[] = []
  await Promise.all([
    ...filePaths.map(async (file) => {
      if (file.endsWith('.vue')) {
        const content = await readFile(file, 'utf-8')
        const hasTsNoCheck = content.includes('@ts-nocheck')

        const sfc = vueCompiler.parse(content)
        const { script, scriptSetup } = sfc.descriptor
        if (script || scriptSetup) {
          // 可能有配置了不需要ts检查的内容
          let content =
            (hasTsNoCheck ? '// @ts-nocheck\n' : '') + (script?.content ?? '')

          if (scriptSetup) {
            const compiled = vueCompiler.compileScript(sfc.descriptor, {
              id: 'xxx',
            })
            content += compiled.content
          }

          const lang = scriptSetup?.lang || script?.lang || 'js'
          // 生成新的临时的文件路径
          const sourceFile = project.createSourceFile(
            `${path.relative(process.cwd(), file)}.${lang}`,
            content
          )
          sourceFiles.push(sourceFile)
        }
      } else {
        // 其他文件直接加入
        const sourceFile = project.addSourceFileAtPath(file)
        sourceFiles.push(sourceFile)
      }
    }),
    ...epPaths.map(async (file) => {
      const content = await readFile(path.resolve(epRoot, file), 'utf-8')
      sourceFiles.push(
        project.createSourceFile(path.resolve(pkgRoot, file), content)
      )
    }),
  ])

  return sourceFiles
}
  • 编译前先对文件的ts 类型做检测。注意这里需要保证一些依赖都已安装,比如 @vue/ 下的一些工具,如果这里检测有莫名其妙的报错,则根据提示去检查是否是提示报错的依赖引用的其他依赖未安装
function typeCheck(project: Project) {
  const diagnostics = project.getPreEmitDiagnostics()
  if (diagnostics.length > 0) {
    consola.error(project.formatDiagnosticsWithColorAndContext(diagnostics))
    const err = new Error('Failed to generate dts.')
    consola.error(err)
    throw err
  }
}
  • 剩下的就是编译输出了
  await project.emit({
    emitOnlyDtsFiles: true,
  })

  const tasks = sourceFiles.map(async (sourceFile) => {
    const relativePath = path.relative(pkgRoot, sourceFile.getFilePath())
    consola.trace(
      chalk.yellow(
        `Generating definition for file: ${chalk.bold(relativePath)}`
      )
    )

    const emitOutput = sourceFile.getEmitOutput()
    const emitFiles = emitOutput.getOutputFiles()
    if (emitFiles.length === 0) {
      throw new Error(`Emit no file: ${chalk.bold(relativePath)}`)
    }

    const subTasks = emitFiles.map(async (outputFile) => {
      const filepath = outputFile.getFilePath()
      await mkdir(path.dirname(filepath), {
        recursive: true,
      })

      await writeFile(
        filepath,
        pathRewriter('esm')(outputFile.getText()),
        'utf8'
      )

      consola.success(
        chalk.green(
          `Definition for file: ${chalk.bold(relativePath)} generated`
        )
      )
    })

组合

处理完打包,接下来还需要把主包的一些文件拷贝到打包目录下一起发布,因为打包目录设置了.gitignore ,所以在发布脚本那里需要先执行选取版本的脚本更改后再打包比较好

最终把所有工作组合到一起,由 gulp 进行任务调度,因为runTask 是启用子进程进行任务执行,相当于并发执行任务了

// build/gulpfile.ts
import { copy } from 'fs-extra'
import { resolve, join } from 'path'
import { configs } from './constants'
import { parallel, series } from 'gulp'
import { copyFile, mkdir } from 'fs/promises'
import { run, runTask, withTaskName } from './utils'
import { outputRoot, projRoot, mainPackage } from './paths'

export const copyFiles = () =>
  Promise.all([
    copyFile(mainPackage, join(outputRoot, 'package.json')),
    copyFile(resolve(projRoot, 'README.md'), resolve(outputRoot, 'README.md'))
  ])

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

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

export const copyFullStyle = async () => {
  await mkdir(resolve(outputRoot, 'dist'), { recursive: true })
  await copyFile(resolve(outputRoot, 'theme-chalk/index.css'), resolve(outputRoot, 'dist/index.css'))
}

// 导出模块任务函数
export * from './build-modules'
export * from './build-full-bundle'
export * from './types-definitions'

export default series(
  // 清理
  withTaskName('clean', () => run('pnpm run clean')),
  withTaskName('createOutput', () => mkdir(outputRoot, { recursive: true })),
  // 打包
  parallel(
    runTask('buildModules'),
    runTask('buildFullBundle'),
    runTask('generateTypesDefinitions'),
    series(
      withTaskName('buildThemeChalk', () => run('pnpm run -C packages/theme-chalk build')),
      copyFullStyle
    )
  ),
  // 拷贝文件
  parallel(copyTypesDefinitions, copyFiles)
)

components-helper

由于 .vue 文件比较特殊,在IDE中提供出色的代码提示需要为组件库提供额外文件,参考 components-helper 即可,这里不再细说

踩坑

执行 pnpm build 会提示 Unknown file extension ".ts" 错误

这是因为 gulp 本身的模块标准是 CommonJSgulpfile.ts 也会被 gulp 视为 cjs 模块

需要安装插件如 @esbuild-kit/cjs-loadersucrase 等进行转换,打包命令改为:gulp --require @esbuild-kit/cjs-loader -f build/gulpfile.tsgulp --require sucrase/register/ts -f build/gulpfile.ts 即可

至此,一个 Monorepo 组件库基于 gulp + rollup 打包分析就完成了

总结

本文先是了解 gulprollup 的基本使用;然后用来打包各自擅长的模块(js、css),结合 pnpm-workspace自动形成一种构建流程;最后通过组合两者设计自己的构建流程。通过这个过程加深了对 Monorepo 项目复杂度的理解

从体验上看无论是通过 pnpm-workspace 自带的能力来控制构建流程还是通过 gulp 自己组装构建流程都有各自方便与不便之处,可根据情况选择

参考