跳至内容
返回

Monorepo 下的模块包设计实践

发布于:  at  16:00

Monorepo 下的模块包设计实践

前言

本文主要面向 前端 、Node.js 开发者 ,业务中使用 Monorepo 组织 应用模块 ,抛砖引玉探讨下:

  1. 怎样设计一个 共享配置包 (配置、类型、utils、枚举等),可以同时在 前端Node.js、 Vite 等项目
  2. UI 组件库 怎么打包?组件库怎么支持服务端渲染(SSR)?有哪些好的最佳实践?
  3. 怎么使用原生语言(Rust / C++ / Golang 等)编写的模块包?

本文演示的代码仓库见:monorepo(使用 pnpm,其它 Monorepo 工具同理)

image.png

为什么设计模块包?

随着 Monorepo 代码越堆越多、多人员协作情况下,不可避免遇到如下问题:

image.png

image.png

有哪些类型的模块包?

由于历史原因,JavaScript 模块化系统一直没有统一规范,大致发展过程是:CJS → AMD → CMD → UMD → ESM,这给设计一个 跨应用使用的模块 带来不少麻烦。

在设计模块包之前,有必要先了解下目前主流的模块类型和规范(esm、cjs、umd)。


模块类型/格式/规范

描述

导出语法

使用语法

备注

esm(ES Modules)

JavaScript 官方的标准化模块系统(草案),在标准化道路上花了近 10 年时间。
适用于:
* Browser
* Node.js ≥ 12.17

export default foo
export const foo = 1;
export { bar as foo } from ”

import foo from ‘name’
import { foo } from ‘name’
import { foo as bar } from ‘name’


* JS 标准,优先考虑使用该模块
* Tree Shaking 友好

cjs(CommonJS)

Node.js 的模块标准,文件即模块。
适用于:
* Node.js

module.exports = foo
exports .foo = 1;

const foo = require (‘name’);
const { foo } = require(‘name’);

* 对 Tree Shaking 不友好
* 前端也可以使用 cjs 格式,依靠构建工具(webpack / rollup)

umd(Universal Module Definition)

umd 严格意义上 不算规范 ,只是社区出的通用包模块结合体的 格式 ,兼容 CJS 和 AMD 格式。
浏览器端用来挂载全局变量 (如:window.*
适用于:
* Browser( external 场景
* Node.js(较少)

(function (root, factory) {
if (// amd) {
} else if (//cjs) {
} else (//global) {
}
})(this, function() {})

window.React
window.ReactDOM
$

通过上面模块的对比,对于 现阶段 选择模块 规范最佳实践 是:

package.json 模块声明

如果模块设计是一门艺术,package.json 就是这门艺术的 使用说明书 !在实际开发过程中,有不少开发者并不能正确配置 package.json。

基础信息

大部分字段来源于 npm 官方 的定义,描述一个包的基础信息:

// package.json
{
    // 包名(示例:import {} from 'your-lib')
    "name": "your-lib",
    "version": "1.0.0",
    "description": "一句话介绍你的包",
    // 发布到 npm 上的目录,不同于 `.npmignore` 黑名单,`files` 是白名单
    "files": ["dist", "es", "lib"],
    // 安全神器,不发到 npm 上
    "private": true,

    /**
     * 包依赖
     */
    // 运行时依赖,包消费者会安装的依赖
    "dependencies": {
        "lodash": "^4.17.21",
        // ❌ "webpack": "^5.0.0",
        // ❌ "react": "^17.0.0" 不推荐将 react 写进依赖
    },
    "peerDependencies": {
       "react": "^16.0.0 || ^17.0.0",
       "react-dom": "^16.0.0 || ^17.0.0"
    },
    // 开发时依赖,使用方不会安装到,只为包开发者服务
    "devDependencies": {
        "rollup": "^2.60.2",
        "eslint": "^8.5.0",
        "react": "^16.0.0",
        "react-dom": "^16.0.0"
    }
}

这里要注意的点:

version

遵循 semver 语义化版本(验证小工具),格式一般为 {major}.{minor}.{patch}

我们来看几个 易错 的例子:

image.png

dependencies, devDependencies, peerDependencies

模块包的依赖,很多模块开发者容易写错的地方。这里用 生产者 表示 模块包开发者,消费者 表示使用模块包的应用

private

不发布的包,记得设置为 true,避免误操作 npm publish 发到外网造成安全问题。

# packages/private/package.json
{
  "private": true
}

$ npm publish
npm ERR! code EPRIVATE
npm ERR! This package has been marked as private
npm ERR! Remove the 'private' field from the package.json to publish it.

模块类型

声明当前包属于哪种模块格式(cjs、esm),不同条件加载不同的模块类型,类型的配置字段源于:

单入口(main, module, browser)

如果是单入口包,始终从包名导出 import from 'your-lib',可以按如下配置:

{
    // -----单入口----
    // 入口文件(使用 cjs 规范)
    "main": "lib/index.js",
    // 入口文件(使用 esm 规范)
    "module": "es/index.js",
    // 包类型导出
    "typings": "typings/index.d.ts",
    // 浏览器入口
    "browser": "dist/index.js",
    "sideEffects": false
}

参数说明:

多入口(exports, browser)

多入口包,例如 import from 'your-lib/react'import from 'your-lib/vue',推荐配置:

{
     // ----多入口---
    "exports": {
        "./react": {
            "import": "dist/react/index.js",
            "require": "dist/react/index.cjs"
        },
        "./vue": {
            "import": "dist/vue/index.js",
            "require": "dist/vue/index.cjs"
        }
    },
    "browser": {
        "./react": "dist/react/index.js",
        "./vue": "dist/vue/index.js"
    },
    "sideEffects": false
}

参数说明:


对 package.json 了解后,开始从常见的模块包看下如何进行设计。

怎样设计模块包?

初始化一个 Monorepo 示例项目(包含 apps 和 packages),目录结构如下(其中 apps 包含常见的应用类型):

- apps(前端、后端)
    - deno-app
    - next-app
    - node-app
    - react-app
    - umi-app
    - vite-app
    - vite-react-app
- packages(模块包)
    - shared(共享配置模块)
        - configs
            - tsconfig.base.json
        - types
        - utils
            - index.ts
        - package.json
    - ui(组件库)
        - react
        - vue
        - package.json
    - native(Rust/C++ 原生模块编译成 npm,用在 Node.js)
        - src
            - lib.rs
        - package.json
- .npmrc
- package.json
- pnpm-workspace.yaml

每个模块包的设计按照下面的步骤展开介绍:

  1. 应用中如何使用?
  2. 怎么构建?
  3. 怎么配 package.json
  4. 效果展示

共享配置模块(packages/shared)

现在要解决枚举、配置、工具方法的复用,希望同时在所有项目中可使用

使用方式

应用中的使用如下:

  1. 项目中声明模块的依赖,workspace:* 表示使用当前 Monorepo 的 shared 模块包
// apps/*/package.json
{
  "dependencies": {
     "@infras/shared": "workspace:*"
  }
}

为了更方便地调试 Monorepo 包模块,这里我们使用 pnpm workspace 协议(同时 yarn 也支持了这一协议)。

  1. 在项目中使用 import 引 esm 格式、require 引 cjs 格式、常用的项目配置 tsconfig.json
// apps/*/index.{ts,tsx,jsx}
import { AppType } from '@infras/shared/types';
import { sum } from '@infras/shared/utils';

console.log(AppType.Web); // 1
console.log(sum(1, 1));   // 2

// apps/*/index.js
const { AppType } = require('@infras/shared/types');
const { sum } = require('@infras/shared/utils');

// apps/*/tsconfig.json
{
- "extends": "../../../tsconfig.base.json"
+ "extends": "@infras/shared/configs/tsconfig.base.json"
}

构建出 esm、cjs 格式

上文讲到过,我们最后同时打出 esm 和 cjs 格式的包,这样使用方可以按需取用。那么,我们应该用哪个构建工具呢?

这里可选的编译/打包工具:

两者区别:编译(a → a’、b → b’)、打包(ab → c,All in One)

经过实践,这里选择 tsup,可以快速的、方便的、开箱即用的构建出 esm、cjs 格式的包,同时还有类型。

用 tsup 不用 tsc/rollup/babel 的原因:共享配置模块其实只需要 tsc 就可以解决,用不上 rollup 丰富的生态,工具配置多又麻烦。还有一点是编译打包速度上 tsup 更快。

打包配置 tsup.config.ts 如下:

// packages/shared/tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['utils/index.ts', "types/index.ts"],
  clean: true,
  dts: true,
  outDir: "dist",
  format: ['cjs', 'esm']
});

执行下 **tsup** 会生成 **dist** 目录,结构如下:

# packages/shared
- dist
    - utils
        - index.js(cjs)
        - index.mjs(esm)
    - types
        - index.js
        - index.mjs
- utils
- types
- package.json

注:不将多入口 utilstypes 目录放到 src 目的是为了在 Monorepo 项目中有 更好的 TS 类型支持! 实际是用了障眼法,将类型提示指向源文件 ts,真正使用的是 dist 产物目录,比如:

- shared

- dist

- react

- index.js(应用构建加载的入口)

- react

- index.ts(编辑器类型提示的入口)

package.json

通用模块包的 package.json 如下,因为是 多入口 ,这里用 exportsbrowser的组合导出了 cjs 和 esm 格式包。

// packages/shared/package.json
{
  "name": "@infras/shared",
  "version": "0.0.1",
  "description": "An infrastructure monorepo shared library for all projects and apps.",
  "browser": {
    "./types": "./dist/types/index.mjs",
    "./utils": "./dist/utils/index.mjs"
  },
  "exports": {
    "./types": {
      "import": "./dist/types/index.mjs",
      "require": "./dist/types/index.js"
    },
    "./utils": {
      "import": "./dist/utils/index.mjs",
      "require": "./dist/utils/index.js"
    }
  },
  "scripts": {
    "prepare": "npm run build",
    "dev": "tsup --watch",
    "build": "tsup"
  },
  "devDependencies": {
    "tsup": "^5.10.3"
  }
}

同时在使用 pnpm publish 发包的时候会移除 scripts.prepare源码实现

image.png

运行

在前端项目中使用:pnpm start --filter "react-app"

image.png

在 Node.js 项目中使用 pnpm start --filter "node-app"

image.png

Vite 中使用 pnpm start --filter "vite-app"

image.png

组件库 UI 模块(packages/ui)

在 Monorepo 微前端组织架构下,组件库复用的场景可以有效提升研发效率。

使用方式

应用/项目引入方式如下:

  1. 项目中声明模块的依赖,workspace:* 表示使用当前 Monorepo 的 ui 模块包,若不是 Vite 应用需要配置下 dependenciesMeta['``@infras/ui'].``injected: true
// apps/*/package.json
{
  "dependencies": {
     "@infras/ui": "workspace:*"
  },
  // 非 Vite 应用
  "dependenciesMeta": {
    "@infras/ui": {
      "injected": true
    }
  }
}

dependenciesMeta 起因

这里有 dependenciesMeta 配置非常有意思,源于 pnpm(≥ 6.20)用来解决 Monorepo 下 React 多版本实例导致的 Invalid hook call 问题。

image.png

问题的原因在于:UI 组件库 devDependencies 中的 React 版本和 应用 中的 React 版本不一致,如下引用结构,应用使用的是 React@17,而组件库使用的是 React@16。(issues:react#13991pnpm#3558rushstack#2820组件多实例问题 等):

--- react-app
 |  |- react@17.0.0
 |- @infras/ui
 |  |- react@16.0.0 <- devDependencies

在此之前,要解决这个问题,之前有两种方案:

而 pnpm 6.20 后推出的 dependenciesMeta ,实际上是 UI 组件库包硬链到应用里,解决 react 组件库本地开发和 peerDependencies 的问题:

 apps
   node_modules
     @infras/ui
-      node_modules
  1. 前端使用:分别在 React、Vue 项目中引入组件库
// React 应用: apps/*/index.{ts,tsx,jsx}
import { Component } from '@infras/ui/react';
<Component />

// Vue 应用: apps/*/index.vue
<script setup lang="ts">
import { Component } from '@infras/ui/vue';
</script>
<template>
    <Component />
</template>

对于不同前端框架的组件, 不建议从一个入口导出 (即 import { ReactC, VueC } from 'ui'),这样会给应用的按需编译带来很大的麻烦!

  1. 组件服务端渲染
// apps/node-app/index.js
const { Component } = require('@infras/ui/react');
const React = require('react');
const ReactDOMServer = require('react-dom/server');

console.log('SSR: ', ReactDOMServer.renderToString(React.createElement(Component)));

为了同时构建 React、Vue 组件库模块 ,包目录设计大致如下:

# packages/ui
- react
    - Component.tsx
    - index.ts
- vue
    - Component.vue
    - index.ts
- package.json

构建出 esm、cjs、umd

问个小问题:一般大型组件库需要构建出 cjs 格式,面向场景是什么?

选择 Rollup 来同时打包 React、Vue 组件库,需要有几点注意:

rollup 配置如下:

// packages/ui/rollup.config.js
import { defineConfig } from 'rollup';
...

export default defineConfig([
  {
    input: 'react/index.tsx',
    external: ['react', 'react-dom'],
    plugins: [...],
    output: [
      {
        name,
        file: './dist/react/index.js',
        format: 'umd',
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM'
        }
      },
      {
        name,
        file: './es/react/index.js',
        format: 'es',
      },
      {
        name,
        file: './lib/react/index.cjs',
        format: 'commonjs',
      }
    ]
  },
  {
    input: 'vue/index.ts',
    external: ['vue'],
    plugins: [...],
    output: [
      {
        name,
        file: './dist/vue/index.js',
        format: 'umd',
        globals: {
          vue: 'vue',
        }
      },
      {
        name,
        file: './es/vue/index.js',
        format: 'es',
      },
      {
        name,
        file: './lib/vue/index.cjs',
        format: 'commonjs',
      }
    ]
  }
])

执行 rollup --config rollup.config.js 后,就会生成 **dist****es****lib**目录:

# packages/ui
- dist
    - react
    - vue
- es
    - react
    - vue
- lib
    - react
    - vue
- react
- vue
- package.json

package.json

组件库的 package.json 同样是 多入口 ,和通用模块一样使用 exports 的组合导出了 cjs、esm、umd 格式包。

{
  "name": "@infras/ui",
  "version": "1.0.0",
  "description": "An infrastructure monorepo ui library for all front-end apps.",
  "browser": {
    "./react": "./es/react/index.js",
    "./vue": "./es/vue/index.js"
  },
  "exports": {
    "./react": {
      "import": "./es/react/index.js",
      "require": "./lib/react/index.cjs",
      "default": "./dist/react/index.js"
    },
    "./vue": {
      "import": "./es/vue/index.js",
      "require": "./lib/vue/index.cjs",
      "default": "./dist/vue/index.js"
    }
  },
  "scripts": {
    "start": "npm run dev",
    "dev": "rollup --config rollup.config.js --watch",
    "build": "rollup --config rollup.config.js",
    "prepare": "npm run build",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "react": "^16.0.0 || ^17.0.0",
    "react-dom": "^16.0.0 || ^17.0.0",
    "vue": "^3.0.0"
  },
  "devDependencies": {
    "typescript": "^4.5.2",
    "@babel/...": "",
    "rollup/...": "^2.60.2"
  }
}

组件库在 package.json 声明有一点 特别注意

{
+ "browser": {
+   "./react": "./es/react/index.js",
+   "./vue": "./es/vue/index.js"
+ },
}

原因:Webpack 5 以下不支持 exports 配置 webpack#9509。 Webpack 4 优先走 browser,而 Webpack 5 优先走 exports

运行

启动 React 应用(pnpm start --filter "react-app"):

image.png

启动 Vue 应用(pnpm start --filter "vite-app"):

image.png

服务端渲染(pnpm start --filter "node-app"):

image.png

原生语言模块(packages/native)

有趣的是,我们可以在 Monorepo 中使用 Rust / Golang 编写的 npm 包模块,处理一些 CPU 密集型任务。

使用方式

在 Node.js 应用的使用方式如下:

  1. 项目中声明原生语言模块的依赖
// apps/node-app/package.json
{
  "dependencies": {
     "@infras/rs": "workspace:*"
  }
}
  1. Node.js 中调用:
// apps/node-app/index.js
const { sum } = require('@infras/rs');
console.log('Rust \`sum(1, 1)\`:', sum(1, 1)); // 2

// apps/node-app/index.mjs
import { sum } from '@infras/rs';

构建出 cjs

这里使用 napi-rs 初始化一个 Rust 构建的 npm 模块包,napi-rs 并没有构建出 esm 格式的包,而是选择用 cjs 格式来兼容 esm(相关 node#40541

# packages/rs
- src
    - lib.rs
- npm
- index.js
- index.d.ts
- package.json
- Cargo.toml

package.json

直接使用 napi-rs 初始化出来的 package.json,无须进行修改即可使用。

{
  "name": "@infras/rs",
  "version": "0.0.0",
  "type": "commonjs",
  "main": "index.js",
  "types": "index.d.ts",
  "devDependencies": {
    "@napi-rs/cli": "^2.0.0"
  },
  "scripts": {
    "prepare": "npm run build",
    "artifacts": "napi artifacts",
    "build": "napi build --platform --release",
    "build:debug": "napi build --platform",
    "version": "napi version"
  }
}

运行

Node 项目中 pnpm start --filter "node-app",这样看 Rust 编译后的函数执行效率比 Node.js 原生快不少( 8.44 ms0.069ms

image.png

发布模块包

如果有发包需求,可以有以下两种方式:

命令行发布

将 Monorepo 非 private: true 的模块包发到 npm 源上,最简单的方式是执行 publish 命令。

$ pnpm -r publish --tag latest

如果想增加包发布的 changelog,可以参考 using-changesets

从平台侧发布

当然你也可以通过字节内部的 AddOne 平台在线对 npm 包进行构建、发布和版本管理,相比 命令行发布 更加 标准化 、更利于包的维护,了解更多

image.png

总结

读到这里,想必大家对 模块标准 有一定的了解,知道一些常见场景下 如何进行模块设计 ,这里做个总结:

参考


在以下平台分享此文章:

上一篇
zsh 冷启动速度优化(Oh My Zsh)