Posted on February 20, 2025

运用 Turborepo 构建全栈项目

运用 Turborepo 构建全栈项目
什么是 turborepo? 它具备哪些优势?

 turborepo 是一个比较受欢迎的 monorepo 构建框架,它的主要功能是将多个项目整合在一个集中的仓库里进行开发和维护,运用 monorepo 技术大致有以下好处:

  • 代码同构:例如在 TS 全栈仓库里,前后端可以使用同一个方法或者接口,更稳固的确保数据类型的准确。
  • ​减少重复开发​:重复功能模块或组件直接可以拆分单独发包,发包方便并且可以降低代码耦合度,方便重复使用。
  • 启动或者编译速度快,不需要多个窗口启动不同应用,编译和开发启动时可以并行。
创建项目

 这里建议使用 pnpm 作为包管理工具,因为 turborepo 支持整合 pnpm 的 workspaces 达到更好的应用构建效果。

pnpm dlx create-turbo@latest

取消 pnpm 的版本锁定,修改根目录下的 .npmrc 如下:

package-manager-strict=false
项目结构

这里以一个 TS 全栈项目举例:

├── README.md
├── apps
│   ├── admin # 中后台前端包
│   │   ├── ...
│   ├── api # 后端API应用包
│   │   ├── ...
│   └── web # 前端应用包
│   ├── ...
├── packages
│   ├── code # 代码风格化配置扩展包
│   │   ├── README.md
│   │   ├── eslint # eslint配置
│   │   ├── package.json
│   │   ├── prettier.js # prettier配置
│   │   ├── stylelint.js # stylelint配置
│   │   └── tsconfig # ts配置
│   ├── store
│   │   ├── ...
│   └── utils
│   ├── ...
├── package.json # 全局依赖
├── pnpm-lock.yaml
├── pnpm-workspace.yaml # pnpm workspaces 配置
├── tsconfig.json # monorepo全局ts配置
└── turbo.json # turborepo 配置

共用包

生成一个常用的自定义函数类库工具包:

pnpm gen --name demo/utils

由于前端应用通常遵守 esm 规范,而 node 遵守 commonjs 规范,因此需要确保工具包的产物能够同时满足这两种规范,这里使用 bunchee 达成这一目的:

  • bunchee:一个零配置的ES6+/TS编译包,可同时编译出commonjs和esm模块

--filter 参数可以筛选安装目录,安装 bunchee 至 utils 包:

pnpm add bunchee -D --filter utils

然后改写 utils 包的 package.json 中的脚本命令如下:

"scripts": {
    "dev": "bunchee -w  --tsconfig tsconfig.build.json",
    "build": "bunchee --tsconfig tsconfig.build.json",
    "lint": "eslint . --ext ts,tsx"
},

然后运行以下命令打包 utils 包:

pnpm build --filter utils

这时可以将一些公用方法放到 untils 包中以供引用,假设 admin 应用包需要引入 utils 工具包,在 admin 项目的 package.json 中进行引入:

{
   "dependencies": {
        "demo/utils": "workspace:*",
    },
}

然后运行:

pnpm build --filter utils

这时就发现 admin 包已经可以成功引用 utils 包的中的内容。

包监控

对于一些常用的引用包,我们会选择将他们做成独立包,上一节已经实现了这一点。那么,我们还希望:每次启动应用时,它所依赖的引用包必须先编译好;更改扩展包自身发生变更时,应用也会跟随进行重新编译并重新启动。

假如我们要在 api 项目(nest 应用)中引用 utils 包,先安装 nodemon 包到 api 项目:

pnpm add nodemon -D --filter api

然后在 api 项目的根目录添加 nodemon.json 文件如下

{
    "watch": [
        "src",
        "../../packages/utils/dist"
    ],
    "ext": "ts,js,mjs,cjs",
    "exec": "nest start -w",
    "legacyWatch": true,
    "ignore": [
        "**/*.spec.ts",
        "**/node_modules/**",
        "**/.git/**"
    ]
}

将 api 项目的启动命令修改为:

"dev": "nodemon --config nodemon.json"

该配置可以保证 api 项目会响应 src 目录以及 utils 包的变化进行热加载。 在根目录通过 turbo 启动 api 应用时需要注意配置:

"api:dev": "turbo dev --filter=utils --filter=api"

此时,通过 pnpm api:dev 启动应用,可以看到 utils 包的变化会实时同步到 api 应用的运行时。

同构方法

monorepo 不仅可以使前后端不同的应用之间使用同一个包,并且不同的应用中也可以互相使用它们建的模块、函数、类型等,我们尝试在 admin 应用中调用 api 的接口类型。 先在 api 项目中返回一个IPost类型的文章列表:

// apps/api/src/types.ts
export interface IPost {
    id: number;
    title: string;
    body: string;
}

// apps/api/src/app.service.ts
@Injectable()
export class AppService {
    // ...
    async getPosts(): Promise<IPost[]> {
        return [{ id: 0, title: 'post1', body: 'post1' }];
    }
}

// apps/api/src/app.controller.ts
@Controller()
export class AppController {
    // ...
    @Get('posts')
    getPosts(): Promise<IPost[]> {
        return this.appService.getPosts();
    }
}

在 apps/api/package.json 中添加导出入口:

{
   "exports": {
        ".": {
            "import": "./dist/main.d.ts"
        },
        "./*": "./dist/*.d.ts"
    },
}

启用服务端跨域:

// apps/api/src/main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule, {
        // 启用跨域访问
        cors: true,
    });
    await app.listen(3000);
}
bootstrap();

在 admin 前端项目中引入 api 依赖并运行 并运行 pnpm i:

 "dependencies": {
        "demo/api": "workspace:*",
    },

然后在 admin 项目中可以引用 IPost 类型:

import { IPost } from 'demo/api/types';
....

其它同构方案:

  • 对于耦合性不是很敏感的项目,会把类型抽取到一个单独的库中然后共用。
  • 稍微复杂的项目可以使用 open api 文档生成类型给前端或者直接使用trpc这类进行同构。