00后项目性能优化经验
1. 故事开头
作为一名刚转正的校招前端萌新,我对前端项目性能优化肯定没啥丰富经验,所以在这里只是记录下在公司项目开发中发现的主要代码问题。
行百里者半九十,极致的性能优化肯定是困难重重的。
通过修改和优化代码之前的逻辑,获得的性能提升往往是巨大的。
2. 项目背景
以下介绍的案例来源于一个真实的公司项目。
项目本身可以说是一个node全栈项目,采用express框架,包含n个中间件,页面采用的是vue2,图表的绘制采用的是版本较低的echarts。
该项目的用户权限控制和相关数据,菜单栏的来源与获取等功能 都是由前端开发和管理的。
该项目的数据来源可以说是相当复杂,测试环境和线上环境访问的数据是完全隔离的,甚至访问的数据库都不是一个种类,测试环境均为mysql数据库,前端和后端有各自要负责的数据库,页面主要展示的数据来自于后端负责的数据库。
后端数据库,数据的主要产出依靠牛逼的数据分析师,操作数据库的相关代码依赖于分析师提供的庞大sql。
页面获取后端数据库的方式主要可以分为两种。
(1)利用node搭建访问后端数据库的接口,同时需要根据不同页面对应的不同业务,拼接出多个庞大的sql,然后获取到数据后进行数据格式处理,最后交由前端进行页面渲染(趋势图、折线图、地图、表格等)。
(2)将对庞大sql的解析,数据库访问性能的优化,数据的缓存等 全部交由后端,前端利用node提供面向页面的定制化格式的数据,此时的node也就成为了中台背景下的BFF(Backends for frontends),服务于前端的后端。
BFF模式
背景
随着前端技术发展,不同前端对于后端的要求差异很大,而后端服务很难提供满足多个前端的统一接口,BFF 则可以针对前端的特定需求,作出适配。
优点
(1)对前端UI展示逻辑的不同,对后端API返回的数据进行裁剪和重新组织,提供面向前端的定制化格式的数据。(2)根据前端业务需求,对后端多个API返回的数据进行聚合。(3)对一些特定场景的数据进行缓存,提高性能,进而提升用户体验。(4)BFF隔离了前端UI展示对后端API的定制化需求,可以很好地支持后端服务的演进。
所以该项目的后端在封装一些通用代码时更加得心应手,更多精力可以专注在接口性能的优化上。
虽然会造成数据的偏差,但是在bff层处理起来并不复杂。
3. 多个下拉框间的联动问题
(1)该项目的主要页面,顶部都是由多个下拉框或者单选选框组成的,下拉框的数据来源于后端数据库。
(2)多个下拉框间通常都有联动效果,例如,城市线、城市类型、大区、城市、商家、门店。
选择城市为北京时,城市线(一线),城市类型(直营城市),大区(华北)。
城市线列表变为所有的一线城市、商家列表变为北京下的所有商家,其他情况以此内推。
(3)现有问题是早期开发的页面,对于这种联动实现是依赖于后端接口实现的,即每切换一次城市就会发送多次网络请求,获取新数据后对相应变量进行重新赋值。
(4)对于这种方式实现的联动效果,其实弊端是很明显的,那就是网络请求次数过多。因为对于该项目的用户而言,多个下拉框的数据切换是最为频繁的页面交互之一。一旦出现某个网络请求的超时,老数据库执行查询SQL的速度过于缓慢等情况,就会造成页面整体卡住,这也是前端接下来需要优化的地方,确保页面主体渲染完毕后再去查询顶部下拉框的数据。
(5)除了这一明显弊端,从代码逻辑的角度来说,这一操作太有定制性了。因为这种页面的联动并不是每次都是这种一对一的,例如,一级品类(保洁清洗,家庭服务)和二级品类(保姆),保姆既属于保洁清洗,又属于家庭服务,这种情况下的联动就需要单独写套逻辑了,这是后端又要想办法修改sql拼接的逻辑,天天搞这种很烧脑!
(6)综上所述,我们达成的方案是后端根据下拉框分成多个接口,每个接口包含该项的全部数据,也就是说所有的联动都交给前端完成。可能你会觉得首次渲染时获取的数据量较大,可能对页面性能造成影响,可是实际情况反而是因为sql拼接的逻辑简单了,执行sql的速度更快,接口数据返回的速度更快。同时,因为下拉框数据的查询完全交给了后端,后端采用了数据缓存策略、近似值计算等优化手段,这使接口性能得到了极大提升。
(7)前端方面,对于首次渲染时获取的下拉框数据,可采用vuex进行持久化存储,这样在进入其他页面时,也能随时获取到需要的数据,而不需要进行二次请求,这样就尽可能减少了网络请求的次数。同时,可以在vuex中设计一个标识变量(false),通过网络请求获取到全部数据后,变为true,这样访问老页面时检测到vuex中的标识变量为true就不用重新请求了。
4. node接口获取速度缓慢问题
(1)因为开发环境和线上环境访问的数据库不一样,所以有些接口在本地调试时返回数据的速度可能会慢一些。但是,本地开发,获取菜单栏数据的接口用了快10秒是不是就有点离谱了。
(2)并且,有的人电脑访问起来挺快的,就我的电脑访问起来最慢???抱着是不是闹鬼的心态,我通过断点调试一步步查看了接口的代码,终于让我定位到出现延迟问题的几处代码。
(3)这几处代码的共同之处是在for循环中执行了访问数据库的代码,循环次数也受用户权限的影响,因为我作为开发人员拥有最高权限,所以导致我每次访问该接口会在一个个for循环中,经历了上百次await ……
(4)梳理代码逻辑之后,我发现有很多冗余的请求,因为在数据库中即使是不同行的数据,也可能会有很多相同的字段,原开发者对于这种数据库查询结果的取重工作留到了最后,却没意识到这产生了很多不必要的sql查询。
(5)经过这次简单优化后,访问接口速度自然得到极大提升,但是我对于一个项目的菜单栏在开发环境下不能秒开的情况还是不满意,毕竟菜单栏又没有多少数据。
(6)是啊!跟菜单栏相关的几张表,行数最多也就百余条,为什么要在for循环里遍历执行访问数据库的操作呢?直接一次性取出表中所有的内容,然后用map存储数据。
(7)果然,效果很好!获取用户菜单数据缩短至1秒以内,可能这就是用空间换时间吧。
5. 接口问题的反思
对于上面刚提到的问题,虽然优化的结果是令人满意的,但是有个问题是被我避开了,而不是被我解决了。
那就是在for循环中执行上百次的查询数据库的操作,为什么会这么慢呢???
通过本地的demo测试,我终于发现了问题关键所在。
const list = [1, 2, 3]
const square = num => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(num * num)
}, 1000)
})
}
let startTime = new Date()
async function test() {
for (let i = 0; i < list.length; i++) {
console.log(`${(new Date()-startTime)/1000}s`);
console.log('test1', await square(list[i]))
}
for (let item of list) {
console.log(`${(new Date()-startTime)/1000}s`);
console.log('test2', await square(item))
}
console.log(`${(new Date()-startTime)/1000}s`);
list.forEach(async x => {
console.log('test3', await square(x))
})
list.map(async item => {
console.log('test4', await square(item))
})
await Promise.all(list.map(async item => console.log('test5', await square(item))))
console.log(`${(new Date()-startTime)/1000}s`);
}
test()
结论: 如下图所示,test1和test2对应的for循环是串着执行的,test3、test4、test5对应的for循环是并发执行的。 如果square函数对应的是执行数据库的查询sql,每一次发起请求都需要等待上一个请求到达,这明显是不合理的,所以这最终导致了代码整体运行速度的缓慢,所以在项目中如果需要一次性发送大量网络请求,并且这些请求造成的影响是毫不相关的,那就应该果断采用并发请求!
6. 彩蛋:经典手写题回归
JS手写一个带并发限制的异步调度器Scheduler,保证同时运行的任务最多有n个。
参考文章: juejin.cn/post/695163…
class Scheduler {
constructor(num) {
this.max = num || 2; // 任务队列的最大长度
this.count = 0;
this.tasks = []; // 存储【待运行】的任务队列
}
add(promiseCreator) {
return new Promise(resolve => {
promiseCreator.resolve = resolve;
if (this.count < this.max) {
// 未达到最大并行数,直接运行任务
this.start(promiseCreator);
} else {
this.tasks.push(promiseCreator);
}
});
}
async start(promiseCreator) {
this.count += 1;
await promiseCreator();
// 执行任务队列的方法
promiseCreator.resolve();
this.count -= 1;
if (this.tasks.length) {
// 先入队的任务队列先执行
this.start(this.tasks.shift());
}
}
}
function timeout(time) {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}
let start = new Date()
let timer = () => ((new Date() - start) / 1000).toFixed(1)
const scheduler = new Scheduler(2);
const addTask = (time, order) => {
scheduler.add(() => timeout(time)).then(() => console.log(timer(), '秒:', order));
};
addTask(1000, '1');
addTask(500, '2');
addTask(300, '3');
addTask(400, '4');
/**
* 预期结果:一开始任务1,任务2入队。
* 0.5s后,任务2完成,任务3入队。
* 0.8s后,任务3完成,任务4入队。
* 1s后,任务1完成
* 1.2s后,任务4完成
*/
7. 总结
前端:尽可能减少访问数据库的次数,充分利用缓存机制,合理使用并发请求。
后端:尽可能避免前端UI展示对后端API的定制化需求,有条件的话将定制逻辑放BFF层。
好的编码习惯是要跟随你整个程序员生涯的。
生活就是这样子,想要有所改进,你总要鼓起勇气去面对这些存在的问题,至少不要让问题变得更棘手。