Skip to content

旧系统怎么改:不推倒重来的渐进式路线

项目能稳定运行很多年,通常说明它解决了大量真实问题。技术栈旧并不等于必须重写,真正需要解决的是:每次修改都不确定会影响哪里。

本文根据一个同时存在 PHP/Yaf、Vue 2、Webpack 3/4、原生小程序和 Vue 3/Vite 子项目的真实工程环境整理。示例已经删除内部名称和业务实现。

读完你能解决什么

  • 判断一个旧项目应该重构、升级还是局部重写;
  • 在没有大量测试的情况下建立第一层保护;
  • 先统一请求、错误和身份,再逐步整理内部代码;
  • 让旧技术和新技术共存,而不是互相拖累。

先别急着重写

下面这些问题很常见:

  • 控制器很长,SQL 和业务判断混在一起;
  • Vue 入口注册了很多全局组件;
  • 一个路由文件有几千行;
  • 请求方法挂在 Vue.prototype 上;
  • 旧构建只能使用特定 Node 版本;
  • 多个小程序复制相似的登录和请求代码。

它们说明系统需要治理,但不代表“重新做一套”一定更快。

重写还要重新发现所有隐藏规则,并处理新旧数据、双写、灰度和回滚。没有可靠行为测试时,重写只是把已知问题换成未知问题。

判断该怎么改

现状优先做法
业务规则经常变,没人说得清完整行为先补日志和特征测试
某个模块输入输出清楚,数据也独立可以局部重写
只是构建工具太旧先固定可用环境,再逐项升级
多个客户端解释接口不一样先统一请求适配和错误结构
核心写流程经常重复或状态不明先处理幂等、事务和状态机
模块之间随意改同一批表先明确数据所有者,不急着拆微服务

最稳的路线分四个阶段

text
阶段 1:固定现有行为

阶段 2:统一系统边缘

阶段 3:迁移一条完整业务流程

阶段 4:再升级框架和工具

“边缘”指客户端请求层、服务端入口、身份处理和第三方调用。这些位置最容易隔离变化,也最适合作为第一步。

落地动作一:让旧项目可以重复构建

先记录并固定:

  • PHP、扩展和操作系统要求;
  • Node 与 npm 版本;
  • 依赖锁文件;
  • 构建命令和产物目录;
  • 环境变量名称,但不记录真实值;
  • 发布顺序和回滚方式。

旧项目常被 node-sass、旧 Webpack 插件或原生模块卡住。先让 CI 能稳定复现当前构建,再谈升级。

不要在同一次提交里同时升级 Node、Webpack、Vue、UI 组件库和样式工具。失败后很难知道是哪一层改变了行为。

落地动作二:补一小组高价值测试

不用一开始追求很高覆盖率。先保护最重要的几条路径:

text
登录 → 查询列表 → 查看详情
创建 → 提交 → 再次查询
同一操作提交两次 → 只产生一个结果
登录失效 → 只恢复一次
外部调用超时 → 本地状态仍能查询

还可以给旧接口写“特征测试”。它不评价旧行为是否漂亮,只记录当前真实表现,确保重构前后没有意外变化。

js
it('keeps the legacy empty-list shape', async () => {
  const result = await legacyClient.fetchList()
  expect(result).toEqual({ items: [], total: 0 })
})

发现旧行为本身有问题时,单独修复,不要悄悄夹在重构中。

落地动作三:先统一客户端请求层

大型 Vue 2 项目里,常见三种请求方式:独立 API 模块、全局原型方法、自有 XHR 封装。

不用一次全部替换。先在外面包一层稳定接口:

js
export async function request(options) {
  try {
    const raw = await legacyTransport.send(options)
    return normalizeSuccess(raw)
  } catch (error) {
    throw normalizeError(error)
  }
}

新页面只调用 request,旧页面继续工作。等调用点逐步迁移完成,再删除旧入口。

兼容层必须有统计和删除条件,否则它会成为新的永久代码。

落地动作四:让服务端入口只做四件事

旧控制器可以先从下面四件事开始收敛:

text
1. 解析输入
2. 建立当前用户上下文
3. 调用一个业务用例
4. 返回统一结果

简化后的 PHP 写法:

php
final class SubmitController
{
    public function __invoke(HttpRequest $request): HttpResponse
    {
        $context = $this->contexts->fromRequest($request);
        $command = SubmitCommand::fromArray($request->body());
        $result = $this->submit->execute($command, $context);

        return $this->responses->success($result);
    }
}

一开始,$this->submit 内部仍可调用旧模型。重要的是入口和页面先依赖稳定边界,内部才能安全替换。

落地动作五:选一条业务流程做试点

不要从“整理所有目录”开始。选择一条完整、风险适中的业务流程:

text
页面提交
  → API
  → 权限检查
  → 业务规则
  → 数据写入
  → 外部通知
  → 页面查询最终结果

适合试点的流程应具备:

  • 输入输出清楚;
  • 有真实价值,但不是最高风险核心;
  • 可以通过功能开关只开放给少量用户;
  • 有明确验证指标;
  • 可以快速切回旧路径。

迁移完成后复盘方法,再复制到下一条流程。

功能开关怎么用

php
if ($flags->enabled('new_submit_flow', $context->tenantId)) {
    return $this->newFlow->execute($command, $context);
}

return $this->legacyFlow->execute($command, $context);

功能开关需要同时具备:

  • 默认状态;
  • 修改权限和审计;
  • 新旧路径的指标对比;
  • 关闭开关后的回退结果;
  • 计划删除日期。

开关越多,系统状态越复杂。完成迁移后要及时清理。

Vue 2 项目先整理什么

main.js 只负责启动

根入口适合注册路由、Store、监控和少量全局样式。业务弹窗和页面专用组件应在模块内引入,不要全部放进启动文件。

把大路由按业务区域拆开

先拆文件,不改 URL:

js
import accountRoutes from './modules/account'
import reportRoutes from './modules/report'

export default new Router({
  routes: [...accountRoutes, ...reportRoutes]
})

每个路由使用统一元信息,例如标题、权限、是否缓存。路由守卫只处理登录和权限,不负责所有页面数据加载。

一端只选一个主要 UI 库

历史页面可以继续使用已有组件库,新页面明确默认选择。若确实要混用,通过业务组件包一层,不让每个页面直接调用三种弹窗 API。

小程序项目怎么减少复制

多个原生小程序可以把代码分成三类:

text
纯逻辑:金额、日期、校验、状态转换
运行时适配:request、storage、login、upload
具体页面:主题、导航、内容和交互

优先共享纯逻辑和协议定义。品牌页面和平台生命周期不必强行共用。

如果同一工作区已经出现 Vue 3、Vite 或 uni-app 子项目,也不代表旧原生项目必须立刻迁移。先让新旧项目遵守同一接口和错误语义,再判断哪些功能值得迁移。

数据结构怎样安全迁移

使用“先加、再迁、后删”的顺序:

text
增加新字段或新结构

新旧代码都能读

由一个写入适配层同时维护新旧结构

小批量回填历史数据

持续校验新旧结果

切换读路径并观察

停止写旧结构

确认稳定后删除旧结构

迁移期仍会有新请求写入,所以双写必须集中在一个适配层,并有差异监控;不要让各控制器各写一份。回填任务要能重复执行,保存进度,并限制对数据库的压力。不要用一个超大事务一次搬完所有数据。

什么时候才适合升级框架

满足下面条件后,升级会安全很多:

  • 构建环境已经固定;
  • 核心流程有冒烟测试;
  • 请求和错误有统一适配层;
  • 页面不再直接依赖大量全局方法;
  • 新旧路径可以切换;
  • 出现问题时知道看哪些指标。

这时再升级构建器或框架,变化范围更小,也更容易回滚。

一个简单的 30 / 60 / 90 天计划

前 30 天

  • 固定构建环境;
  • 画客户端—业务能力表;
  • requestId 和错误分类;
  • 选 5 条核心冒烟流程;
  • 选定一个试点功能。

31~60 天

  • 统一客户端请求适配;
  • 让服务端入口变薄;
  • 包装第三方 SDK;
  • 为试点补特征测试和契约测试;
  • 在功能开关后运行新路径。

61~90 天

  • 对比新旧路径指标;
  • 演练回滚和失败恢复;
  • 清理无流量兼容代码;
  • 把方法复制到第二个功能;
  • 单独制定工具链升级计划。

常见错误

只移动文件,不改变依赖

把控制器内容复制到 service 目录,不代表边界变清楚。新的服务仍不应读取全局请求或随意修改其他模块数据。

用微服务解决代码混乱

边界没理清之前拆服务,只会多出超时、重试和分布式一致性问题。

新旧代码永久双写

双写必须有差异监控和退出日期。否则每次变化都要维护两套规则。

一次升级所有依赖

每次升级只解决一个层次的问题,并保留可比较的旧产物。

完成检查

  • [ ] 重构前后的行为有测试对比;
  • [ ] 新路径有日志、指标和错误分类;
  • [ ] 可以按小范围逐步开启;
  • [ ] 关闭开关可以安全返回旧路径;
  • [ ] 数据迁移可以暂停和继续;
  • [ ] 兼容逻辑有删除日期;
  • [ ] 没有把生产配置和凭据写进仓库;
  • [ ] 团队可以用同样方法处理下一个功能。

最后记住

旧系统现代化不是“一次换新”,而是把系统训练成能够安全改变。

先固定行为,再统一边缘;先迁移一条流程,再复制方法;先做到可观察、可回滚,最后才升级框架。

为复用而记录,为理解而整理。