使用NX+PNPM搭建Monerepo项目

1. Monorepo简介

1.1 Monorepo 和 Polyrepo

MonorepoPolyrepo是两种不同的代码仓库的管理策略。
其中Monorepo是使用一个仓库管理多个项目的代码,而Polyrepo是使用一个仓库管理一个项目代码。

image.png

Polyrepo策略的问题

  • 复用繁琐:项目之间的代码复用一般需要通过私有npm仓库或者文件拷贝
  • 数据冗余:大量重复的命令行脚本、构建工具的配置文件等
  • 依赖冗余:系统中会存在大量重复的npm依赖包
  • 调试困难:在排查定位问题时,需要切换环境和项目仓库

Monorepo策略的优势

  • 环境简单:代码属于同一个仓库,开发过程无需切换环境
  • commit原子化:每一次commit都是基于某个特定的功能的修改(可能涉及多项目)
  • 无版本冲突:所有项目的公共依赖可以保证版本的统一
  • 协作高效:由于多项目在一个仓库中,更方便检视其他项目开发人员的代码提交,便于发现问题和协作

1.2 常用的Monorepo管理工具

  • Bazel 是一个基于Make和Maven的Java构建工具。

    • Bazel可以跨平台构建不同类型的代码,包括但不限于Java、C++、Python和Go等。
    • Bazel可以将多个代码仓库和构建过程组合为一个单一的模块化构建过程,并通过构建缓存来提供高效和可靠的增量编译。
    • Bazel支持在线构建,允许开发人员在离线环境之外构建和测试代码。
    • Bazel支持Docker镜像,并可以将其用于代码构建和测试。
  • Lerna 它主要用于管理JavaScript的monorep项目。

    • Lerna的主要功能是使得在代码库中同时管理多个模块更加容易,从而优化跨包的代码共享和开发流程。
    • Lerna可以通过一系列命令来管理多包,包括初始化项目、安装依赖关系、发布子包和管理版本等。

2. Pnpm简介

pnpm是一个javascript的包管理器,可以替代npmyarn

相比于其他包管理器,pnpm的主要优势在于:

  1. 共享依赖pnpm会将多个项目中的相同依赖下载到一个共享的位置,并将它们链接到每个项目中。这意味着更少的重复下载和磁盘空间占用,以及更快的安装。
  2. 更快的安装:由于pnpm会使用硬链接和符号链接,因此在安装依赖时,不需要重新复制整个依赖树,只需要复制依赖树的一部分即可。这使得pnpm安装速度比npm更快。

2.1 PnpmLerna的区别

pnpmlerna都是JavaScript项目管理工具。
以下是pnpmlerna的一些异同点:

相同点

  • 一次性安装多个项目的依赖项。
  • 具有类似于npm的功能,如版本锁定和依赖项查找。
  • 允许您在本地存储中缓存依赖项,以便加快安装速度。
  • 支持分别管理项目的依赖项。
  • 可以在Monorepo项目中使用。

不同点

  • pnpm采用唯一依赖共享机制,而lerna则采用符号链接。
  • pnpm使用单一存储库,而lerna使用多存储库。
  • pnpm支持自动垃圾收集删除不再需要的依赖项,而lerna不支持。
  • pnpm可以并行安装依赖项,而lerna在这方面的表现相对较差。
  • pnpm支持自动版本锁定,而lerna需要手动处理版本锁定。

3. Workspaces简介

在使用Npm/Pnpm/Yarn等包管理工具时,Workspaces的概念是实现 monorepo的一种手段。Workspace的本质是建立起本地文件和node_modules依赖之间的链接,从而实现不同的package之间的引用。
当使用npm时,Workspace的实现是通过在package.json文件中创建一个 workspaces字段来定义相关的数据,该字段的值是一个字符串数组:

package.json

{ 
    "workspaces": [ "packages/b", "packages/a" ] 
}

当使用pnpm时,Workspace的实现是在项目根目录下创建一个pnpm-workspace.yaml文件,文件内容示例如下:

pnpm-workspace.yaml

packages:

    # all packages in direct subdirs of packages/

    - 'packages/\*'
    # all packages in subdirs of components/
    - 'components/\*\*'
    # exclude packages that are inside test directories

    - '!**/test/**'

4. 构建项目

  1. 首先使用pnpm初始化一个项目
pnpm init
  1. 在项目根目录下创建 appspackages文件夹,其中apps文件夹用于存储可构建、可发布的应用,packages文件夹用于存储各组件或功能模块
mkdir apps packages

3.在项目根目录下配置Workspace数据,在根目录下创建一个pnpm-workspace.yaml文件,并写入如下内容

pnpm-workspace.yaml

packages:

    # all packages in direct subdirs of packages/

    - 'packages/*'
    # all apps in direct subdirs of apps/
    - 'apps/**'
    # exclude packages that are inside test directories

    - '!**/test/**'

配置pnpm的代理,在项目根目录下创建 .npmrc 文件,并写入

    registry=https://registry.npm.taobao.org/

上述步骤执行完成执行完成之后一个简单的目录结构如下

.

+-- packages

+-- apps

`-- .gitignore
`-- .npmrc
`-- package.json
`-- pnpm-worksapce.yaml

4. 创建一个vue3+TypeScriptapp

# 进入apps的文件夹
cd apps
# 根据模板创建一个vite+vue+TypeScript项目
pnpm create vite
# 进入vite项目
cd vite-project
# 安装项目的依赖
pnpm install

成功执行完成之后,整个的项目目录结构变成了下面的样子

.

+-- packages

+-- apps

    `-- vite-project
        `-- package.json
        +-- public
        +-- src
        ...
`-- .gitignore
`-- .npmrc
`-- package.json
`-- pnpm-worksapce.yaml

5. 一般情况下,在monorepo项目中,无需从根目录进入到子目录中去启动项目。使用worksapce的方式,在项目根目录下,使用如下格式命令即可运行对应项目package.jsonscripts脚本中的内容

pnpm --filter <package-name> <command>

上述命令运行时,pnpm会去Workspace的文件夹下的项目中寻找名为package-name
的项目,并运行项目package.json文件scripts中的command命令。其中项目的名字由项目中package.jsonname字段指定。

pnpm --filter vite-project dev

当该命令执行完成之后,项目就正常运行起来了。

image.png

  1. 在项目可以正常运行后,我们需要为应用安装组件库依赖,在项目根目录下执行以下的命令去安装element-plus组件库
# pnpm add --filter <package-name> <depends>
pnpm add --filter vite-project element-plus
  1. 创建一个功能模块
# 从根目录进入packages文件夹
cd packages
# 创建一个utils文件夹
mkdir utils
# 进入utils文件夹
cd utils
# 初始化一个空的项目
pnpm init

在utils文件夹下创建 index.ts文件,修改package.json文件的内容

{









    "name": "utils",
    "main": "dist/index.js",
    "script": {
        "build": "rm -rf dist && tsc"
    }

}


utils文件夹下配置tsconfig.json文件

{









  "compilerOptions": {
    "allowJs": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "module": "commonjs",
    "outDir": "./dist"
  },
  "include": ["."],
  "exclude": ["dist", "node_modules", "**/*.spec.ts"]
}

index.ts中编写一个生成随机字符串UUID的函数

export function generateUUID() {
     let uuid = ''
     let i
     let random


     for (i = 0; i < 32; i += 1) {
       // eslint-disable-next-line no-bitwise
       random = (Math.random() * 16) | 0


       if (i === 8 || i === 12 || i === 16 || i === 20) {
         uuid += '-'
       }

       // eslint-disable-next-line no-nested-ternary, no-bitwise
       uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16)
     }

     return uuid
}

为了避免编写冗余的TypeScript配置文件,有些通用的配置可以在项目根目录下进行配置,在根目录下创建tsconfig.json文件

{









    "compilerOptions": {
        "baseUrl": "./",
        "target": "ES2020",
        "useDefineForClassFields": true,
        "module": "ESNext",
        "lib": \["ES2020", "DOM", "DOM.Iterable"],
        "skipLibCheck": true,


        /* Linting */
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true,
        "paths": {
          "@packages/*": ["packages/*"],
          "@apps/*": ["apps/*"]
        }
    },
    "include": \[
        "apps/**/\*.ts",
        "apps/**/*.d.ts",
        "apps/\*\*/*.tsx",
        "apps/**/\*.vue",
        "packages/**/*.ts",
        "packages/\*\*/*.d.ts",
        "packages/**/\*.tsx",
        "packages/**/*.vue"
    ],
    "exclude": \["dist", "node\_modules", "\*\*/*.spec.ts"]
}

在Workspace中的子项目中设置TypeScript配置信息的继承关系

apps/vite-project/tsconfig.json

{









    "extends": "../../tsconfig.json",

    "compilerOptions": {

        /* Bundler */
        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "preserve"
    },
    "include": \["src/**/\*.ts", "src/**/*.d.ts", "src/\*\*/*.tsx", "src/\*\*/\*.vue"],
    "references": \[{ "path": "./tsconfig.node.json" }]
}

packages/utils/tsconfig.json

{









    "extends": "../../tsconfig.json",

    "compilerOptions": {

        "outDir": "./dist"
    },
    "include": \["."]
}


构建utils

pnpm --filter utils build
  1. 在项目中引入依赖, 使用pnpmadd命令进行这一操作
pnpm add utils --filter vite-project --workspace

在执行完上述的命令之后,vite-projectpackage.jsondependcies字段下会多出一条记录

{









    "dependencies": {
        ...
        "utils": "workspace:^",
        ...
    }

}



workspace:^ 表示依赖包通过workspace指定的本地文件解析,而不是从某个远程注册表(如NPM)解析。^只是表示我们希望依赖于它的最新版本,而不是特定的版本。只有在使用外部NPM包时,使用特定版本才有意义。

  1. vite-project项目中引入utils包,并使用其中的功能函数
    <script setup lang="ts">
    import HelloWorld from './components/HelloWorld.vue'
    import { generateUUID } from 'utils'
    </script>


    <template>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src="/vite.svg" class="logo" alt="Vite logo">
        </a>
        <a href="https://vuejs.org/" target="_blank">
          <img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
        </a>
      </div>
      <h1>{{ generateUUID() }}</h1>
      <helloworld msg="Vite + Vue">
    </helloworld>
 </template>

之后运行项目

pnpm --filter vite-project dev

image.png

构建项目

pnpm --filter vite-project build 

image.png

5. 包管理工具优化

使用pnpm可以对monorepo项目进行一个基础的管理,但是也面临一些问题:

  • 指定单个项目构建时,如果依赖有更新,需要手动构建该项目的依赖
  • 运行项目时,如果worksapce中的依赖项未构建,无法找到对外暴露的构建后的内容,则会报错(在上述步骤中如果在utils创建后未进行构建,vite-project项目在运行时就会报错)
  • 在构建时,没有文件缓存,项目变庞大或者项目文件未变化时,启动项目或者构建项目需要很长时间
  • 命令行使用较为繁琐,需要指定–filter参数等

针对上面的这些问题,可以使用nx工具,从而完成一些自动化的操作。

  1. 在项目中进行nx初始化
npx nx@latest init

在完成一些初始化的配置之后,项目根目录中会生生一个nx.json的文件

{









    "tasksRunnerOptions": {
        "default": {
            "runner": "nx/tasks-runners/default",
            "options": {
            "cacheableOperations": \[]
            }
        }
    },
    "affected": {
        "defaultBase": "main"
    }
}

2.运行和构建项目,nx使用如下格式运行命令

npx nx <target> <project>

其中targetpackage.json scripts字段下定义的脚本,project对应workspace中的项目名称。
使用nx构建vite-project时,可以使用以下命令

npx nx build vite-project

接下来建议使用全局安装nx,这样就可以避免每次运行命令时使用npx前缀

pnpm install --global nx@latest

运行构建命令如下:

nx build vite-project

当第一次执行完该命令后,构建完成耗时2s
image.png

再次执行该命令时,由于没有文件变更,构建仅仅耗时11ms

image.png

由于缓存机制,缓存输入未变动时,二次构建的时间大卫缩短。

  1. 配置缓存,执行 nx init命令时,nx会进行以下配置:
  • 收集worksapce中所有依赖的NPM script数据
  • 询问用户哪些脚本执行后需要缓存
  • 询问用户哪些脚本需要在项目执行时去执行,例如当运行build脚本时需要预先执行项目依赖的build脚本
  • 询问脚本执行后是否有文件输出,如果有则会作为缓存的一部分

在上述初始化配置完成之后,会在项目根目录下生成一个nx.json的文件

{









  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": [
          "build"
        ]
      }
    }
  },
  "affected": {
    "defaultBase": "master"
  },
  "targetDefaults": {
    "build": {
      "dependsOn": [
        "^build"
      ],
      "outputs": [
        "{projectRoot}/dist"
      ]
    },
    "test": {
      "dependsOn": [
        "^test"
      ]
    }
  }
}

在进行缓存时,更多的是关注源码的改动,如果文档类型的改动我们并不希望脚本重新执行,例如,我们修改了项目的readme.md但是并没有修改源码,我们并不希望在build时进行重新的构建,这个时候我们就需要再输入变更的检查中剔除.md文件
改动如下

{









    ...
    "targetDefaults": {
        "build": {

          "dependsOn": [

            "^build"

          ],

          "outputs": [

            "{projectRoot}/dist"

          ],

          "inputs": [

            "!{projectRoot}/**/*.md"
          ]
        },
        ...
}

如此一来,在build运行时,md文件就不会作为影响输出输入文件,而不会影响到缓存数据。在nx.json中我们可以自定义的编辑cacheableOperations的值去更改我们需要缓存的NPM script命令。
为了更好的复用这个缓存输入变量,我们把他注入到一个全局的变量noMarkdown之中:

{  
    "tasksRunnerOptions": {  
        ...  
    },  
    "namedInputs": {  
        "noMarkdown": ["!{projectRoot}/**/*.md"]  
    },  
    "targetDefaults": {  
        "build": {  
            "inputs": ["noMarkdown", "^noMarkdown"]  
        },  
        "test": {  
            "inputs": ["noMarkdown", "^noMarkdown"]  
        }  
    }
}

其中^符号表示的是同样的规则也适用于当前项目的依赖项目。

  1. 编写脚本依赖,在上文中,当我们编写完utils文件夹,但是没有进行build时,如果此时运行vite-projectdev命令,就会报错,我们希望在dev时,能够执行utilsbuild命令,接着对nx.json进行优化
{









    ...,
     "targetDefaults": {
        "build": {

          "dependsOn": [

            "^build"

          ],

          "outputs": [

            "{projectRoot}/dist"

          ],

          "inputs": [

            "noMarkdown",
            "^noMarkdown"
          ]
        },
        "test": {
          "inputs": [
            "noMarkdown",
            "^noMarkdown"
          ]
        },
        "dev": {
          "dependsOn": [
            "^build"
          ]
     }
    ...
}

运行vite项目

nx dev vite-project

可以看到在vite项目启动前,会运行对utils项目的构建工作。
image.png

  1. nx也支持基于分支收集依赖,并触发目标脚本,更新缓存。如下:

image.png

当在某一个feature分支修改了lib2的内容之后,我们可以运行一下命令

    nx affected:<target>

nx会比较当前分支和基础分支(配置文件中定义为main)的commit信息,运行

nx affected:test

由于appB依赖于lib2,当lib2触发test时,也会触发appBtest

image.png

但是当lib2触发build时,由于在配置文件中定义了build依赖于依赖模块的build,因而会触发appB lib2lib3build

  1. 可视化的分析workspace的依赖关系,如果想要去查看项目间的依赖关系,nx 也提供了一个可视化的界面给我们,当运行nx graph时,可以看到项目之间的依赖以图形化的方式表示了出来。

image.png

总结

pnpm init

  • 初始化文件夹,创建package.json

pnpm install

  • 安装项目依赖

pnpm add <pkg>

  • --filter <package-name> 指定workspace中的项目
  • --worksapce 在workspace中寻找,并安装依赖
  • 为指定项目安装依赖包

nx init

  • 使用nx时初始化
  • 收集项目的npm脚本、配置缓存脚本、指定npm脚本执行时的依赖、缓存脚本运行的输出目录
  • 创建nx.json

nx <command name> <project name> <options>

  • 运行npm脚本,target指定项目,command指定脚本名称

Demo仓库:github.com/Arfly/mono-… 文章中如有错误,欢迎评论指正。

参考资料

什么是monorepo
monorepo和polyrepo的对比
package.json中workspace字段说明
pnpm官网
pnpm workspace说明
nx的使用

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYVVFeIV' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片