小程序架构演进与遗留重构

面对几千行的巨型 Page、满天飞的 setData 和无处安放的业务逻辑,如何安全地重构一个遗留小程序。本文给出分层目标架构、绞杀者模式的渐进迁移路径、状态收敛与 Service 抽取的具体技法,以及用架构适应度函数与 ESLint 规则守住重构成果的工程手段。

几乎每个存活两年以上的小程序都会变成这样: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,防止回退。整个过程中,每一次合并都应该是可发布的——任何需要「停下来等重构完成」的方案都注定会失败。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniprogram」更多文章

  1. 小程序第三方 SDK 集成与治理
  2. 小程序无障碍与适老化改造
  3. 小程序深色模式与主题系统