几乎每个存活两年以上的小程序都会变成这样:pages/order/detail.js 有 2800 行,里面既有接口请求、又有表单校验、还有埋点上报;setData 在十几个函数里零散出现,每次改动都要担心性能;一个业务字段改了名,要在七个文件里搜索替换;想加个新页面,得先把某个文件复制一份再删删改改。
这类代码不是一天写坏的,也没法一天改好。真正的问题是:改动风险高于收益,导致团队宁愿继续堆代码也不愿重构。要打破这个循环,重构必须是渐进的、可验证的、随时可以停下来的。本文给出一条从诊断到落地的完整路径。
一、诊断:遗留小程序的典型症状
重构之前先量化现状,否则无法证明改进了。
1.1 症状清单
| 症状 | 量化指标 | 危害 |
|---|---|---|
| 巨型页面文件 | 单文件 > 800 行 | 理解成本高,改动易漏 |
| setData 滥用 | 单页面 > 30 处 | 渲染性能差,状态难追踪 |
| 无分层 | 页面直接 wx.request | 逻辑无法复用与测试 |
| 全局变量耦合 | getApp().globalData 被写 > 10 处 | 隐式依赖,时序 bug |
| 无类型约束 | 无 TS 或 any 遍地 | 重构时改错不报错 |
| 重复代码 | 相似逻辑复制 > 3 处 | 改一处漏三处 |
1.2 用脚本量化
不要靠感觉判断,写个脚本跑一遍:
# audit.py —— 遗留小程序体检
import re, glob, os
def audit(path):
rows = []
for f in glob.glob(f'{path}/**/*.js', recursive=True):
if 'node_modules' in f or 'miniprogram_npm' in f:
continue
src = open(f, encoding='utf-8').read()
rows.append({
'file': os.path.relpath(f, path),
'lines': src.count('\n'),
'setdata': len(re.findall(r'\.setData\(', src)),
'request': len(re.findall(r'wx\.request\(', src)),
'global': len(re.findall(r'getApp\(\)\.globalData', src)),
})
return sorted(rows, key=lambda r: -r['lines'])
for r in audit('miniprogram')[:20]:
print(f"{r['lines']:>6} 行 setData={r['setdata']:>3} request={r['request']:>2} {r['file']}")
这份报告就是重构的起点与进度基准。
二、目标架构
2.1 四层模型
pages/ 视图层:只负责渲染与事件转发
order/detail.js
components/ 组件层:可复用的 UI 单元
order-card/
services/ 服务层:业务逻辑,纯 JS,可单测
order-service.js
models/ 数据层:接口请求、数据转换、缓存
order-api.js
utils/ 工具层:无业务语义的纯函数
format.js
分层的核心规则是依赖只能向下:页面依赖 service,service 依赖 model,model 依赖 utils。反向依赖(service 里 import 页面、utils 里调 service)一律禁止。
2.2 页面退化为「薄壳」
重构后,页面的职责只剩三件:接参数、调 service、setData 渲染结果。
// pages/order/detail.js —— 重构后
import orderService from '../../services/order-service'
Page({
data: { order: null, loading: true, error: '' },
async onLoad(query) {
await this.loadOrder(query.id)
},
async loadOrder(id) {
this.setData({ loading: true, error: '' })
try {
const order = await orderService.getDetail(id)
this.setData({ order, loading: false })
} catch (e) {
this.setData({ loading: false, error: e.message })
}
},
onPayTap() {
orderService.pay(this.data.order.id)
}
})
页面里不再出现 wx.request、不再出现业务判断、不再出现埋点细节。
2.3 服务层要能被单测
服务层不 import 任何小程序 API,所有副作用通过注入传入:
// services/order-service.js
export function createOrderService({ api, analytics, cache }) {
return {
async getDetail(id) {
const cached = cache.get(`order:${id}`)
if (cached) return cached
const raw = await api.fetchOrder(id)
const order = normalizeOrder(raw)
cache.set(`order:${id}`, order, 60 * 1000)
analytics.track('order_detail_view', { id })
return order
},
async pay(id) {
analytics.track('order_pay_start', { id })
return api.createPayment(id)
}
}
}
// 生产环境注入真实依赖
export default createOrderService({
api: require('../models/order-api'),
analytics: require('../utils/analytics'),
cache: require('../utils/cache')
})
这样 getDetail 的缓存逻辑、埋点顺序都能用 Jest 覆盖,不用启动小程序。
2.4 状态收敛
setData 散落是性能与可维护性的双重问题。改造方向是「单一数据源 + 集中更新」。可以引入 /miniprogram-state-management/ 里的方案,把跨页面共享的状态(用户信息、购物车、主题)收进 store,页面只订阅自己关心的切片:
// store/index.js
import { createStore } from './mini-store'
export const store = createStore({
state: { user: null, cartCount: 0 },
mutations: {
setUser(s, user) { s.user = user },
setCartCount(s, n) { s.cartCount = n }
}
})
// 页面订阅
Page({
onLoad() {
this.unsubscribe = store.subscribe(
s => ({ cartCount: s.cartCount }),
slice => this.setData(slice)
)
},
onUnload() {
this.unsubscribe()
}
})
页面内的局部状态(表单输入、弹窗开关)仍留在 data 里,不要为了「统一」把所有状态都塞进 store——那是另一种过度设计。组件化拆分的原则可参考 /miniprogram-component-architecture/。
三、渐进迁移:绞杀者模式
一次性重写是小程序重构最大的陷阱:改到一半线上出 bug,回滚成本极高。正确做法是绞杀者模式(Strangler Fig Pattern)——新代码与旧代码并存,逐步把流量从旧实现迁移到新实现,直到旧实现可以删除。
3.1 迁移单元的选择
以「页面」为迁移单元最合适:边界清晰、可独立验证、失败影响可控。不要以「函数」为单位迁移,那样每次改动都同时碰到新旧两套逻辑。
3.2 页面级迁移流程
// pages/order/list.js —— 迁移期:新旧实现并存
import newImpl from '../../services/order-list-v2'
import oldImpl from '../../services/order-list-legacy'
const USE_V2 = wx.getStorageSync('ff_order_list_v2') === true
Page({
async loadList(params) {
const impl = USE_V2 ? newImpl : oldImpl
try {
const data = await impl.fetch(params)
this.setData({ list: data })
} catch (e) {
// 新实现失败时自动回退到旧实现,保证可用性
if (USE_V2) {
wx.reportMonitor('order_list_v2_fail', 1)
const data = await oldImpl.fetch(params)
this.setData({ list: data })
return
}
throw e
}
}
})
灰度开关可以存在本地(调试用),也可以来自服务端配置,实现按用户比例放量,并保证开关关闭后能立刻回退到旧实现。
3.3 迁移节奏
| 阶段 | 动作 | 退出条件 |
|---|---|---|
| 1. 影子运行 | 新旧实现同时执行,只返回旧结果,对比差异 | 差异率 < 0.1% |
| 2. 小流量 | 1% 用户走新实现 | 错误率无上升,性能不劣化 |
| 3. 放量 | 10% → 50% → 100% | 连续 3 天指标稳定 |
| 4. 清理 | 删除旧实现与开关 | 代码中无 legacy 引用 |
影子运行阶段最关键:它在不影响用户的前提下暴露新旧逻辑的差异,是绞杀者模式最容易被跳过、也最不该跳过的一步。这套思路与后端系统里的 绞杀者模式与遗留系统迁移 完全一致,只是迁移单元从服务变成了页面。
四、重构技法
4.1 提取 Service
从巨型页面里抽逻辑时,按「副作用」而非「功能」切分。一个函数里既有 wx.request 又有 setData,就把 wx.request 抽走,setData 留下。
// 重构前:页面里混着请求、转换、埋点
async loadDetail(id) {
const res = await wx.request({ url: `/api/order/${id}` })
const order = { ...res.data, amountText: (res.data.amount / 100).toFixed(2) }
wx.reportAnalytics('view_order', { id })
this.setData({ order })
}
// 重构后:页面只剩 setData
async loadDetail(id) {
const order = await orderService.getDetail(id)
this.setData({ order })
}
4.2 数据转换下沉到 model
接口返回的字段格式(分、时间戳、状态码)应该在 model 层统一转换,页面拿到的永远是可直接渲染的数据。这样接口改字段时只改一处。
// models/order-api.js
function normalizeOrder(raw) {
return {
id: raw.order_id,
amountText: (raw.amount_cents / 100).toFixed(2),
statusText: ORDER_STATUS_MAP[raw.status] || '未知',
createdAt: new Date(raw.created_at * 1000)
}
}
4.3 渐进式类型化
不必一步到位全量 TypeScript,可以只给 service 与 model 加类型(.d.ts 或 // @ts-check),页面暂时保持 JS。这样重构时改动 service 签名会有类型报错提示,收益最大而成本最低。
// models/order-api.d.ts
export interface Order {
id: string
amountText: string
statusText: string
createdAt: Date
}
export function fetchOrder(id: string): Promise<Order>
4.4 埋点下沉
埋点散落在业务代码里是重灾区。把埋点收进 service,页面不再直接调 wx.reportAnalytics。这样埋点口径统一,且新增埋点不用改页面。
4.5 安全网:先补测试
重构前必须先给要改的逻辑补测试。小程序页面的测试成本高,所以优先给即将抽取的 service 写测试——这也是分层的额外收益。没有测试的重构是赌博。
五、度量与守卫
重构最大的风险是「改着改着又退回去了」。必须用自动化手段守住架构约束。
5.1 架构适应度函数
适应度函数(Fitness Function)是把架构规则写成可自动执行的检查:
// scripts/fitness.test.js
const fs = require('fs')
const glob = require('glob')
test('页面不得直接发起网络请求', () => {
const offenders = []
glob.sync('miniprogram/pages/**/*.js').forEach(f => {
const src = fs.readFileSync(f, 'utf-8')
if (/wx\.request\(/.test(src)) offenders.push(f)
})
expect(offenders).toEqual([])
})
test('utils 层不得依赖 services 层', () => {
const offenders = []
glob.sync('miniprogram/utils/**/*.js').forEach(f => {
const src = fs.readFileSync(f, 'utf-8')
if (/require\(.*services/.test(src)) offenders.push(f)
})
expect(offenders).toEqual([])
})
test('单页面文件不超过 500 行', () => {
const offenders = []
glob.sync('miniprogram/pages/**/*.js').forEach(f => {
const lines = fs.readFileSync(f, 'utf-8').split('\n').length
if (lines > 500) offenders.push(`${f} (${lines})`)
})
expect(offenders).toEqual([])
})
这些测试跑在 CI 里,任何违反架构约定的提交都会失败。这是防止架构腐化最有效的手段,思路与 架构适应度函数 完全一致。
5.2 ESLint 规则
轻量约束用 ESLint 表达更自然:
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.object.name='wx'][callee.property.name='request']",
message: '页面与组件禁止直接调用 wx.request,请使用 services 层'
}
],
'no-restricted-globals': [
'error',
{ name: 'getApp', message: '禁止使用全局 getApp(),请通过 store 获取状态' }
]
}
}
5.3 性能基线
重构不能以牺牲性能为代价。给核心页面建立性能基线(首屏时间、setData 次数、包体积),每次提交对比,劣化超过阈值就失败。
| 指标 | 基线 | 阈值 |
|---|---|---|
| 首页启动耗时 | 620ms | 不超过 +10% |
| 列表页 setData 次数 | 8 | 不超过 12 |
| 主包体积 | 1.6MB | 不超过 2MB |
六、架构决策记录
重构过程中会做大量决策:「为什么引入 store 而不是继续用 globalData」「为什么 service 用工厂函数而不是 class」。这些决策如果不记录,半年后新人会重新讨论一遍,甚至改回去。
架构决策记录(ADR)用一个简短的 Markdown 文件记录每次重要决策:
# ADR-007:服务层采用依赖注入而非直接 import
日期:2026-10-07
状态:已采纳
## 背景
service 层直接 import wx API 导致无法单测。
## 决策
service 以工厂函数形式导出,依赖通过参数注入。
## 后果
- 正面:可单测,可替换实现,便于影子运行对比
- 负面:生产环境需要一处组装代码,略显啰嗦
ADR 的价值在于把「为什么这么设计」的上下文固定下来,让后续的讨论有据可依,而不是每次换人就从零重议。
小结
遗留小程序的重构不是一次性的「大扫除」,而是一套可重复的工程流程:先量化症状,再定分层目标,然后用绞杀者模式逐页迁移,最后用适应度函数和 CI 把成果锁住。
落地建议:第一步先跑体检脚本,挑出最痛的两个页面;第二步为它们补 service 层并写单测;第三步用灰度开关做影子运行与放量;第四步把架构规则写进 CI,防止回退。整个过程中,每一次合并都应该是可发布的——任何需要「停下来等重构完成」的方案都注定会失败。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。