引言
MulCon 是一个复杂前端模块&状态管理解决方案, MulCon 希望可以帮助复杂应用在拆解模块编写 state 和 UI 时能够更简单。
我们在 MulCon 2.0------多实例复杂前端解决方案 中阐述了 MulCon 2.0 重构的背景和方案,解决了 1.0 在真实开发场景中遇到的诸多痛点。在 MulCon2.0 于真实业务中落地近一年后,重新审视 MulCon 2.0 的设计,详细剖析其我认为优秀的设计和一些不足之处。
功能设计回顾
我们先回顾 MulCon 1.0 的一些核心设计过程。
以 MySQL 控制台举例,其功能复杂度在数据库业务中几乎是最为复杂的,其功能涵盖了
- 表、视图、触发器、函数、事件等的增删改查
- SQL编辑器
- 查询结果展示与编辑
- 多窗口的生命周期维护
业务上的复杂性,也决定了 UI 交互上的复杂,常常会因为数据的变动而同时刷新多个地方,而不同模块的数据大概率也需要多处共享,这便衍生出了"状态"管理的需求。
我们为什么需要再造一个"轮子"?
整个 dbw 工作台的交互方式与浏览器的交互方式很相似,用户的操作会触发创建 Tab,Tab 下包含了对数据操作的各种功能。以查询为例,用户可以对不同库下面的各个表发起查询,查询功能下包含了 sql 编辑器、预览/编辑多个查询结果,但是对"查询"这个功能本身,代码只需一份就够了,但需要维护多份状态。下面是MySQL控制台的部分能力录屏演示

❓回到问题"为什么要再造一个轮子?"
💡实际上我们并没有去发明什么新东西,只是通过现有的技术/库去扩展和封装更多贴近业务的能力,将复杂业务模块的拆解与组装变得更加"公式化"。通过公式化的编码,可以一定程度地避免代码的"野蛮生长"或"劣化",也能够让代码和功能有更长的生命周期,而不是反复"重构"。
为什么选择响应式方案
状态管理从时间跨度上大致分为 redux 和 mobx 这种元老级的方案,zustand、jotai、valtio、recoil 等新生代方案,前者相对来说实现和使用上都偏重一点,后者在使用上和实现上都会更加轻量化。
从原理上可以将这些状态管理库分为响应式和非响应式方案,如 mobx、valtio、jotai 是响应式方案,其它都是非响应式方案。
业务在实际使用中时有"精准 render"的诉求,期望只有视图依赖的状态改变时才需要刷新,而非在任意状态改变时都需要刷新,以防止在状态复杂时更新一些非UI状态而导致的无效刷新,所以我们选择了响应式更新的状态管理路线。这样我们能基于类 mobx 方案,构建更多定制化的能力。通过定制化改造后,也能一定程度上弱化响应式状态管理的一些缺点。

响应式方案相对非响应式来说有如开发便捷、代码更少、性能更好 等优点,但在可维护性和调试便捷性上可能会稍逊于非响应式方案。
精准 render 指的是框架通过在第一次渲染时,同步收集组件访问了哪些状态,从而在下次状态更新时判断该视图是否需要更新。
将状态和视图等聚合为模块
一个功能模块可能不止有状态,它还包含了对外暴露的 api,如table 列表
对外支持刷新。以及视图,一个模块如果需要跨结构交互,那么它大概率存在一个视图,用于展示它的一些状态或与用户进行交互。
我们大胆地将这些元素组织到一起,并将其称之为module
,将状态管理从"技术方案x业务"的组织方式,转变为"业务x业务"。将使用者从"业务模块+状态管理"视角拉到"业务模块"上,淡化"状态管理"的概念后,能够让复杂功能和需求在拆分模块时,天然的带上了"状态"。而非把"状态"作为"负担"再去做设计。

模块构建及模块间关系
常规状态管理并没有工厂的概念,仅有状态的概念,相同状态通常会通过一个状态生成器来创建。
我们使用工厂模式来定义并创建模块,不仅包含了状态生成,也能将其他能力一并创建,如上文提到的 Parent、children 等。通过这种形式可以让需要创建多模块实例的场景在模块处理上更为简单便捷。

我们借鉴和 HTML 一样的树形结构来组织模块,这会让模块之间的关系理解起来更加符合直觉,如父子、兄弟等等。

跨模块访问
跨模块访问在复杂业务场景中有非常高的使用频率,如:访问兄弟模块数据、触发父模块刷新、被父模块调用 api 进行数据初始化等等。
我们对任意位置模块的访问都需要极高的灵活需求。传统状态管理库如 redux 在 dbw 的场景中,需要向上或向下访问父/子状态时都难以避免要先拿到顶端模块在某个模块列表中的下标,再访问其对应的属性,这对高频的跨模块访问造成诸多不便。
下面是一个类似场景的例子:
css
const rootState = {
consoles: [
{
querySQL: "select * from foo limit 10",
queryResult: []
},
{
querySQL: "select * from bar limit 10",
queryResult: []
},
]
}
queryResult
和 querySQL
的状态在两个不同的 react 组件中使用,如果在 queryResult 的组件中需要访问 querySQL,我们访问路径应该是 rootState.consoles[parentIndex].querySQL
。
既然我们将模块像 html 元素一样作为叶子放在整个模块 tree 中,那么就能像 document.querySelector
一样去查找模块并且访问。并且考虑到真实场景下模块间的业务相关性问题,我们将 query 改进为默认查找离它最近的目标, 因为就控制台场景来说,每个相同工厂的模块之间理论上是没有任何关联关系的。

使用命令系统
命令模式具有解耦合,可排队,可撤销等诸多优点。我们在模块中引入命令来解决诸多问题,如
- 按钮与快捷键共享命令
- 通用能力在模块实现命令后即可,无需改动命令触发者
- 撤销用户的操作
但我们最后发现,命令作为可选功能,并不适合 MulCon 本身深度绑定,应该作为可插拔式的能力。
投入使用
下面是一个完整的模块的伪代码,通过三个文件,分别定义模块的视图、模块和模块工厂
Component.tsx
javascript
export const SearchBar = (props) => {
return (
<div>Hello World</div>
);
};
Module.ts
typescript
import { BaseModule, IModuleInit } from 'Mulcon';
interface SearchBarState {
loading: boolean;
// ...
}
export class SearchBarModule extends BaseModule<SearchBarState, SearchBarProps> {
_component = SearchBar;
state: SearchBarState = {
loading: false,
};
get componentProps(): SearchBarProps {
return {
...this.state,
// other state
};
}
constructor(params: IModuleInit) {
super(params);
this.factory.registerFactories([
new ChildrenFactory1({ parentModule: this }),
new ChildrenFactory12({ parentModule: this }),
]);
createCommand(this, {
name: commandName,
execute: () => {
//
},
});
}
public setLoading(loading: boolean) {
this.state.loading = loading;
// this.query() 通过 query 查找模块
}
}
Factory.ts
scala
import { BaseFactory } from 'Mulcon';
import { SearchBarModule } from './Module'
export class FooFactory extends BaseFactory {
protected ModuleClass = SearchBarModule;
constructor(...props: ConstructorParameters<typeof BaseFactory>) {
super(...props);
this.create();
}
}
在任意接入 MulCon 的 React 组件内通过工厂创建模块,并挂载模块视图
javascript
import { Module } from 'Mulcon';
const App: React.FC = () => {
const app = useMemo(() => {
const fooFactory = new FooFactory();
return fooFactory.create()
}, []);
return (
<>
...
<Module module={app} />
...
</>
);
};
解决使用痛点
v1 版本基本符合了我们对一个能够处理多模块管理状态与通信的解决方案的想象,但在正式投入使用一段时间后,还是发现了诸多痛点,我们围绕这些痛点进行了改进,在提升使用体验的同时,为其增加了更多能力去解决更多的通用性问题。
新的编码范式
如果你使用过 redux,肯定对 redux 的样板代码深恶痛绝,在 hooks 出现之前的老版本 redux中,connect、mapStateToProps 和 mapDispatchToProps 相信令你印象深刻,想要在组件中使用 redux 中的 state,需要用connect 将 要使用的 state 注入到组件中。

MulCon 中的样板代码
我们在设计 MulCon 时也进入了这个误区, 工厂与模块关联,模块与视图关联。为了描述工厂和模块的 Schema,工厂需要继承 BaseFactory,模块需要继承 BaseModule。并且需要关注三个文件,又回到 redux 的 reducer + action 的感觉。从设计上来说,这种方式是违背了 Code locality 的概念的,导致在实际开发时频繁切换上下文。
使用成熟的方案
工厂承担了自身创建、管理子模块、以及单例等等与模块生命周期相关的能力,除此之外的能力都由模块本身来处理,如 state、action 等。我们借鉴主流的设计,将 state、action 、模块/子模块管理等融合到一起,淡化工厂的概念,使模块内部的概念更加内聚并且更易理解。
typescript
// 重构后一个模块的定义方式
import { defineModule } from "Mulcon"
export const MyModule = defineModule({
name: 'myModule',
state: () => {
return {
count: 0,
get doubleCount() {
return this.count * 2
}
}
},
action: (state) => {
return {
setCount(count: number) {
state.count = count;
}
}
},
onCreated() {
},
onDestroy() {
}
})
如果你是一个前端老司机,对这种定义 js 对象的语法一定不会感到陌生,它不仅在 vue 中出现过,也在其它各种知名框架/库中都出现过。
我们希望通过新的编码范式能够减少样板代码的编写,引入现有的成熟方案解决编码范式上的问题。
Code locality 是一个编程概念,指的是程序中相关的代码在物理位置上的接近程度。
提高代码的局部性的最大收益是减少上下文切换带来的各种隐性成本。
样板代码(Boilerplate):指的是在程序开发中经常出现的、几乎没有变化的代码段。这类代码通常用于完成某些通用的、重复的任务,比如初始化一个对象、建立数据库连接等。样板代码的主要问题是它会增加代码量,降低程序的可读性和可维护性,同时也可能导致错误的复制粘贴。
增强类型
ts 的出现为我们日常编码中节省了大量的时间、精力,但如果未合理使用 ts 可能会让我们失去提示和类型检查。
当我们在整个项目中都合理地使用 ts 编写代码时,几乎所有的改动都能让你"安全下车",这使得你在做功能重构、字段重命名等较为敏感的改动时,ts 编译器能够及时的通知到你,而如果在改动依赖的地方使用了 any,ts 编译器则无法通知到你。
为什么会丢失类型?
MulCon 1.0 在设计之初并未过多考虑"外部"在使用模块时访问定义在模块上的 state 和 action 等,表现出的问题则是,虽然在定义模块时给定了 state 类型,但在创建模块后依然会丢失,外部感知不到这个模块上的 state 类型。
我们来最小化复现一下问题:
typescript
class Foo {
userInput: string;
constructor(userInput: string) {
this.userInput = userInput;
}
getUserInput() {
return this.userInput
}
}
const foo = new Foo('Hello');
const userInput = foo.getUserInput();
// ^? string
如何保存类型并衍生更多类型?
state、action、children-子模块 都属于用户输入,想要将用户输入的运行时内容转为类型并在多个地方使用,在 ts 中仅限于函数或 class 的"泛型",而两者的本质其实是一样的。(interface 或 type 属于纯类型,无法接受运行时数据)。
我们来看看用泛型改造后,储存用户输入的类型,并对类型二次加工:
scala
class Foo<T extends string> {
userInput: T;
constructor(userInput: T) {
this.userInput = userInput;
}
getUserInput(): T {
return this.userInput
}
getUserInputUpperCase(): Uppercase<T> {
return this.userInput.toUpperCase() as Uppercase<T>
}
}
const foo = new Foo('Hello');
foo.getUserInput();
// ^? Hello
foo.getUserInputUpperCase();
// ^? HELLO
可以看到我们在使用泛型后,传入的值被转为了类型储存为变量,并能对变量进行任意形式的加工。
类型剥离、复用
如果 state 直接传入了 js 对象,那我们应该如何复用这个 js 对象的类型?
当一个类型也带有泛型时,我们可以轻易将其泛型从类型上剥离出来复用,而不必去引入类型。
我们还是以上面定义的 class 为例,实例化一个新的对象:
scala
const foo = new Foo('Hello');
// ^? 此时的 foo 类型为 Foo<'Hello'>
type GetFooUserInputType<T extends Foo<unknow>> = T extends Foo<infer U> ? U : never
type UserInputType = GetFooUserInputType<typeof foo>
// ^? Hello 此时我们可以拿到用户输入的类型
这种方式得以让我们在内部深度加工用户的输入,通过用户输入衍生出更多相关的其它类型。
typescript 中的 infer 关键字需配合 extends 关键字使用,用于提取出 infer 所在位置(泛型,函数参数,返回值等)的具体类型。
复用
对于相似功能,重复的代码意味着重复的改动。适当地将代码抽象后单独维护,提供给其它模块直接使用有助于提高工作效率,甚至针对该复用的代码进行单元测试也会更加容易。

作为 children 复用
在老版本的 MulCon 中,所有的模块都是以 class 形式来定义的,并且通过继承 abstract class 来约束子类的行为,但在后续的使用中发现很多难以解决的痛点。
State + actions 是我们的模块基本组成部分,通过继承,我们能够实现一部分能力的复用,但对于多个功能模块的复用,并未探索出好的方案,更多采用了"子模块"的形式来复用代码,并且这种形式还会遇到下面的问题:
- 模块与宿主模块的state和action更割裂,难以优雅地融合
- 生命周期未自动同步,模块无法自动感知宿主的生命周期
模块切片(Slice)
我们借鉴了 redux-toolkit 中的 slice
概念,将一个模块中可以抽象出去的部分称为模块切片。其特点是在引用它的模块在被创建时 mixin 到模块中,并感知这个模块的所有生命周期。并且我们规避掉 mixin 技术容易与目标命名冲突的问题,提供了命名空间。
typescript
const mySlice = defineSlice({
state: () => ({
count: 1
}),
action: (state) => ({
setCount(count: number) {
state.count = count;
}
})
})
const myModule = defineModule({
// 灵活添加命名空间,避免与宿主冲突
slices: { mySlice },
state: () => ({
count: 2,
}),
action: (state) => ({
setSliceCount(count: number) {
// state 中通过我们定义的 namespace 访问 slice 的 state
state. mySlice . count = count;
}
})
})
const myModule = myModule.create();
// 在外部也能直接通过 namespace 访问 slice 中的 action
myModule. action . mySlice . setCount ( 888 );
它相较于使用 children 的不同点在于:
- 通过 namespace 访问有更短的路径
- 使用起来像导入一个模块一样简单
- 任意粒度的状态抽象,无心理负担(未作为独立单元产生额外运行时开销)
- 感知宿主模块的生命周期
模块销毁
当我们销毁一个 Tab 时,如何确保它是安全可销毁的?
👉🏻 在关闭 Tab 按钮点击时发起确认
这个是最简单的方案,但也存在诸多问题
用户的内容已经保存,无脑弹窗会打扰用户。
👉🏻 子模块通知 Tab 是否需要确认
该方案比较符合我们的需求,也是最先想到的解决方案。但从设计模式上来说,子模块依然违背了最少知识原则, 子模块不应该关注父模块 UI 交互上的逻辑。

我们遵循最少知识原则, 在发起销毁请求时,仅关注自身是否能被销毁即可,在父级模块发起销毁请求时,MulCon 内部调用 onDestroy 并接受其返回值,检查所有模块是否可以被销毁,从而告诉调用方每个子模块的销毁"意见"
javascript
const myModule = defineModule({
...
onDestroy() {
return true / false
}
}).create();
const canDestroy = myModule.destroy();
至此,子模块的销毁不依赖外部任何逻辑,仅需关注自身是否可以被销毁。下面是销毁逻辑示意图:

插件系统
我们在1.0中一开始便设计了命令系统,用于解决高频的"跨模块"的命令执行,但我们在迭代 2.0 时发现,部分业务可能不需要用到命令这一能力,于是想到将命令系统作为"插件"分离出去,需要用到这部分能力,则引入这个插件即可。
横向扩展
我们通过感知模块生命周期、StateChange 事件等方式,横向扩展出更多的能力。

下面是结合一个模块,定义一个插件的伪代码:
javascript
const FooModule = defineModule({
name: 'foo',
state: () => ({
count: 1,
}),
actions: (state) => ({
setCount: () => {
state.count++;
}
}),
+ plugins: [
+ () => ({
+ onCreated() {
+ // 在插件内感知模块创建
+ },
+ onDestroy() {
+ // 在模块销毁前调用
+ },
+ onStateChange() {
+ // 捕获状态改变事件
+ }
+ })
+ ]
});
结合以上插件定义,我们内置了命令插件,内部实现包括:
- 一个命令上下文,在该上下文中命令被储存、和管理
- 定义命令并在模块创建后注册到命令中心
- 模块销毁时将命令从从命令中心移除释放内存
将命令插件化后,我们解决了前文中提到的命令与模块深度绑定的问题,作为可选能力灵活引入。

命令模式在富文本编辑器领域有非常多的实践,通过命令模式可以轻松实现编辑器内容的撤销与重做以及其它复杂的功能。我们将命令模式引入 MulCon,希望在降低模块的间耦合之后,也能够支持撤销的能力,使得用户的部分前端 UI 操作可以撤销。
更多业务场景
本地持久化
复杂的 UI 状态在未保存数据时丢失会造成极差的用户体验,我们想要在用户关闭标签页或浏览器发生崩溃时能从本地甚至远端数据恢复这些状态,基于插件模型和树状模块结构,我们设计出能够基于任意模块节点进行自动持久化的插件,只需配置 storageKey 即可它的设计也非常简单。

时间旅行
在命令插件中我们已经实现了撤销/重做,为什么还需要一个插件去实现撤销/重做呢,命令模式的撤销与重做是基于 command payload去做的,也就是说必须使用命令才能记录操作,而我们不可能所有的状态都用命令去做,于是我们依然基于模块提供的这些能力,去构建一个能够将状态回放至任意操作上去的插件。


性能
我们在 2.0 重构完成后进行了多轮性能测试,发现在 370 个子模块同时创建与销毁都消耗了大量的时间,通过使用性能分析工具针对多个消耗性能的点进行诊断
减少无效访问收集
响应式状态管理方案的工作原理是收集访问过的属性路径,在属性更新时触发组件的渲染,此时再次收集访问的属性。我们在视图中通过 query 查询子模块时,遍历了模块池中的的其它模块,导致触发了不必要的属性收集,在连续创建多个模块时触发 path 更新导致出现了 react 无效的 re-render。
我们默认将 query 阶段的 path 访问标记为不参与依赖收集,从而大大减少了模块在创建时触发无效的渲染次数。
使用更高效率的 API
模块顺序与位置个数在发生变动时,可能需要更新其在整颗模块树中的位置描述,我们在构建 path 时是以向上遍历的形式,所以直觉上就会从一个数组中反向 push 路径

当模块广度与深度都出现较大规模时,我们发现销毁一个模块然后去刷新兄弟模块的 path 时消耗了大量的时间,排查后发现 js 的unshift
函数非常的消耗性能,从其行为来看,在数组首端添加一个元素可能导致其后面所有的元素重新分配内存,而 push 则仅需在末尾追加一个元素,操作会更少,所以效率更高。于是在构建 path 时将 unshift 改为 push,并在返回时 reverse
。
合并多次更改
常规状态下我们在修改响应式状态时,监听状态的地方会立即收到更新事件从而立即重新渲染视图,但在一个同步操作中我们希望能合并这些操作,在操作结束后再更新视图,若没有合并这些操作,多次触发渲染会造成性能上的浪费。
ini
state.count = count;
state.status = status
Fomiliy reactive 本身提供了 action 装饰器来帮我们合并操作,从而将一个同步任务的所有 state change 操作进行合并。
前后对比
我们从语法、实现方式、api 使用、减少无效计算等多个维度优化后,在相同场景下性能都有质的提升:
场景 | Before | After | 提升效果 |
---|---|---|---|
370个一级子模块创建并首次渲染完成 | 300ms | 6ms | 50倍 |
370个一级子模块在强制销毁完成 | 37s | 200ms | 180+倍 |
在 dbw 场景中,我们同时发起上百个 sql 去查询数据库,控制台将批量创建同等数量的查询结果,优化前后在系统内的体验是非常明显的。
落地情况
目前 mulcon 2.0 已经在 dbw 的控制台场景完成从 1.0 的全部切换,涉及 MySQL、Redis、veDB、MongoDB、SQLserver、PostgreSQL等多个数据库产品。
从使用反馈来看,2.0 从类型安全、编码范式、减少样板代码以及学习/上手成本等几个维度都有较大的改进。
我们也在不断探索,从业务中提炼更多典型场景,将复杂业务简单化,在提升效率的同时保证更好的交付。