从0到1搭建Monorepo组件库的构建和开发体系

在现如今的前端体系中,无论是阅读源码还是日常开发,应该都绕不过MultiRepoMonoRepo这两种最为常用的代码管理方式。所谓工欲善其事,必先利其器。当我们想要把业务代码写好的时候,良好的代码管理方式以及对应的基建工具就成为了一个非常重要的利器。这篇文章,笔者就会带领大家一步一步的从0到1搭建一个Monorepo项目,并配置统一的构建体系和开发体系。

前言

在搭建项目之前,我们需要先了解一些前置知识。

MonoRepo

Monorepo 是一种项目代码管理方式,指单个仓库中管理多个项目,有助于简化代码共享、版本控制、构建和部署等方面的复杂性。简单来说,在过去,当我们采用MultiRepo 来进行项目管理时,各个项目的构建、工具链、lint等等都需要单独配置,这样难免会造成代码冗余和效率低下。另外,如果项目与项目之间存在依赖关系时,开发环境下进行彼此的调试也十分繁琐。而Monorepo则是为了解决这些问题而生的。

脚手架

社区上比较成熟的MonoRepo工具有很多,例如比较常见的Lerna。但是在考虑到包管理工具上,我们选择了非常优雅快捷的Pnpm ,而恰巧Pnpm的workspace协议本身就让他成为了一个实现MonoRepo的利器。所以,最终还是采用了pnpm作为整体项目的基础脚手架。

初始化项目

我们以一个多包并且独立发布的组件库为例。

首先,我们利用npm init去初始化一个工程,并利用pnpm作为包管理工具。我们先提前下载一些基础的依赖包,如reactreact-domtypescript等等。

csharp 复制代码
npm init
pnpm i react react-dom typescript

然后,我们创建一个packages目录来存放我们需要发布的组件,另外再新建一个utils目录来存放一些工具代码。这些目录的代码实现我会在后续的内容中一步一步的带大家一起完成。

接着,为了搭建Monorepo架构,更加方便组件之间的引用和调试,我们还需要新建一个pnpm-workspace.yaml去指定我们的工作区,配置完成之后,如果可用的 packages 与已声明的可用范围相匹配,pnpm会对声明区域内的package构建一个软链接link到父级的node_modules,这样在引用这个packages的时候,就会找到这个软链接并返回。这样就达到了实时引用和调试的效果了。

yaml 复制代码
packages:
  - "packages/**"
  - "utils/**"

创建完毕之后,我们现在的代码目录是这样的:

go 复制代码
├─packages
├─utils
├─package.json
├─pnpm-loack.yaml
├─package.json
├─pnpm-workspace.yaml
└─tsconfig.json

新建组件

为了模拟真实的开发场景,我们在packages下新建两个组件,一个为UI组件,另一个为工具类函数组件,借此来模拟不同的本地开发场景和构建发布场景。

在packages下新建一个yael-group-button,输出一个UI组件。

js 复制代码
// yael-group-button/src/App.tsx

import React, { FC } from 'react';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  color: string;
}

const Button: FC<ButtonProps> = (props) => {
  const { color, children, ...rest } = props
  return (
    <button style={{background: color}} {...rest}>
      {children}
    </button>
  )
}

export default Button;

并在组件的根目录新建一个index.ts导出我们的组件和interface:

js 复制代码
// yael-group-button/index.ts
import Button from "./src/App";

export { type ButtonProps } from "./src/App";
export default Button;

同样的,我们在packages下新建一个yael-group-utils,以同样的方式导出一个方法:

typescript 复制代码
// yael-group-utils/src/index.ts


/**
 * 导出一个两数相加方法
 * @returns 
 */
export const add = (a: number, b: number) => {
  return a + b
};

完善构建体系

在开发组件的过程中,我们需要用到两套构建工具:

  • 开发时的本地服务,用于编辑调试
  • 最终的构建打包,用于发布生产

无疑,现如今社区里的构建工具非常多,大家可以根据自己组件库的组件类型去做不同的技术选型。

而在笔者看来,在本地开发的时候,webpackvite的插件体系和开发体验是最好的,另外针对npm的生产构建打包当中,rollup无论是在插件生态,产物的体积大小和干净程度都是领先于webpack的。无独有偶,vite在构建生产的产物时所用的构建工具就是rollup,这对本人的技术性选型是十分吻合的。

所以,在本次组件库的搭建当中,会利用到vite去做本地开发的工具,再通过添加部分rollup插件去完成生产发布产物的编译和打包。

接下来我会带领大家,一步一步的完成整个组件库的通用打包的配置开发。

创建入口

首先,我们在utils目录下新建一个pack文件夹,里面存放了所有的构建的配置和启动的脚本代码。 另外,为了方便别的组件可以直接调用,这个pack文件夹本身也作为一个npm包暴露出去。所以,我们先来完善一下打包工具的package.json

我们期望,无论是在哪个组件下,只要在pageage.json中的script下设置了如下命令,通过npm run build就可以启动我们的打包代码。

json 复制代码
  "scripts": {
    "build": "pack",
  },

那么,我们的pack包在packages.json就必须提供一个以pack命名的全局bin脚本,通过main字段来配置我们的工具入口文件,再通过bin字段来向外部暴露一个全局命令。

json 复制代码
{
  "name": "yael-pack",
  "version": "1.0.0",
  "description": "common pack",
  "main": "./bin/index.js",
  "bin": {
    "pack": "./bin/index.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
  }
}

我们先随便在脚本入口文件bin/index.js中写点代码验证一下。

js 复制代码
// bin/index.js
console.log('打包+++')

接着,我们需要在根目录下注册我们这个npm包,怎么注册呢?其实很简单,利用pnpm提供的workspace属性就可以了。

json 复制代码
"devDependencies": {
   "yael-pack": "workspace:*"
}

写好之后,只需要在根目录下执行pnpm i,就可以在顶层的node_modules里的bin目录下,看到我们的命令名称了。

现在,我们切换到任意一个组件目录,去执行pack都能看到生效了:

完善入口文件

我们接着来完善一下脚本的入口文件,不过在写代码之前,我们得先来梳理一下入口文件需要起到的作用,当我们在对应组件下执行pack命令时,能够通过添加对应的参数来执行不同类型的构建流程。主要分为三种:

  • pack npm 构建生成npm包的产物
  • pack js 构建生成umd格式的产物
  • pack dev启动本地服务

所以,我们可以这么来写:

js 复制代码
#!/usr/bin/env node

// 获取命令中携带的参数
const argv = require("minimist")(process.argv.slice(2));
const command = argv._[0];

// 获取构建成npm包的方法
const build = require("./command/build.js")

// 获取构建成umd格式的方法
const buildjs = require('./command/buildJs.js')

// 获取启动本地服务的方法
const runDev = require('./command/dev.js')

// 构建方式的映射
const { MODE } = require("../constants");

const { BUILD_NPM, BUILD_JS, DEV } = MODE;

switch (command) {
  case BUILD_NPM:
    build();
    break;
    
  case BUILD_JS:
    buildjs();
    break;

  case DEV: 
    runDev();
    break;

  default:
    break;
}

入口文件其实非常简单,无非就是根据pack命令后面携带的参数,执行相对应的方法。

接下来,我们只需要一步一步的完善每一种构建类型所对应的方法。这里,我会在bin目录下新建一个command目录来存放各个构建类型所对应的方法。

完善构建NPM格式的方法

当我们需要发布一个npm包时,我们往往需要将我们的代码打包成两种模块规范的产物,分别是ESMCJS,另外针对TS的项目引入,我们最好再生成一份类型声明的产物。 所以此时我们的述求就变得非常简单了:

  • 利用rollup的output属性,生成两种模块规范的产物,分别放在esm和cjs目录下。
  • 利用插件同步生成对应的类型声明文件。

我们可以这么来完善启动npm构建的脚本:

js 复制代码
// bin/command/build.js
const path = require("path");
const { exec } = require("child_process");
const { MODE } = require('../../constants/index.js')
const { log } = require("../../utils/index.js")


module.exports = function() {
  // 获取vite配置文件的路径,后续会说到
  const configPath = path.resolve(__dirname, "../../../vite.config.js");
  // 打包前先删除遗留的打包的产物
  const init = `rm -rf dist`;

  // 拼接打包的命令,这里需要通过cross-env注入一个环境变量,表明是用于打包NPM,后续会解释用途
  const esm = `cross-env BUILD_MODE=${
    MODE.BUILD_NPM
  } vite build --config ${configPath}`;

  // 完整命令
  const command = `${init} && ${esm}`;
  
  // log方法为打印命令执行的实时日志
  // exec执行上一步拼接好的命令
  log(exec(
    command,
    function (error) {
      if (error) {
        process.exit(1);
      }
    }
  ))

}

然后,我们在utils目录下新建一个vite的配置文件,并完善打包的配置

js 复制代码
// vite.config.js

import { defineConfig } from "vite";
import { resolve, join } from "path";
import dts from "vite-plugin-dts";
import babel from "@rollup/plugin-babel";
const { MODE } = require("./pack/constants/index.js");

// 获取命令启动时的目录
const base = process.cwd();
// 获取刚刚注入的环境变量
const { BUILD_MODE } = process.env;

// 这里通过一个方法,根据打包的类型,动态配置rollup的输出类型
// 暂时只有npm的判断,后续会继续完善打包UMD格式的输出
function getOutput(mode) {
  let outputList = "";
  switch (mode) {
    case MODE.BUILD_NPM:
      outputList = [
        {
          dir: "dist/cjs",
          format: "cjs",
          preserveModules: true,
          preserveModulesRoot: "src",
          entryFileNames: "[name].js",
        },
        {
          dir: "dist/esm",
          format: "es",
          preserveModules: true,
          preserveModulesRoot: "src",
          entryFileNames: "[name].js",
        },
      ];
      break;
    default:
      break;
  }

  return outputList;
}

export default defineConfig({
  build: {
    // 设置打包时的默认入口,我们约定好所有的组件的入口都是src/index.ts
    lib: {
      entry: resolve(base, "./index.ts"),
    },
    rollupOptions: {
      external: ['react'],
      // 配置输出目录 
      output: getOutput(BUILD_MODE),
      plugins: [
        // 配置babel
        babel({
          babelHelpers: "runtime",
          extensions: [".ts"],
        }),
      ],
    },
  },
  plugins: [
    // 利用vite-plugin-dts生产类型声明文件,这里只有打包npm格式时才需要用到
    BUILD_MODE === MODE.BUILD_NPM && dts({
      entryRoot: resolve(base, "./"),
      outDir: "dist/types",
    }),
  ],
});

接着,我们还需要完善下组件里的package.json: 通过mainmodule配置不同的模块规范下默认的导出路径,另外通过types字段来配置类型声明文件默认导出路径,以及files字段来配置发布组件时所上传的文件目录。

json 复制代码
{
  "name": "yael-group-button",
  "version": "1.0.0",
  "description": "按钮组件",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "types": "dist/types/index.d.ts",
  "files": [
    "dist"
  ],
  "keywords": [],
  "author": "yael",
  "license": "ISC",
  "peerDependencies": {
    "@babel/runtime-corejs3": "^7.21.0"
  }
}

配置完成之后,我们在Button组件的目录下新增一个命令:

json 复制代码
  "scripts": {
    "build": "pack npm",
  },

运行npm run build:

可以看到,构建的产物和我们预期的一致。

完善构建UMD格式的方法

有时候公司内部会存在一些老旧项目,无法通过安装npm包的形式引入某个组件,这个时候我们就需要将组件打包成UMD的格式,通过script标签的时候引入。

在上面已经完成的代码的基础上,我们来继续完善构建UMD格式的方法。

首先,我们需要明确的是,对比构建NPM,构建UMD的最大区别在于你需要将你的组件方法挂载到全局的window下,所以我们就需要自定义一个全局变量来挂载你的方法。另外,为了更好的区分版本和组件名称,我们在构建之前也需要定义好打包出来的文件名称以及版本。 那么这三个属性可以怎么让构建的脚本知道呢?笔者认为定义在组件下的packages.json是最为合适的。

json 复制代码
{
  "name": "yael-group-utils",
  "version": "1.0.0",
  "moduleName": "yaelUtils",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "types": "dist/types/index.d.ts",
  "files": [
    "dist"
  ],
  "repository": {
    "type": "git",
    "url": "git@git.dev.sh.ctripcorp.com:corp-framework-components/corp-fe-libs.git"
  },
  "scripts": {
    "build": "pack npm",
    "build:umd": "pack umd"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

可以看到,package.json里本身就携带了nameversion字段,对应上了我们上面说到的文件名和版本,我们只需要再定义一个moduleName字段,来对应全局变量的名称即可。

接着,我们只需要在构建umd的脚本里获取这些信息,然后通过注入环境变量的方式去告诉rollup即可。

ini 复制代码
// bin/command/buildJs.js
const path = require("path");
const { exec } = require("child_process");
const { MODE } = require("../../constants/index.js");
const { log } = require("../../utils/index.js");

/**
 * 获取当前打包项目的package.json信息
 */
const pkgPath = path.resolve(process.cwd(), "package.json");
const pkg = require(pkgPath);
const { name, version, moduleName } = pkg;

module.exports = function () {

  const configPath = path.resolve(__dirname, "../../../vite.config.js");
  const initDist = `rm -rf dist`;

  // 注入环境变量
  const umd = `cross-env BUILD_MODE=${
    MODE.BUILD_JS
  } VERSION=${version} FILE_NAME=${name} NAME=${moduleName} vite build --config ${configPath}`;

  const command = `${initDist} && ${umd}`;

  log(
    exec(
      command,
      function (error) {
        if (error) {
          process.exit(1);
        }
      }
    )
  );

};

针对UMD,我们再调整下vite.config.js的代码:

js 复制代码
import { defineConfig } from "vite";
import { resolve, join } from "path";
import dts from "vite-plugin-dts";
import babel from "@rollup/plugin-babel";
const { MODE } = require("./pack/constants/index.js");

const base = process.cwd();
const { BUILD_MODE, VERSION, FILE_NAME, NAME } = process.env;

function getOutput(mode) {
  let outputList = "";
  switch (mode) {
    case MODE.BUILD_NPM:
      outputList = [
        {
          dir: "dist/cjs",
          format: "cjs",
          preserveModules: true,
          preserveModulesRoot: "src",
          entryFileNames: "[name].js",
        },
        {
          dir: "dist/esm",
          format: "es",
          preserveModules: true,
          preserveModulesRoot: "src",
          entryFileNames: "[name].js",
        },
      ];
      break;
    case MODE.BUILD_JS:
      outputList = [
        {
          name: `${NAME}`,
          dir: `dist/${VERSION}`,
          format: "umd",
          entryFileNames: `${FILE_NAME}.js`,
        },
      ];
      break;
    default:
      break;
  }

  return outputList;
}

export default defineConfig({
  build: {
    lib: {
      entry: resolve(base, "./index.ts"),
    },
    rollupOptions: {
      external: ['react'],
      output: getOutput(BUILD_MODE),
      plugins: [
        babel({
          babelHelpers: "runtime",
          extensions: [".ts"],
        }),
      ],
    },
  },
  plugins: [
    BUILD_MODE === MODE.BUILD_NPM && dts({
      entryRoot: resolve(base, "./"),
      outDir: "dist/types",
    }),
  ],
});

我们在yael-group-utils下新增一个打包umd的命令:

json 复制代码
  "scripts": {
    "build:umd": "pack umd"
  },

执行npm run build:umd,我们可以看到产物:

完善本地服务代码

无论是调试构建产物还是调试开发代码,我们都需要一个本地服务去做这些事情,基于vite本身的预编译和ES Mobule的机制,我们可以很轻松的做到这一点。

首先,我们在utils目录下新建一个dev目录和一个index.html,所有的调试代码都由dev/index.tsx来导出,最终在index.html来呈现。

然后,来完善一下本地服务的启动脚本代码:

js 复制代码
// bin/command/dev.js
const path = require("path");
const { exec } = require("child_process");
const { log } = require('../../utils')


module.exports = function() {
  // 拼接启动命令的根目录路径
  const rootDevPath =  path.resolve(process.cwd(), "../../utils");
  // 拼接配置文件的路径
  const configPath = path.resolve(__dirname, "../../../vite.config.js");

  const devCli = `${
    "vite"
  } ${rootDevPath} --config ${configPath}`;
  
  log(exec(devCli, function(error) {
    if (error) {
      process.exit(1);
    }
  }))

}

dev目录下的入口也来补充一下:

jsx 复制代码
// utils/dev/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './src';

const root = ReactDOM.createRoot(document.getElementById('root') as Element)

root.render(<App/>)
html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/dev/index.tsx"></script>
  </body>
</html>

这样,我们在任意组件下新增一个本地服务启动的命令,执行npm run dev就可以调试我们的代码了。

json 复制代码
  "scripts": {
    "build": "pack npm",
    "dev": "pack dev"
  },

完善CHANGELOG和版本管理

当我们更新了某个组件的功能之后,我们往往需要将更新的功能点或者修复的bug,清晰的记录在CHANGELOG.md里,如果同仓库里的组件存在相互依赖的情况,在升级某个基础组件的同时,我们还跌将依赖该组件的上层组件同步升级一版本。

那么这些事情能否利用一个工具来有序的完成呢?

答案就是changesets

应该怎么配置呢?其实非常简单。

首先,我们在根路径下安装:

css 复制代码
pnpm i @changesets/cli -D -w

然后,执行初始化命令:

csharp 复制代码
pnpm changeset init

当我们改动某个组件之后,我们执行pnpm changeset,来缓存我们的改动,当我们确认修改已经完成,并且需要发布组件之后,我们执行pnpm changeset version,就可以跟着步骤一步一步的完善我们的改动信息,最终changeset会同步修改我们对应组件的版本号和添加一个CHANGELOG.md文件。非常方便!

最后

以上的所有代码都保存在了github仓库下:

github.com/HENGGE1226/...

如果觉得写得还不错同学,希望能点个小小的star。

相关推荐
__WanG3 分钟前
Flutter将应用打包发布到App Store
前端·flutter·ios
leluckys6 分钟前
flutter 专题十七 Flutter Flar动画实战
前端·flutter
豆包MarsCode21 分钟前
我用豆包MarsCode IDE 做了一个 CSS 权重小组件
开发语言·前端·javascript·css·ide·html
22x艾克斯31 分钟前
Web Notifications API-让网页也能像QQ一样实现消息通知
前端
X 西安33 分钟前
第十章JavaScript的应用
开发语言·javascript·ecmascript
想你的风吹到了瑞士39 分钟前
变量提升&函数提升
前端·javascript·vue.js
生椰拿铁You1 小时前
12 —— Webpack中向前端注入环境变量
前端
Huazzi.1 小时前
免费好用的静态网页托管平台全面对比介绍
前端·网络·github·web
吃土少女古拉拉1 小时前
前端和后端
前端·学习笔记
夫琅禾费米线2 小时前
leetcode2650. 设计可取消函数 generator和Promise
开发语言·javascript·leetcode·ecmascript