FORMA

老系统升级与兼容改造实践

1. 背景与目标

公司原有业务系统采用 Node + jQuery 架构,长期运行后暴露出典型遗留系统问题:

  • 技术栈老旧,工程化与类型体系薄弱;
  • 前后端边界不清,页面脚本与业务逻辑耦合严重;
  • 公共方法分散,重复代码多,变更成本高;
  • 发布链路依赖人工流程,质量保障不足。

本次升级以“稳定优先、渐进迁移、可回滚”为原则,整体使用 Vue 3 + TypeScript + Vite + Pinia 搭建新中台外壳,在尽量不改动老业务逻辑的前提下,通过 iframe 承载历史页面,逐步完成能力替换与业务迁移。

核心目标如下:

  • 建立统一登录与权限体系;
  • 统一路由、菜单、接口层和全局状态;
  • 保持关键业务连续可用,避免一次性重写导致的风险;
  • 为后续模块化迁移提供标准化底座。

2. 总体方案与架构分层

2.1 双层架构

  • 外层(新中台)Vue 3 + TypeScript + Vite + Pinia
    • 承担登录鉴权、布局、菜单、动态路由、请求封装、全局异常处理等职责。
  • 内层(老系统)iframe 承载历史页面
    • 继续承载高耦合存量业务,减少对老代码入侵改造。

该方案的价值在于:先统一“系统能力层”,再按业务价值与复杂度分批迁移页面,降低全量替换风险。

2.2 迁移阶段建议

可按以下节奏推进(可根据团队规模调整):

  1. 底座期(1-2 周):搭建脚手架、鉴权、路由、请求层、日志与监控。
  2. 并行期(2-6 周):新老页面并存,优先迁移高频/高收益页面。
  3. 收敛期(持续):下线低价值旧页,沉淀组件与规范,建立迁移验收机制。

3. 升级范围与关键改造点

3.1 登录与账户体系重构

  • 以新框架重建登录、重置密码、会话续期流程;
  • 统一错误码处理(如未登录、会话过期、权限不足);
  • 避免在 localStorage 明文存放敏感信息,优先采用 HttpOnly Cookie(若后端条件允许)。

3.2 路由与权限(后端主导)

推荐由后端维护路由元数据与权限点,前端根据返回的路由树动态渲染菜单与页面,并通过路由守卫控制访问。

建议路由元数据至少包含:

  • pathnamecomponent(或 iframeUrl);
  • meta.titlemeta.iconmeta.rolesmeta.keepAlive
  • 页面级权限点(如 user:createorder:refund)。

3.3 基础能力统一封装

  • utils:日期、数值、字符串、安全转义、节流防抖等;
  • hooks:加载态、弹窗、消息、分页筛选等可复用交互逻辑;
  • config:系统常量、品牌配置、环境差异配置;
  • store:用户信息、权限状态、全局 UI 状态;
  • http:请求拦截、响应归一化、错误上报。

3.4 类型与接口契约治理

  • 使用 TypeScript 描述接口响应结构与业务实体;
  • 通过枚举/常量集中维护 API 路径,减少“魔法字符串”;
  • 建议与后端约定统一响应结构(如 code/message/data)。

4. 环境配置与多环境支持

企业项目通常存在开发、测试、预发布、生产等多环境。接口域名、静态资源域名、iframe 宿主域名可能不同,建议通过 Vite .env 文件管理。

示例:

bash
VITE_APP_API_URL=https://api.example.com
VITE_APP_APP_ID=123456
VITE_APP_IFRAME_HOST=https://legacy.example.com

代码中读取:

ts
const apiUrl = import.meta.env.VITE_APP_API_URL;
const iframeHost = import.meta.env.VITE_APP_IFRAME_HOST;

注意事项:

  • 仅以 VITE_ 前缀声明的变量会暴露到前端;
  • 不要将密钥、数据库密码等敏感信息写入前端环境变量;
  • 为每个环境配置独立域名白名单与 CORS 策略。

5. 基础能力封装实践

5.1 axios 请求层(示例)

以下示例强调两个实践:请求阶段统一注入上下文响应阶段统一归一化与错误处理

typescript
import axios, {
  type AxiosInstance,
  type InternalAxiosRequestConfig,
  type AxiosResponse,
} from "axios";
import qs from "qs";

const instance: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_APP_API_URL as string,
  timeout: 60_000,
});

instance.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = localStorage.getItem("token");
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }

    if (config.method === "get" && config.params) {
      config.paramsSerializer = (params) =>
        qs.stringify(params, { arrayFormat: "repeat" });
    }
    return config;
  },
  (error) => Promise.reject(error),
);

instance.interceptors.response.use(
  (response: AxiosResponse) => response.data,
  (error) => {
    const status = error?.response?.status;
    if (status === 401) {
      // 可在此做统一登出与重定向
    }
    return Promise.reject(error?.response ?? error);
  },
);

export interface IHttpResult<T> {
  code: number;
  message: string;
  data: T;
  success: boolean;
}

export const httpServer = {
  get<T = unknown, U = unknown>(url: string, params?: U) {
    return instance.request<IHttpResult<T>>({ url, method: "get", params });
  },
  post<T = unknown, U = unknown>(url: string, data?: U) {
    return instance.request<IHttpResult<T>>({ url, method: "post", data });
  },
  postForm<T = unknown, U = unknown>(url: string, params?: U) {
    return instance.request<IHttpResult<T>>({
      url,
      method: "post",
      data: qs.stringify(params, { arrayFormat: "repeat" }),
      headers: {
        "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
      },
    });
  },
};

5.2 API 枚举与函数封装(示例)

typescript
enum UserApi {
  UploadImage = "/api/upload/image",
  DeleteImage = "/api/upload/delete",
  ImageList = "/api/upload/list",
}

import { httpServer } from "@/http";

export function getImageList<T, U>(params: U) {
  return httpServer.get<T, U>(UserApi.ImageList, params);
}

6. iframe 并行阶段的体验优化

6.1 常见问题

简单使用 <iframe :src="url"> 在路由切换时会触发整页重载,导致:

  • 重复请求静态资源与接口;
  • 表单临时输入丢失;
  • 页面滚动位置与交互上下文丢失。

6.2 优化策略

  • 使用 v-show 控制显隐,尽量避免卸载重建;
  • 维护 iframe 实例缓存(如按路由 path 建立 Map);
  • 在切换前后同步滚动位置;
  • 对高频页面可增加预加载策略(注意并发与内存上限)。

示例(简化):

vue
<iframe
  v-for="item in routes"
  :key="item.path"
  v-show="item.path === route.path"
  :src="item.iframeUrl"
/>

7. 新老系统通信与图片预览兼容

老系统常以“新标签页打开图片”为默认行为;新系统可能希望统一为 Element Plus 预览组件。可通过 postMessage 做父子通信,但必须关注安全边界。

7.1 通信示例

javascript
// iframe 内部(老系统)
window.parent.postMessage(
  {
    command: "open-image",
    data: { url },
  },
  "https://admin.example.com",
);

// 外层新系统
function handleMessage(event) {
  const trustedOrigin = "https://legacy.example.com";
  if (event.origin !== trustedOrigin) return;

  if (event.data?.command === "open-image") {
    const imageUrl = event.data?.data?.url;
    // 1) 调用图片预览组件,或
    // 2) 兜底 window.open(imageUrl, "_blank", "noopener,noreferrer")
  }
}

window.addEventListener("message", handleMessage);
// 卸载时移除监听
window.removeEventListener("message", handleMessage);

7.2 安全建议

  • targetOrigin 不要使用 "*",应指定明确来源;
  • 接收消息时必须校验 event.origin 和消息结构;
  • 对 URL 做协议与域名白名单校验,防止注入恶意链接;
  • 统一定义消息协议版本(如 type, version, payload),降低联调歧义。

8. 风险控制与验收建议

8.1 风险清单

  • 会话一致性风险:新老系统 token 刷新机制不一致;
  • 权限漂移风险:菜单可见但接口无权,或反之;
  • 性能风险iframe 数量过多导致内存上涨;
  • 通信风险postMessage 校验不足带来安全隐患。

8.2 验收维度

  • 功能正确性:登录、登出、权限、关键业务流程;
  • 体验一致性:切页速度、滚动位置恢复、图片预览行为;
  • 稳定性:接口错误回退、白屏兜底、异常链路可观测;
  • 可维护性:新增页面开发时间、重复代码减少比例、问题定位时长。

9. 结语

对遗留系统升级而言,“推倒重来”通常不是最优解。通过“新外壳 + 老页面承载 + 渐进迁移”的策略,可以在业务连续运行的前提下,逐步获得现代工程能力,并为后续彻底去遗留化奠定基础。


参考文献

  1. Vite 文档:环境变量与模式
    https://vite.dev/guide/env-and-mode.html
  2. Vue 3 官方文档
    https://vuejs.org/
  3. Pinia 官方文档
    https://pinia.vuejs.org/
  4. Axios 官方文档
    https://axios-http.com/
  5. qs 仓库
    https://github.com/ljharb/qs
  6. MDN: window.postMessage
    https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
  7. Element Plus 官方文档
    https://element-plus.org/
  8. OWASP Top 10
    https://owasp.org/www-project-top-ten/

相关文章