注册

这一年我优化了一个46万行的超级系统

背景

我曾带领团队治理了一个超级工程,是我毕业以来治理的最庞大、最复杂的工程系统,涉及到开发的方方面面。下面我给大家列几个数字,大家感受一下:

指标数据
菜单数量250+
代码行数46 
路由数量300+
业务组件、util600+
构建时间6min
关联业务报表、CRM、订单、车辆、配置、财务...

image.png

这是上一任留给我的烫手山芋,而且从需求迭代频次来看,这个系统占据了这个业务部门50%的需求,也就是说,之前的伙计把几乎所有的业务功能都做进了一个系统中,像屎山一般堆积,构建一次的时间长达6-9min,作为一个合格的前端,简直怒火冲天。

问题

面对这样的超级应用,想要解决,必须先把问题整理出来,才好对症下药。

  • 构建时间过长,影响开发体验。
  • 系统单一,代码量庞大,未做拆分,对于后期维护难度很大,且存在增量上线风险(改一个字,也要整个系统打包上线)。
  • 代码业务组件太少,复用率低,需要整理业务代码,封装高效业务组件库。
  • 工具函数、请求Axios、目录规范、菜单权限、公共能力等未做统一封装和梳理。
  • 存在大量重复的代码、大量重复的组件(有很多都是拷贝了一份,改个名字)。
  • 页面加载极其缓慢,无论是首屏还是菜单加载,很明显存在严重的性能问题。
  • 工程中随意引入各种插件,比如:big.js、xlsx.js、echarts、velocity-animate、lodash、file-saver等。
  • 代码中存在很多mixin写法,导致调试难度很大。

以上是针对46万行的应用做出的问题分析报告,找出这些问题以后,我们就能开启优化之路。

目标

image.png

我觉得不管哪一行,进入这个社会最重要的能力是生存能力,面对生存最重要的技能是解决问题的能力。想要把问题解决好,就要有章法可循,比如:找到问题要害、制定目标、提供解决方案、复盘总结。

方案

  • 250+菜单归类整理、废弃菜单下线
  • 搭建业务组件库
  • 搭建工具函数库
  • 基础框架优化
  • 基于microApp做微服务拆分、引入webpack5的module-federation机制
  • 引入rocket-render插件,对局部页面、基础功能做重构,后续逐步替换。
  • 性能优化

菜单整理

300 多个菜单,业务和产研人员可能已经更换过好几次,通过跟产品的沟通,可以得知,受业务调整影响,很多功能已经废弃,系统未及时下线导致日积月累。然而由于对业务不熟,产研很难判断哪些菜单需要下线,我们也不能随意凭感觉下线某一个菜单或功能,必须采取谨慎的态度,有法可依,因此我们采用如下措施:

菜单汇总

产品牵头,汇总系统所有菜单,按环境进行逐个确认。前端会根据确认结果依次删除路由配置、菜单、关联组件、调用 API 等相关代码。

  1. 同业务方确认后,直接下线。
  2. 线上访问异常菜单,进行标注。
  3. 数据异常菜单,进行标注。
  4. 对于无法确认的,通过监控查看页面访问量,通过1-3个月的观察,最终决定是否下线。

通过1个月的菜单整理,下线了大概50+的菜单、删除了近100+页面,以及不计其数的业务组件代码,整体代码量减少近8万,同时,我们将250多个菜单进行归类,拆分了6个大类,供后续微服务做准备。

框架优化

一个稳定的系统,必然有一个稳定的框架做支撑。我们在梳理的过程中发现系统极其脆弱,无 ESLint 语法检查规范、没有 Prettier 格式化、Hard Code、随处可见的自定义组件、到处报错的控制台...

  • 引入 ESLint 做语法检查.
  • 引入 Pettier 做代码格式化。
  • 引入 Husky lint-staged 规范代码提交。
  • 对于硬编码部分跟产品沟通做成可视化配置或者json配置形式。
  • Axios的二次封装:常规请求、全局Loading、文件下载、登录失效拦截、报错拦截提示、状态码统一适配。
  • 插件删减:big.js 剔除改为手动计算、lodash 替换为 lodash-es、删除动画插件、文件导出统一由后端返回二进制,前端通过Axios封装下载函数进行文件下载等等。
  • 配置文件封装:多环境配置、常量定义。
  • 菜单权限封装。
  • 按钮权限指令封装:v-has="'create'"
  • router路由提取优化,针对路由守卫处理一些特殊跳转问题。
  • 接入前端监控平台,对于资源请求失败、接口请求超时、接口请求异常等做异常监控。
  1. 对于eslintprettier大家自行参考其他文章,此处不再赘述。
  2. 对于Axios二次封装,大家可能会比较奇怪,为什么要封装文件下载?因为文件导出本身也是一个请求,只是跟常规get/post有差异,所以我们为了方便调用,把它放在一个文件里面定义,比如:
export default {
get(url, params = {}, options = { showLoading: true, showError: true }) {
return instance.get(url, { params, ...options })
},
post(url, params = {}, options = { showLoading: true, showError: true }) {
return instance.post(url, params, options)
},
download(url, data, fileName = 'fileName.xlsx') {
instance({
url,
data,
method: 'post',
responseType: 'blob'
}).then(response => {
const blob = new Blob([response.data], {
type: response.data.type
})
const name = (response.headers['file-name'] as string) || fileName
const link = document.createElement('a')
link.download = decodeURIComponent(name)
link.href = URL.createObjectURL(blob)
document.body.append(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(link.href)
})
}
}

我们在api.js中调用的时候,就会变的很简单,如:api.get('/user/list')api.download('/export',{ id: 123 })。当然可能每个人有自己的开发习惯,这些都是我个人的经验,也不一定适合大家。对于上面的参数有一个options变量,用来做参数扩展的,比如展示loadingerror,为了做全局Loading和全局错误提示用的。

  1. 如果你担心页面并发请求,导致重复loading问题,可以通过计数的方式来控制,避免多次重复Loading
  2. 插件删除部分大家可能有争议,这个纯属于个人行为,因为我们不是金融行业,不需要高精度,如果出现计算我们直接四舍五入即可,而lodash-es主要为了做tree-shaking,还有很多插件根据自身情况考虑要不要引入。
  3. 指令封装,对于按钮权限非常管用,举个例子:

封装指令

import { getPageButtons } from '@hll/css-oms-utils'
// 权限指令
Vue.directive('has', {
inserted: (el, binding) => {
let pageButtons = getPageButtons().map;
if (!pageButtons[binding.value]) {
el.parentNode && el.parentNode.removeChild(el)
}
}
})

权限判断

// 1. 通过v-if判断
<el-button type="primary" v-if="pageButtons.create">创建el-button>

// 2. 通过v-has指令判断
<el-button type="primary" v-has="'create'">创建el-button>

getPageButtons 其实是为了兼容历史代码而封装的函数。

整个系统权限极其混乱,代码替换,耗时近 2 周,覆盖近 500 个文件才完成。

  1. 状态码适配

这个我觉得有必要提一下,我相信很多前端可能都遇到这个问题,一个系统对应n个后台服务,不同的后台服务有不同的格式,比如:A系统返回code=0,B系统返回result=0,C系统返回res=0,那前端就要做不同的适配,其实也有不同的方法可以做:

  • 让后端接入网关,统一在网关做适配。
  • 前端在拦截器中开发adapter函数,针对响应码做适配。
  • 针对分页、返回码、错误信息等都可以在适配函数里面做几种适配,或者提供单一函数在不同的页面单独调用,类似于requestto模块。

业务组件库建设

这一部分工作非常重要,不管你在哪个公司,做任何行业,我都建议封装业务组件库,当然封装有三种形式:

  1. 基于公司自建的npm平台开发业务组件库,通过npm方式引入。
  2. 对于小体量项目,直接把业务组件库放在components中进行维护,但是无法跨项目使用。
  3. 基于webpack5module federation能力开发公共组件,跨项目提供服务。

MF文档参考:http://www.webpackjs.com/concepts/mo… 我觉得在某些场景下,这个能力非常好用,它不需要像npm一样,发布以后还需要给项目做升级,只要你改了组件源码,发布以后,其他引用的项目就会立即生效。想要快速了解这个能力,我建议大家先看一下插件文档,然后了解两个概念exposesremotes,如果还是看不懂,就去找个掘进入门文章再结合本地实践一次基本就算入门了。

我是基于上面三种方式来搭建系统的业务组件库,对于通用部分全部提取封装,基于rollupvite搭建一套npm包,最终发布到公司私有npm平台。对于一些频繁改动,链路较长部分通过module federation进行封装和暴露。

梳理业务后,其实封装了很多业务组件,上面只是给大家提供了思路,没有一一列举具体组件,下面给大家简单列一个大纲图:

image.png

业务组件库建设对于这种大体量的超级系统收益非常大,而且是长期可持续的。

微服务搭建

前面第一部分梳理完菜单以后,其实已经将250+的菜单进行了归类,归类的目的其实就是为了服务拆分。拆分的好处非常明显:

  • 服务解耦,便于维护。
  • 局部需求可单独上线,不需要整包上传,减小线上风险。
  • 缩小每个服务模块的构建时间,提升开发体验。

本次基于pnpm + microApp + module federation来实现的微服务拆分,为什么?

  • 微服务拆分以后,创建了 7 个仓库,非常不利于代码维护。pnpm天然具备monorepo能力,可以把多个仓库合并为一个仓库,而且可以继续按7个项目的开发构建方式去工作。
  • 微服务使用的是京东的microApp框架,集成非常简单,上手成本很低,文档友好,我觉得比阿里的乾坤简单太多了(个人主观看法)。
  • 对于难于抽取的组件,直接通过module federation对外暴露组件服务。

上面在搭建业务组件库的时候,其实遇到了一个非常棘手的问题,有些组件跨了很多项目,链路很长,在抽取的过程中难度非常大,大家看一个图:

image.png

服务之间耦合太严重,从而导致组件抽取难度很大,那如何解决? 答案就是module federation,抽取不了,就不抽取了,直接通过exposes对外暴露组件服务,在其它子服务中调用即可。

下面给大家举一个接入microApp的例子:

基座服务(主应用)


import microApp from '@micro-zoe/micro-app';

microApp.start()

添加组件容器(主应用)

<template>
<micro-app name="vms" :url="url" baseroute="/" @datachange='handleDataChange'>micro-app>
template>

<script>
export default {
name: 'ChildApp',
data() {
return {
activeMenu: '',
url: 'http://xxxx',
};
},
methods:{
handleDataChange({ detail: { data } }) {
// todo
},
}
};
script>

分配菜单(主应用)

{
path: '/child',
name: 'child',
component: () => import('./../layout/microLayout.vue'),
meta: {
appName: 'vms',
title: '子服务A'
}
}

就这样,一个主服务就搭建好了,等子服务上线以后,点击/child菜单,就能打开对应的子服务了。 当然因为我们是一个超级应用,所以我们在做的过程中其实遇到了很多问题,比如:跨域问题、变量问题、包共享问题、本地开发调试问题、远程加载问题等等,不过大家可以参考官方文档,都是可以解决的。

Rocket-render接入

这是我个人开源的一套基于Vue2的渲染引擎,通过json定义就可以快速搭建各种简单或复杂的表单、表格场景,跟formly这一类非常相似。

给大家举一个简单的例子:

  1. 安装插件
yarn add rocket-render -S
  1. 组件注册
// 导入包
import RocketRender from 'rocket-render';
// 导入样式
import 'rocket-render/lib/rocket-render.css';
// 默认安装
Vue.use(RocketRender);
// 或者使用自定义属性,以下的值都是系统默认值,如果你的业务不同,可以配置下面的全局变量进行修改。
Vue.use(RocketRender, {
size: 'small',
empty: '-',
inline: 'flex',
toolbar: true,
align: 'center',
stripe: true,
border: true,
pager: true,
pageSize: 20,
emptyText: '暂无数据',
});

插件支持自定义属性,建议默认即可,我们已经给大家调好,如果确实想改,就通过自定义属性的方式进行修改。

  1. 页面应用

search-form 自带背景色和内填充,如果你有特殊需要,可以添加 class 进行覆盖,另外建议给 model 添加 sync 修饰符。

<template>
<search-form
:json="form"
:model.sync="queryForm"
@handleQuery="getTableList"
/>

template>
<script>
export default {
data() {
return {
queryForm: {},
form: [
{
type: 'text',
model: 'user_name',
label: '用户',
placeholder: '请输入用户名称',
},
],
};
},
};
script>

我们针对需要自定义部分提供了slot插槽,所以不用担心用了以后,对于复杂需求支持不了,导致返工的现象,完全不存在,除了json以外,我们还内置了非常多的开发技巧,保证你爱不释手,继续看几个例子:

  1. 日期范围组件,通过export直接暴露两个字段。
{
type: 'daterange',
model: 'login_time',
label: '日期范围',
// 对于日期范围控件来说,一般接口需要拆分为两个字段,通过export可以很方便的实现字段拆分
export: ['startTime', 'endTime'],
// 日期转换为时间戳单位
valueFormat: 'timestamp', // 支持:yyyy-MM-dd HH:mm:ss
defaultTime: ['00:00:00', '23:59:59'], //可以设置默认时间,有时候非常有用。后端查询的时候,必须从0点开始才能查到数据。
}

前端是一个组件对应一个字段,但是接口往往需要让你传开始和结束日期,通过export可以直接拆分,太好用了。

  1. 下拉组件支持一步请求
{
type: 'select',
model: 'userStatus',
label: '用户状态',
multiple: true, // 支持多选
filterable: true, //支持输入过滤
clearable: true,
// 如果下拉框的值是动态的,可以使用fetchOptions获取,必须返回promise
fetchOptions: async () => {
return [
{ name: '全部', id: 0 },
{ name: '已注销', id: 1 },
{ name: '老用户', id: 2 },
{ name: '新用户', id: 3 },
];
},
// 字段映射,用来处理接口返回字段,避免前端去循环处理一次。
field: {
label: 'name',
value: 'id',
},
options: [],
// 如果想要修改其它表单值,可以通过change事件来处理
change: this.getSelectList,
}

通过fetchOptions可以直接在里面调用接口来返回下拉的值。如果是静态的,直接定义options即可。 如果接口返回的类别字段不是label和value结构,可以通过field来做映射。我觉得用的太爽了。

还有很多,我就不举例子了,大家去看文档就好了,文档写的非常清楚。

性能优化

前面几部分做完以后,我们的代码总量基本已经降到了 30 万行左右了,而且整个构建时长从9min降到了 30s ,有了业务组件库的加持,简直不要太爽,不过你看到的这些,看起来容易,其实我们花费了近1年的时间才完成。 剩下就是提升系统性能了:

  1. 资源全部上cdn,不仅上cdn,还要再阿里云针对图片开启webp(需要做兼容处理),cdn记得添加Cache-Control缓存。
  2. 服务器全部支持gzip压缩。
  3. 添加external配置,我在npm开发了一个vite-plugin-external-new插件,可以帮你解决。
  • 这个大家一定要做,因为可以有效降低vender体积,你通过LightHouse做性能分析,就能知道原因,对于体积较大的js会给你提示的。
  • 通过external,我们可以直接让vuevue-routervuexelement-ui等等全部通过defer加载。
  1. 建议在根html
    中加一个Loading标签
<div id="app">
<div class="loading">加载中...div>
div>

这样做的好处是,如果vue.js还没有加载完成之前,可以让页面先loading,等new Vue({ el: '#app' })执行以后,才会覆盖#app里面的 内容,这样可以提升FCP指标。 5. 对于比较大的插件,建议按需

export const loadScript = (src: string) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.defer = true;
script.onload = resolve;
script.onerror = reject;
script.src = src;
document.head.append(script);
});
};

某一个页面如果使用较大的插件,比如:xlsx、html2Canvas等,可以单独在某一个页面内做按需加载。

  1. 有些页面也可以针对vue组件或者大图片做按需加载。
  2. 其它性能优化,我觉得可做可不做,针对LightHouse分析报告针对性下手即可,可能很多文章将性能优化会非常细致,比如chunk分包等等,我倒觉得不是很重要。 只要把上面的做完,理论上前端的性能已经会有巨大的提升了。

结果指标

指标优化前优化后
构建时长6-9min30-45s
代码行数46万30 万
服务1个7个
业务组件库乱七八糟基于rollup开发构建
基础框架乱七八糟高逼格
性能评分30分92分
团队成员9个4个

以上就是我面对一个 46 万行代码的超级系统,耗时大概一年的时间所做的一些成果,很多技术细节也只是泛泛而谈,但是我希望给大家提供的是工作方法、解决思路等偏软实力方面,技术功底和技术视野还是要靠自己一步一个脚印。

这一年我过的很充实,面对突如其来的问题,我心情很复杂,这一年,我感觉也老了很多。最后,感谢你耐心的倾听,我是河畔一角,一个普通的前端工程师。


作者:河畔一角
来源:juejin.cn/post/7394095950383710247

0 个评论

要回复文章请先登录注册