pnpm搭建Monorepo工程涵盖集成ts + eslint + prettier + husky + lint-staged + commitizen

921 阅读8分钟

用pnpm搭建menorepo工程

初始化项目

本项目采用monorepo,所以优先安装pnpm

  npm install -g pnpm

创建一个空项目,执行 pnpm init 生成package.json文件

  pnpm init

在根目录新建pnpm-workspace.yaml,内容如下:

    packages:
        - 'packages/*'

我们所有的 packages 都放在 packages 目录下。在根目录创建 packages文件夹。 在packages下分别创建两个文件夹 corevue 且分别在 corevue 文件夹下执行命令 npm initname分别为 @mysentry/core@mysentry/vue

常用的monorepo pnpm命令

-w, --workspace-root

在根目录执行命令,比如在根目录安装依赖,那么这个依赖可以在所有的packages中使用

// 例如根目录安装rollup打包
pnpm add rollup -w -D

-F<package_name>, --filter <package_name>

在过滤的指定包运行命令,我们可以通过下面的命令在指定的package安装依赖,这个依赖只可以在该package中使用

pnpm -F @mysentry/core add lodash

给@mysentry/vue添加@mysentry/core依赖

pnpm install @mysentry/core -r --filter @mysentry/vue 

可以看到vue文件夹下的package.json文件中的dependencies中就存在了@mysentry/core

集成TypeScript

安装依赖

// 根目录执行
pnpm install typescript@4.9.4 rollup-plugin-typescript2 tslib -D -w

初始化

// 根目录执行
pnpm tsc --init

初始化后会在根目录生成.tsconfig.json文件,并修改为如下内容:

{
  "compilerOptions": {
    "outDir": "dist",           // 输出的目录
    "sourceMap": true,          // 开启 sourcemap
    "target": "es2016",         // 转译的目标语法
    "module": "esnext",         // 模块格式
    "moduleResolution": "node", // 模块解析方式
    "strict": false,            // 关闭严格模式,就能使用 any 了
    "resolveJsonModule": true,  // 解析 json 模块
    "esModuleInterop": true,    // 允许通过 es6 语法引入 commonjs 模块
    "jsx": "preserve",          // jsx 不转义
    "lib": ["esnext", "dom"],   // 支持的类库 esnext 及 dom
    "baseUrl": ".",             // 当前目录,即项目根目录作为基础目录
  }
}

集成Eslint + prettier

安装Eslint

根目录下执行

pnpm install eslint@8.29.0 -D -w

安装typescript解释器及ts规则

pnpm install @typescript-eslint/parser@5.46.1 @typescript-eslint/eslint-plugin@5.46.1

创建.eslintrc文件

此文件为eslint配置文件

{
  // 配置root后就不会向上寻找配置
  "root": true,
  // ts 解释器
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    // eslint官方扩展,无需再安装npm包就可使用
    "eslint:recommended",
    // 通过使用这个扩展,可以继续利用 ESLint 的核心推荐规则集,同时避免与 TypeScript 类型系统的检查产生不必要的冲突
    "plugin:@typescript-eslint/eslint-recommended",
    // 为 TypeScript 项目提供的一个推荐规则集
    "plugin:@typescript-eslint/recommended",
  ],
  "rules": {
    "no-useless-escape": "off",
    "@typescript-eslint/interface-name-prefix": "off",
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-empty-function": "off"
  }
}

创建.eslintignore文件

此文件是为了告诉eslint忽略哪些文件不做检查

dist
errorJson.js
browser.js
node_modules
test
.gitignore
.prettierignore
scripts/*
rollup.config.mjs

添加Lint命令

package.json中配置lint命令

"scripts": {
  // 省略...
  "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
}

安装Prettier

pnpm install prettier -D -w

创建.prettierrc.json

{
  // 单行100字符
  "printWidth": 100, 
  // 缩进
  "tabWidth": 2,
  // 空格缩进
  "useTabs": false,
  // 分号
  "semi": true,
  // 单引号
  "singleQuote": true,
  // 箭头函数参数 单个参数不需要括号
  "arrowParens": "avoid",
  // 不检测CRLF or LF
  "endOfLine": "auto"
}

将代码格式化交给Prettier

我们引入prettier会和eslint存在格式化代码冲突,我们将代码格式化交给prettiereslint负责ts检查。

pnpm install --save-dev eslint-plugin-prettier eslint-config-prettier -D -w

修改.eslintrc

{
  "root": true,
  // ts 解释器
  "parser": "@typescript-eslint/parser",
  "plugins": [
  	"@typescript-eslint", 
+  	"prettier"
  ],
  "extends": [
    // eslint官方扩展,无需再安装npm包就可使用
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "rules": {
+   "prettier/prettier": "error",
    "no-useless-escape": "off",
    "@typescript-eslint/interface-name-prefix": "off",
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-empty-function": "off"
  }
}

集成husky + lint-staged

下载husky

pnpm install husky -D -w // 9.0.6

初始化husky

// 执行此命令初始化 v9版本
npx husky init

ps:注意此命令是V9版本husky配置,V8版本看此处husky版本文档

修改pre-commit文件

执行命令后会生成.husky文件夹,修改.husky文件夹下pre-commit文件

- npm test
+ npm run lint

测试husky

修改任意文件,使得不符合eslint规范,然后执行git add .,再执行git commit -m '测试husky',会提交失败并且将不符合eslint规范得地方抛出为成功。

下载lint-staged

lint-staged是配置只检查暂存区的代码进行检验

pnpm install lint-staged -w -D // 15.2.0

配置lint-staged

package.json文件中添加

"lint-staged": {
    "**/*.{ts,tsx,json}": [
      "prettier --write",
      "eslint --fix"
    ]
 },

pre-commit文件修改如下:

- npm run lint
+ npx lint-staged

测试lint-staged

我在gitee上修改core/src/index.ts文件,将不符合规范得代码保存,本地拉取后,本地修改core/src/index.ts文件,只检查了core/src/index.ts文件,视为正确。

集成commitizen + cz-customizable

git提交约定,我们将采用Angular提交规范

Angular提交规范

Angular提交规范(中文)

全局安装commitizen

npm install -g commitizen  

安装cz-customizable

此包为自定义提交规则,我们暂时不用,可以直接看下一步

pnpm install cz-customizable -D -w

Commitizen适配器

这里使用得得提交规则为Angular

pnpm install cz-conventional-changelog -D -w
  • 在项目中安装cz-conventional-changelog 适配器依赖
  • 将适配器依赖保存到package.jsondevDependencies字段信息
  • package.json手动新增config.commitizen字段信息,主要用于配置cz工具的适配器路径:
"devDependencies": {
 "cz-conventional-changelog": "^3.3.0"
},
"config": {
  "commitizen": {
    "path": "./node_modules/cz-conventional-changelog"
  }
}

接下来可以使用cz的命令**git cz代替git commit进行提交说明**

然后我们发现用git commit -m '非法提交'任然生效,后面我们将解决这个问题

commitlint

校验提交说明是否符合规范,安装校验工具

npm install --save-dev @commitlint/cli

@commitlint/config-conventional

安装符合Angular风格的校验规则

npm install --save-dev @commitlint/config-conventional 

配置文件 commitlint.config.js

在根目录下创建commitlint.config.js文件

module.exports = {
  extends: ['@commitlint/config-conventional'],
};

使用husky中commit-msg钩子验证

.husky文件夹下创建commit-msg文件,内容如下:

npx --no-install commitlint --edit $1

验证

使用git commit -m '不符合angular规范'提交代码,会失败即为成功。

创建Packages

创建子包

packages文件夹下创建core文件夹。在core文件夹下,执行npm init。修改package.json如下:

{
  "name": "@mysentry/core",
  // 打包配置
  "buildOptions": {
    "name": "MySentryCore",
    "formats": [
      "esm",
      "cjs"
    ]
  },
  // 版本号
  "version": "1.0.2",
  "description": "",
  // 包入口
  "main": "dist/core.cjs.js",
  "module": "dist/core.esm.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "lodash": "^4.17.21"
  }
}

packages/core下创建src目录,在src目录下创建core目录和index.t

index.ts入口文件,编写如下代码:

import { SDK_NAME, add } from './core/index'
function init ():void {
  console.log('init')
  let sum = add(1, 3)
}
export default {
  SDK_NAME,
  init
}

packages/core/src/core目录下,分别创建index.tsadd.tsconfig.ts

index.ts

export * from './add'
export * from './config'

add.ts

export const add = (a: number, b:number): number => {
    return a + b
}

config.ts

export const SDK_NAME = 'Sentry';

接下来,在packages目录下创建vue文件夹,在vue目录下执行npm init,修改package.json如下:

{
  "name": "@mysentry/vue",
  "buildOptions": {
    "name": "MySentryVue",
    "formats": [
      "esm",
      "cjs",
      "umd"
    ]
  },
  "version": "2.0.1",
  "description": "",
  "main": "dist/vue.cjs.js",
  "module": "dist/vue.esm.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
  	// 此依赖是执行上面提到的给@mysentry/vue添加@mysentry/core依赖命令  pnpm install @mysentry/core -r --filter @mysentry/vue 
    "@mysentry/core": "workspace:^"
  }
}

创建src文件夹,创建index.ts文件并编写如下代码:

import { reactive } from "./vue/index";


export default {
    reactive
}

src文件夹再创建vue文件夹并创建index.ts,编写如下代码:

export * from './reactive'

vue目录下创建reactive.ts

import core from '@mysentry/core';

export const reactive = () => {
    console.log(core.SDK_NAME)
}

Rollup打包

我们将基于已经大家安好的Monorepo项目进行rollup打包

依赖安装

因为vue采用的是rollup进行打包, 我们也采用rollup

# 安装rollup
pnpm install rollup -D -w

# 安装rollup插件
pnpm install @rollup/plugin-json @rollup/plugin-node-resolve -D -w

# 安装execa
pnpm install execa -D -w
  • rollup是一个类似于 webpack 的打包工具,rollup文档
  • @rollup/plugin-json 是一个能让我们从json文件中导入数据的插件。
  • @rollup/plugin-node-resolve是一个能让我们从node_modules中引入第三方模块的插件。
  • execa 是一个能让我们手动执行脚本命令的一个工具。

依赖全部安装完毕后,根目录packages.json文件的devDependencies信息如下:

"devDependencies": {
    "@rollup/plugin-json": "^6.1.0",
    "@rollup/plugin-node-resolve": "^15.2.3",
    "execa": "^8.0.1",
    "rollup": "^4.13.0"
 }

脚本命令

在实现之前,我们先在根目录下创建scripts目录,并新建一个build.js文件,其内容如下:

console.log('build.js')

然后,在根目录package.json文件中添加打包命令,如下:

"scripts": {
	"build": "node scripts/build.js"
}

最后,在控制台中执行build命令,可以在终端成功看到打印内容:

# 执行命令
pnpm run build

# 输出内容
node scripts/build.js
build.js

打包

现在,我们思考一个问题:因为我们packages目录下可能会存在很多个子包,所以我们需要为每个子包都执行一次打包命令,并输出dist到对应的目录下。

Nodejs在14版本以上,可以通过在package.json中设置type为module,来使项目可以使用 import xx from 'xx'来引入依赖。

基于以上问题,我们将可能面临的问题进行拆分

  • 如何准确识别出所有的子包?

    可以采用node中的fs模块去读packages目录下的所有子文件夹/文件,然后保留所有文件夹就是我们的所有子包,实现代码如下:

    const fs = require("fs");
    
    const pkgs = fs.readdirSync('packages').filter(p => {
      // statSync() 方法返回文件信息状态 isDirectory()如果是文件目录则返回true
      return fs.statSync(`packages/${p}`).isDirectory()
    })
    
    console.log(pkgs) // ['core', 'vue']
    

    代码介绍:readdirSync()返回指定目录下所有文件名称组成的数组,statSync()isDirectory()返回指定文件的详细信息对象,isDirectory()方法返回当前文件是否为文件夹。

    在撰写完以上代码后,我们再次执行打包命令,可以看到如下打印信息。

    pnpm run build
    
    # 输出信息
    node scripts/build.js
    ['core', 'vue']
    
  • 如何使用execa进行一次打包命令

    假设现在要给packages/core打包,可以先这样做:

    const fs = require("fs");
    + const execa = require("execa");
    
    const build = async (pkg) => {
      await execa('rollup', ['-c', '--environment', `TARGET:${pkg}`], { stdio: 'inherit' })
    }
    
    build('core')
    

    以上execa执行的命令相当于

    rollup -c --environment TARGET:core
    

    命令解读:-c代表制定rollup配置文件,如果其后没有跟文件名,则默认取根目录下的rollup.config.js文件。--environment表示注入一个环境变量,在我们的打包命令中注入了一个TARGET,可以使用process.env.TARGET取出来,其值为core

    现在,我们在根目录下新建 rollup.config.js 文件,并编写如下代码:

    const pkg = process.env.TARGET
    console.log(pkg)
    

    然后,再次运行打包命令:

    TIP

    因为 rollup.config.js还没有配置,所以运行报错是正常的

  • 如何批量执行打包命令

    有了core的打包经验,我们就可以实现给所有子包都打包,其实现代码如下:

    const runParallel = (targets, buildFn) => {
      const res = []
      for(const target of targets) {
      	res.push(buildFn(target))
      }
      return Promise.all(res)
    }
    
    const build = async (pkg) => {
    	await execa('rollup', ['-c', '--environment', `TARGET:${pkg}`], { stdio: 'inherit' })
    }
    
    runParallel(pkgs, build)
    

    再次执行打包命令:输出结果如下:

    TIP

    因为 rollup.config.js还没有配置,所以运行报错是正常的

    # 打包命令
    pnpm run build
    
  • 如何配置rollup?

    在根路径下创建rollup.config.mjs文件,根据rollup的基础知识,我们知道需要提供inputoutput以及plugin等配置,可以撰写如下代码:

    import json from '@rollup/plugin-json'
    import nodeResolve from '@rollup/plugin-node-resolve'
    
    // 根据执行build.js中代码分别设置 packages 不同的值,例如打包core文件夹时 pkg就为core,同理打包vue时,pkg则为vue
    const pkg = process.env.TARGET
    
    const createConfig = (name) => {
      return {
        input: 'src/index.js',
        output: {
          name,
          file: `dist/${name}.esm.js`,
          format: 'esm'
        },
        plugins: [
          json(),
          nodeResolve()
        ]
      }
    }
    module.exports = createConfig(pkg)
    

    先别着急运行打包命令,因为以上代码还存在一些问题:

    a. inputfile 的路径不正确,需要定义一个 resolve 函数来表示当前 package包的路径

    b. output.name的值,我们希望是MySentryXxx而不是xxx

    c. format方式希望能够支持ESMCommonjsUMD这三种规范

    先来解决第一个问题,我们第一个的resolve函数如下:

    // rollup.config.js 新增代码
    import path from 'path'
    import { fileURLToPath } from 'url'
    const pkg = process.env.TARGET
    // 获取绝对路径并且实现 获取到打包时当前package文件夹,例如正在打包core时,获取的就是packages/core下的p文件
    const __dirname = fileURLToPath(new URL('.', import.meta.url))
    
    const resolve = (p) => {
      return path.resolve(`${__dirname}/packages/${pkg}`, p);
    };
    console.log(resolve('src/index.js'))
    

    既然 resovle 函数已经定义好了,那么修改 rollup.config.cjs 文件后完整代码如下:

    import path from 'path'
    import { fileURLToPath } from 'url'
    import json from '@rollup/plugin-json'
    import nodeResolve from '@rollup/plugin-node-resolve'
    const pkg = process.env.TARGET
    const resolve = (p) => {
      return path.resolve(`${__dirname}/packages/${pkg}`, p)
    }
    const createConfig = (name) => {
      return {
        input: resolve('src/index.js'),
        output: {
          name,
          file: resolve(`dist/${name}.esm.js`),
          format: 'esm'
        },
        plugins: [
          json(),
          nodeResolve()
        ]
      }
    }
    module.exports = createConfig(pkg)
    

    接下来解决第二个问题,既然要自定义名字,那么可以选择在当前包的package.json文件中去定义,我们以packages/core为例:

    修改packages/core/package.json文件

    {
      "name": "@mysentry/core",
      "buildOptions": {
        "name": "MySentryCore"
      },
      ...省略其他
    }
    

​ 其他packages下的子项目同理修改,都修改好后我们需要去读取这个配置,代码如下:

import { createRequire } from 'module'
const require = createRequire(import.meta.url)

const { buildOptions } = require(resolve('package.json'))

接着,在createConfig方法中去修改output.name的值:

const createConfig = (name) => {
  return {
    output: {
      name: buildOptions.name,
      ...省略其他
    },
    ...省略其他
  }
}

最后解决第三个问题,根据前面配置name的思路,可以在buildOptions中去定义另外一个属性formats,同样以core为例:

// packages/core目录下的packages.json
{
  "name": "@mysentry/core",
  "buildOptions": {
    "name": "MySentryCore",
    "formats": [
      "esm",
      "cjs"
    ]
  },
  ...省略其他
}

为了更好的进行区分打包的产物,我们在core配置["esm", "cjs"],而对于vue配置["esm", "cjs", "umd"]

在所有formats配置完毕后,我们再次修改rollup.config.cjs,完整代码如下:


import path from 'path'
import { createRequire } from 'module'
import { fileURLToPath } from 'url'
import json from '@rollup/plugin-json'
import nodeResolve from '@rollup/plugin-node-resolve'
import typescript from 'rollup-plugin-typescript2';
import commonjs from '@rollup/plugin-commonjs';
const pkg = process.env.TARGET;

const require = createRequire(import.meta.url)
const __dirname = fileURLToPath(new URL('.', import.meta.url))

const resolve = (p) => {
  return path.resolve(`${__dirname}/packages/${pkg}`, p);
};

 
const { buildOptions } = require(resolve('package.json'));

const formatMap = {
    esm: {
        file: resolve(`dist/${pkg}.esm.js`),
        format: 'esm'
    },
    cjs: {
        file: resolve(`dist/${pkg}.cjs.js`),
        format: 'cjs'
    },
    umd: {
        file: resolve(`dist/${pkg}.js`),
        format: 'umd'
    }
}

const createConfig = (output) => {
  output.name = buildOptions.name
  return {
    input: resolve("src/index.ts"),
    output,
    plugins: [
      typescript({
        tsconfigOverride: {
          compilerOptions: {
            module: 'ESNext',
          },
        },
        useTsconfigDeclarationDir: true,
      }),
      json(),
      commonjs(),
      nodeResolve()],
  };
};

const configs = buildOptions.formats.map(format => createConfig(formatMap[format]))

export default configs



接着,我们来运行打包命令:

# 运行打包命令
pnpm run build

# 输出信息
created packages/core/dist/core.cjs.js in 12ms
created packages/core/dist/core.esm.js in 37ms
created packages/vue/dist/vue.esm.js in 58ms
created packages/vue/dist/vue.cjs.js in 30ms
created packages/vue/dist/vue.js in 22ms

可以看到 packages/core/dist 和 packages/vue/dist。

发布到npm

如果要想发布package,还需要有一个默认的入口文件,我们以packages/core为例,在其目录下新建index.js,并撰写如下代码:

需要在core根目录下创建index.js

module.exports = require('./dist/core.cjs.js')

你可能会很疑惑,为什么只导出CommomJs规范的文件,ESMUMD规范的文件又该如何导出呢?

其实,这两个规范可以在其目录下的package.json文件中去配置导出,以core为例:

{
 "name": "@mysentry/core",
 "main": "dist/core.cjs.js",            
 "module": "dist/core.esm.js",       // ESM规范导出
 "unpkg": "dist/core.js"             // UMD规范配合CDN
}

安装和初始化changesets

在项目根目录下执行 pnpm install @changesets/cli -D -w

pnpm install @changesets/cli -D -w

执行pnpm changeset init 初始化 changeset , 在根目录下会生成.changeset 文件夹,其中config.json文件是 changeset `的配置文件。

.changeset 文件夹不能被 git 忽略,需要一起提交到git上!

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  // changelog 生成方式
  "changelog": "@changesets/cli/changelog",
  // 不要让 changeset 在 publish 的时候帮我们做 git add
  "commit": false,
  "fixed": [],
  // linked: 配置哪些包要共享版本
  "linked": [],
  // access: 公私有安全设定,内网建议 restricted ,开源使用 public
  "access": "public",
  // baseBranch: 项目主分支
  "baseBranch": "master",
  // updateInternalDependencies: 确保某包依赖的包发生 upgrade,该包也要发生 version upgrade 的衡量单位(量级)
  "updateInternalDependencies": "patch",
  // ignore: 不需要变动 version 的包
  "ignore": []
}

发布第一个版本

优先将整个项目推入到gitee/github上,这步我们就不赘述了。

然后我们需要在npm官网上创建一个Organization, 名称为mysentry,其实就是取得@mysentry/xxx中得,创建好后还需要执行pnpm adduser 登录自己得npm账号密码还有邮箱。

然后执行 pnpm changeset publish 就发布到npm上了。

更新版本

修改功能代码,我们还是用core来举例子:

// 新增代码
export const test = () => {
  console.log('test')
}

执行pnpm run build执行打包

然后执行 pnpm changeset add,会生成对话,选择子项目等等。

然后执行pnpm changeset version 更新版本号

最后执行pnpm changeset publish推送到npm官网

下载包及使用

创建一个vue项目,然后执行pnpm install @mysentry/core下载包,然后在main.js中使用

import { test } from '@mysentry/core'

test()