人机交互爱好者的随笔

0%

基于Monorepo实现Web应用

上文中,笔者介绍了 Monorepo 这一项目组织方式、Lerna 这个用于便捷管理 monorepo 项目的工具以及它们在开源项目中的应用。那这一次就来聊聊 Monorepo 如何在 WebApp 中落地。

优点与缺点

对于大型开源项目来说,MonoRepo 是利远大于弊的,这也使得 babel、react-router 等知名开源项目纷纷采用 MonoRepo 来进行项目管理;但是对 WebApp 来说,利与弊乍一看似乎很难讲清楚。但两者之间依然存在共通之处:

  1. 多模块可以更好的实现功能解耦,让模块可以独立开发、测试、发布和部署
  2. 跨模块的调用采用依赖包引用的方式进行而不是相对/绝对路径引用的方式,避免了文件搬运时的路径修改文件

此外,WebApp 使用 MonoRepo 进行项目管理,还有以下好处:

  1. 当其他 Web 项目与当前项目存在一定共通之处时,可以方便的让其他项目使用对应模块的功能(如工具函数),而不需要进行代码复制
  2. 可以将某一个模块作为模板模块,在开发新功能时通过 CLI 或其他方式快速创建模块原型,节省前置准备时间

当然,在 WebApp 中使用 MonoRepo 并不是一件简单的事情,相比常规的项目组织方式,Monorepo 会在不少方面对开发者提出一些难题与挑战:

  1. 雷同的配置(test、webpack)会在各模块间存在多份,因此需要将这些配置也封装为一个模块,并被各模块引用;或者使用create-react-app(以下简称为cra)等工具来减少手动配置,但这类工具也会限制开发者
  2. 模块间需要小心的进行版本管理,避免引用错误的兄弟模块导致报错

项目结构

我们已经知道,一个典型的 MonoRepo 项目具有以下项目结构:

1
2
3
4
5
6
├── packages
| ├── pkg1
| | ├── package.json
| ├── pkg2
| | ├── package.json
├── package.json

在开源项目中,pkg1、pkg2 与其他子模块可能都是独立发布的依赖包,而根(root)目录的package.json文件中一般只包含风格检查(lint)、测试(test)、编译(compile)、打包(build)等相关任务的描述和脚本,所有具体的功能都在子模块中完成;而 WebApp 中则有所不同——子模块具有不同的类型——我们可以把子模块按功能、职责分为以下类型:

  1. 应用入口(entry):作为项目部署的直接目标,包含路由配置(router)、模板实现(layout)、静态资源(asset)等组成单位,但不涉及具体功能的实现。
  2. 功能模块(module):可以独立运行、部署的功能模块,每个功能模块负责某一部分功能的实现。
  3. 公共依赖(common):同时会被应用入口与功能模块所依赖的项目级基础设置,如通用配置(config)、工具函数(utils)、基础布局(layouts)、通用组件(components)、持久层基类与启动配置(core)。
  4. 模板模块(template):作为功能模块的模板,用于在新增功能模块时可以快速创建模块原型。

在这个基础上,我们可以将项目结构细化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
├── packages
| ├── app
| | ├── package.json
| ├── module1
| | ├── package.json
| ├── module2
| | ├── package.json
...
| ├── template
| | ├── package.json
| ├── config
| | ├── package.json
| ├── utils
| | ├── package.json
| ├── layouts
| | ├── package.json
| ├── components
| | ├── package.json
| ├── core
| | ├── package.json
├── package.json

基础实现

技术栈:TypeScript+React+Mobx+React-Router
为了简化实现,我们采用cra作为模板进行模块创建,并在此基础上做一些调整
具体实现的源码,可以参考monorepo-react

应用入口

一个应用入口模块应该具有以下基本目录结构:

1
2
3
4
5
6
7
8
9
10
├── app
| ├── package.json
| ├── src
| | ├── assets // 资源目录
| | ├── layouts // 具体模板(可选)
| | ├── model // 持久层实例(可选)
| | ├── router // 路由配置
| | ├── App // 入口组件
| | ├── index // 模块入口
| ├── config-overrides.js // 用于修改cra项目配置的配置文件

我们在入口的package.json添加对其余模块的依赖,保证 lerna 在运行编译脚本时可以根据依赖关系控制顺序,且 app 模块的基本逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// src/App.tsx
import React from "react";
import { Layout } from "antd";
import { HashRouter as Router } from "react-router-dom";
import { renderRoutes } from "react-router-config";

import { AppMenu } from "@mono/layouts"; // 引入layouts子模块的组件,用于渲染导航菜单

import { routerConfig } from "./router";

const { Header, Content, Sider, Footer } = Layout;

function App() {
return (
<div className="App">
<Router>
<Layout>
<Header>Header</Header>
<Layout>
<Sider theme="light">
Sider
<AppMenu menuConfig={routerConfig}></AppMenu>
</Sider>
<Content>{renderRoutes(routerConfig)}</Content>
</Layout>
<Footer>Footer</Footer>
</Layout>
</Router>
</div>
);
}

export default App;

// src/router/index.ts
import { RouterConfig } from "@mono/layouts";
import Template from "@mono/template"; // 引入template子模块,作为一个与路由关联的组件

export const routerConfig: RouterConfig = [
{
path: "/template",
name: "template",
component: Template,
},
];

// config-overrides.js
module.exports = {
webpack(config) {
// 当引用的资源的具体路径是app模块的兄弟子模块时,将其加入babel解析,实现对兄弟子模块的热更新
customBabelLoaderInclude([path.resolve(__dirname, "../")])(config);
addWebpackResolve({
// 在引入npm包的时候,将monoEntry这一自定义字段作为入口解析的最高优先级选项
mainFields: ["monoEntry", "browser", "module", "main"],
})(config);
return config;
},
};

可以看到,app 模块以依赖包的形式引用了兄弟子模块中的layouttemplate,用于生成路由配置和导航菜单。同时通过修改 webpack 配置中的resolve.mainFields字段值,扩展出一个自定义字段,让 app 模块可以直接引用配置了该字段的功能模块的入口组件,而不是编译生成的代码。在这样的机制下,就能像正常 spa 项目那样开发页面级的功能模块了

模板模块

模板模块的功能是为其他功能模块(或者说页面)提供一个代码原型,借助 cli 等工具可以快速创建新的功能模块,并且可以根据项目的迭代随时优化模板的实现逻辑,以适应项目的需要。

模板模块(以及基于模板产生的功能模块)会包含自身所需的各个组成部分,能够独立的运行、开发、测试、编译、部署,也可以作为入口模块的一部分使用

一个模板模块大致会具有以下结构

1
2
3
4
5
6
7
8
9
10
11
12
13
├── template
| ├── package.json
| ├── src
| | ├── index // 模块入口
| | ├── App // 入口组件
| | ├── views // 视图组件
| | ├── assets // 资源目录(可选)
| | ├── factory // 关联model并创建组件的工厂(可选)
| | ├── model // 持久层实例(可选)
| | ├── service // 接口服务实例(可选)
| | ├── router // 路由配置(可选,若单模块下存在多个路由)
| | ├── interface // 模块的接口声明(可选,面向接口编程)
| ├── config-overrides.js // 用于修改cra项目配置的配置文件

其中,针对单模块启动 devServer 时,入口文件为index;而将模板/功能模块作为入口模块的一个页面时,入口文件为App(需要在package.json中指定)。

对于模板/功能模块的持久层技术栈,笔者这里选择了跟多模块天然贴切的mobx全家桶,当然redux或者其他持久层技术栈也是可以的,但需要一些额外的步骤(比如将子模块的持久层作为单一 store 树中的一个节点)进行处理,这里就不展开讲解了,有兴趣的朋友可以自己尝试。关于模板模块中各个部分的实现思路,可以参考React组件设计模式(2)文章中的介绍。

基础模块

core

core 模块是整个项目中整合核心逻辑的一个库,在笔者的 demo 项目中,主要是包含了依赖注入的相关实现、全局 loading 的实现以及对使用的 ui 组件库中经常发生交互与状态变更的组件(如 antd 的 Table、Form)的外置状态类的实现

cra

也可以命名为 config 或 scripts 等,主要是包含了入口模块、模板模块与各个功能模块需要的 webpack 等工具的配置,以及基于模板模块创建功能模块的 cli 脚本,作用类似于create-react-app提供的react-scripts库。若直接使用官方 cra 创建项目,那这里基本就是包含了基于customize-cra工具的一些用于修改 cra 基础配置的工具函数

总结

基于 Monorepo 的 WebApp 可以享受到这一系统设计方案的种种利好,并通过一些额外的处理逻辑来规避可能出现的缺陷,让整个系统的开发效率和可维护性得到极大的提高。同时,这种设计思路也许可以作为微前端的雏形,通过进一步的框架调整来实现真正的微前端应用。