Crispin's Blog

从零搭建金融贷款低代码前端框架:xxx-editor 实践总结

最近有点忙,在做一个面向金融贷款业务的前端框架项目 xxx-editor,整体采用 pnpm Monorepo 架构,涵盖低代码编辑器、多个 Next.js 子应用和一套完整的组件库。这篇文章记录一下整体架构设计和几个值得分享的技术点。

项目背景

金融贷款类 H5 应用有几个典型特点:

  1. 页面结构高度相似:注册、登录、申请步骤、还款等页面在不同产品线之间大量复用
  2. 多地区多品牌:同一套业务逻辑需要支持菲律宾、香港等不同市场,UI 和文案各有差异
  3. 安全要求高:请求加密、活体检测、证件上传等功能必不可少

基于这些特点,我们决定用 Monorepo 把所有子应用和共享包统一管理,并在此基础上做一个低代码编辑器,让运营和产品可以快速配置页面。

Monorepo 结构设计

1
2
3
4
5
6
7
8
9
10
11
xxx-editor/
├── packages/
│ ├── core/ # 低代码编辑器核心(Designer + Renderer)
│ ├── widgets/ # 基础 UI 组件库(40+ 组件)
│ ├── templates/ # 页面级模板(Login、Register、MePage 等)
│ ├── components/ # 增强型表单组件
│ └── utils/ # 工具函数(HTTP、加密、格式化、缓存、i18n)
├── server/ # 主业务 Next.js 应用
├── ph-life/ # 菲律宾生活服务应用
├── ph-life-website/ # 菲律宾官网
└── max-cash-portal/ # Max Cash 多语言门户

包管理用的是 pnpm workspaces,pnpm-workspace.yaml 配置如下:

1
2
3
4
5
6
packages:
- "packages/*"
- "server"
- "ph-life"
- "ph-life-website"
- "max-cash-portal"

各子应用通过 workspace 协议引用内部包,例如:

1
2
3
4
5
6
{
"dependencies": {
"@xxx/widgets": "workspace:*",
"@xxx/utils": "workspace:*"
}
}

这样改动组件库后,所有子应用都能实时感知,不需要发布 npm 包。

HTTP 层的设计

HTTP 层是整个框架里最复杂的部分之一,封装在 packages/utils/src/http/axios 下。核心是一个 VAxios 类,支持:

  • 请求/响应拦截:统一处理 Token 注入、错误码映射
  • 请求取消:通过 AxiosCanceler 管理 pending 请求,页面切换时自动取消
  • 自动重试AxiosRetry 对网络抖动场景做指数退避重试
  • 请求加密:敏感接口的请求体走 AES 加密
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建实例时注入 transform 钩子
const http = createAxios({
transform: {
beforeRequestHook(config, options) {
// 拼接时间戳、加密 body 等
},
responseInterceptors(res) {
// 统一解包 { code, data, msg }
},
responseInterceptorsCatch(error) {
// checkStatus 映射错误码到提示文案
},
},
});

Token 刷新用的是经典的”请求队列”方案:刷新期间的所有请求挂起,刷新成功后统一重放。

组件库的 Variant 模式

packages/widgets 里的组件大量使用了 Variant(变体) 模式。以 Card 为例:

1
2
3
4
5
6
Card/
├── Basic/ # 基础卡片
├── VariantOne/ # 贷款产品卡片
├── VariantSecond/ # 还款信息卡片
├── VariantRecord/ # 账单记录卡片
└── VariantSeventh/ # 推广活动卡片

每个 Variant 有独立的 definition.ts(props 类型定义)、styled.ts(样式)和主组件文件,互不干扰。这种结构的好处是:

  • 新增变体不影响已有变体,改动范围可控
  • Storybook 可以为每个变体单独写 story,方便 QA 验收
  • 低代码编辑器可以把每个变体作为独立的”积木块”注册

低代码编辑器核心

packages/core 实现了一个轻量的低代码运行时,主要由三部分组成:

  • Designer:拖拽画布,左侧 WidgetPanel 展示可用组件,右侧 SettingPanel 配置属性
  • Renderer:根据 JSON Schema 渲染页面,通过 WidgetListRender 递归渲染组件树
  • register:组件注册机制,将 widgets 中的组件映射到编辑器可识别的 key
1
2
3
4
5
6
// 注册一个组件
register({
key: "CardVariantOne",
component: CardVariantOne,
defaultProps: { title: "贷款产品", amount: 0 },
});

JSON Schema 的结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
"widgets": [
{
"key": "CardVariantOne",
"props": { "title": "快速贷", "amount": 50000 }
},
{
"key": "Button",
"props": { "text": "立即申请", "type": "primary" }
}
]
}

加密工具的分层设计

packages/utils/src/encrypted 里的加密工具做了比较清晰的分层:

1
2
3
4
encrypted/
├── implForStorage.ts # 工厂类,支持 AES / MD5 / SHA256 / SHA512 / Base64
├── standard.ts # 对外暴露的标准 API
└── index.ts

工厂类用单例模式管理各加密实例,避免重复初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
class AesEncryption {
private static instance: AesEncryption;
static getInstance(options: AesEncryptionOptions) {
if (!this.instance) this.instance = new AesEncryption(options);
return this.instance;
}
encrypt(value: string) {
/* ... */
}
decrypt(value: string) {
/* ... */
}
}

对外只暴露 encryptByAESdecryptByAESencryptByMd5 等函数,调用方不需要关心内部实现。

多语言方案

max-cash-portal 是一个多语言门户,用的是 Next.js App Router 的 [lang] 动态路由方案:

1
2
3
4
app/
└── [lang]/
├── layout.tsx # 根据 lang 加载对应字典
└── page.tsx

middleware.ts 负责检测用户语言并重定向:

1
2
3
4
export function middleware(request: NextRequest) {
const locale = getLocale(request); // 从 Accept-Language 或 cookie 读取
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
}

字典文件放在 dictionaries/ 下,按语言分文件,generateStaticParams 在构建时生成所有语言的静态页面。

Storybook 组件文档

packages/stories 是整个组件库的 Storybook 工程,独立作为 @xxx/storybook 包存在,运行在 6006 端口。
这也是我认为架构设计里比较成功的一点,Storybook 和组件库虽然紧密相关,但职责不同,分开管理更清晰并且方便 UI 和 QA 访问而不需要跑整个 Next.js 应用。

配置要点

Storybook 使用的是 @storybook/react-webpack5,在 webpackFinal 里配置了 workspace 包的路径别名,让 stories 可以直接 import 内部包的源码而不是构建产物:

1
2
3
4
5
6
7
8
9
// packages/.storybook/main.js
webpackFinal: async (config) => {
config.resolve.alias = {
"@xxx/widgets": path.resolve(__dirname, "../widgets/src"),
"@xxx/utils": path.resolve(__dirname, "../utils/src"),
"@xxx/components": path.resolve(__dirname, "../components/src"),
};
return config;
};

这样改了组件源码后,Storybook 热更新可以直接生效,不需要先 build 包。

样式方面配置了完整的 Less / Sass / CSS Modules 支持,因为 widgets 里的组件混用了多种样式方案。

Story 的写法

项目里的 story 统一用 StoryFn 模板模式,以 Guide 组件为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Template: StoryFn<GuideProps> = (args) => <Guide {...args} />;

export const Default = Template.bind({});
Default.args = {
title: "Item List",
items: [{ imageSrc: "...", itemTitle: "Item 1", description: "..." }],
};

export const CustomStyles = Template.bind({});
CustomStyles.args = {
// 通过 cssClassnames + cssStyles 注入自定义样式
cssStyles: `.xxx-guide img { width: 32px; }`,
};

每个组件至少有 Default 和边界场景(如 Empty)两个 story,复杂组件还会有 CustomStyles 等变体,方便 QA 和设计师对照验收。

Form 组件的 story 比较特殊,用了 argTypescontrol: 'check' 让测试人员在 Storybook 面板上勾选需要展示的表单字段,动态渲染表单,不需要改代码就能验证各种字段组合:

1
2
3
4
5
6
argTypes: {
fields: {
control: 'check',
options: xxx_FORM_FIELDS.map((field) => field.label),
},
}

启动与构建

1
2
3
4
5
# 开发模式
pnpm dev:storybook

# 构建静态站点(可部署到 CDN 供设计师访问)
pnpm build:storybook

小结

整个项目下来,几个体会比较深的点:

  1. Monorepo 的收益在中后期才明显:前期搭架子比较费时,但后期多个子应用共享组件和工具函数时,维护成本大幅降低
  2. Variant 模式适合业务组件:纯 UI 组件用 props 控制变体就够了,但业务组件的差异往往不只是样式,Variant 模式更清晰
  3. 低代码不是银弹:低代码适合结构固定、配置频繁的场景,对于逻辑复杂的页面,还是直接写代码更高效
  4. 加密要早规划:加密方案如果后期才加,改动面会很大,最好在 HTTP 层统一处理,业务代码无感知