前端性能优化相关的“技能点”笔者之前也写过几篇,但是大多都是小打小闹。我重新整理了曾经使用过的性能优化手段。本文介绍三种方案:页面资源预加载、服务请求优化和非首屏视图延迟加载。
页面资源预加载
页面是不可能真正预加载的,但是有一个地方:入口代码中依赖的 js 模块。
一般来说,为了首屏的快速展示,我们并不会加载所有的代码/资源,而是当创建某个页面时再开始加载并执行页面相关的代码。
比如我老东家微店自研的脚手架就是这么做的,保证了 webview 页面的打开速度。还有的公司的 JSBundle 加载页面也是这么做的。
但是这个流程确实有可以优化的地方:让相关页面的 js 代码(下一个页面/所有子页面/最可能的页面)提前到前一个环节中,也就是在上一个页面展示的同时把下一个页面的 js 下载好,这样在进入下一个页面时页面创建到首屏渲染过程中就减少了 js 代码耗费时间。
如图就是笔者利用自己开发的微前端框架改造的一个老项目,它由两个子应用共同实现了5个页面 —— 我的意思是,这种优化手段是不可能用在普通的“页面开发级”实现中,必须是在框架或者更基础的底层实践中使用!
当一种手段没法支持我们的想法,那必然之路是:寻找更高层次/更底层的思路。比如我之前所在公司的脚手架是没法支持我“让页面间跳转和原生一样”的想法,但是如果能在页面之上还有一个东西去“控制”多个页面行为,就可以让“页面跳转”变成“单页应用路由跳转”。说实话这就是笔者写一个框架的原因。
笔者的做法是:
在“获取到当前页面路由”的时候,就去 异步 加载后面所有页面的 js 资源。
export const start = () => {
// ...
// 查找到符合当前url的子应用
let app = currentApp()
//...
// 路由被触发了不止一次,我们可以加一个限制
window.__CURRENT_SUB_APP__ = app.activeRule
// 预加载 - 加载接下来所有的子应用,但是不显示
prefetch()
}
import { parseHtml } from "./index"
import { getList } from "../const/subApps"
export const prefetch = async () => {
// 获取到所有子应用的列表,但不包括当前正在显示的
const list = getList().filter(item => !window.location.pathname.startsWith(item.activeRule))
// 预加载剩下的所有子应用
await Promise.all(list.map(async item => await parseHtml(item.entry, item.name)))
}
(这里是 async,但是调用方并没有 await)
与此同时,路由劫持会监听并找到当前的子应用,去执行它的生命周期、页面加载、事件监听等一系列操作。
注意:这个手段应该是没法用于普通页面开发行为的,而且像网上说的大多数通过 script 和 link 标签去预加载 js 资源的都是“单页”,不可能在多页面跳转中真正有效果。
预请求
预请求就是在不影响当页加载和交互的情况下,提前发出下一个页面的接口请求,并将结果缓存。以期望在下一个页面时消除网络请求时间对页面加载的影响,从而达到【直出/瞬开】的效果。
准确地说,预请求对于“触发请求时机”、“请求场景”、“数据有效性”有着严格的要求。比如笔者之前写过相关的文章,在文章中对于“列表页”到编辑页的数据进行了“预请求”操作。
这当然不可能上线!说实话我的这个试验在数据量小的情况下是达到了效果的,但是数据量一大“命中率”就会大大降低,虽然我加了保险让它尽可能地不会影响到原先的性能,但是由此导致的开发投入远不能匹配收益。感兴趣的可以看看之前的这篇文章:用户体验新尝试&思考|让“跳转”加速。
我来总结几个点:
- 业务逻辑越复杂的页面,对预请求的要求和难度也就越大。如果我们对用户行为预判不够准确,会导致大量无效请求和没用的本地缓存,造成服务资源的浪费。慎重!(之前有考虑过利用其他手段比如 tensorflow 增加准确率,但是这完全是技术角度思考,从业务来说组里不可能会同意这种方法)
- 拿一个极端场景来说,商家在 pc 设置了某个商品,然后在 app 中又进行了编辑,那么在pc中的缓存时效怎么判断?假如说有一些对实时性要求高的场景比如秒杀信息,我们需要避免由于信息更新不及时导致的用户负反馈和不必要的损失。预请求缓存的数据需要设置合理的失效时间!
- 假如你使用了预请求,根据判断在用户进入列表页后正在缓存第一项的编辑态相关数据,用户也确实点击了第一项!但是,此时你的请求依然正在进行中。笔者在实践中使用了一种方案:构造一个通信模块,它能告诉我当前请求状态,如果请求依然在进行中,就等待,拿到数据后(会被缓存)通知我,我再从缓存中取数据。如果请求失败,同样会告诉我,我会按照超时重新进行“编辑页的正常请求”。
当然,这种东西和有些优化手段一样,你不能只前端发力,可以拉着后端一起参谋,反正这个方案针对的就是“前后端交互时间”。
非首屏视图延迟加载
这个东西说的就多了,不过有一种方案笔者之前一直没写过:利用 textarea
标签让绝大部分非核心内容,比如非首屏展示区域“延后加载”。
没错就是先把 div 内容放在 textarea 标签中,然后用 js 慢慢取出来内容。
有一个需要注意的点就是,为了SEO考虑,你必须用多个 textarea
标签!
操作很简单:把 html 代码放入 textarea。
对于屏幕外我们首屏并不需要看到/非核心视图区域的 html 内容,存放到隐藏的 textarea 中,最好是 visibility: hidden;
,让该 textarea 仍然占据本该渲染的位置(这一步是为了防止滚动条抖动)。
<textarea id='lazy-area' data-index='1'>
<!-- 正常的html内容 -->
</textarea>
然后你可以利用 setTimeout
让 textarea 的 value 插入到文档中,或者监控视区变化 MutationObserver
当某个 textarea 进入可见区域再加载这部分的 html 节点。
observeListItem() {
let observerVideo = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry, index) => {
// 当移入指定区域内后....
if(entry.intersectionRatio === 1) {
let div = document.createElement('div');
let area = document.querySelectorAll("#lazy-area")[index];
div.innerHTML = area.value;
area.parentNode.insertBefore(div,area);
area.parentNode.removeChild(area);
return;
} else {
if(cacheIndexs[entry.target.dataset.index].observe) {
cacheIndexs[entry.target.dataset.index].observe = false;
}
}
// observer.unobserve(entry.target);
});
},
{
// root: document.getElementById('scrollView'),
rootMargin: '-16px -16px -16px -24px',
threshold: 1
}
);
document.querySelectorAll('#lazy-area').forEach(video => { observerVideo.observe(video) });
},
这种方案的好处是减少首屏渲染的 DOM 节点总数。
扩展:经典前端面试题
刚才提到利用 setTimeout
让 textarea 的 value 插入到文档中。这里突出一个点:首屏元素加载显示完成后再去加载后续元素。从而引出了“宏任务和微任务”的概念。
关于这个概念,笔者之前也写过相关文章:点此跳转。而且被很多人说过通俗易懂,但是笔者最近研究中发现那篇文章中说的还是“太绕了”。本文剩下的时间里给各位再梳理一遍:
进程?线程?
进程就是系统进行资源分配和调度的一个独立单位。一个进程内包含多个线程。
著名的【渲染进程】包含这些:
- GUI渲染线程(页面渲染)
- JS 引擎线程(执行 js 脚本)(和 GUI 线程互斥)
- 事件触发线程(eventloop 轮间处理线程)
- 事件(onclick)、定时器、ajax(独立线程)
这里有三个经典问题:
- ajax?ajax是立即调用的,然后开一个线程去执行,成功后把回调放入宏任务队列。
- “JS是单线程的”。应该是“js 的主线程是单线程的”,它会调用 API,这些 API 会再去开一个线程。
- webworker?他是多线程,但并不是完全独立的,而是“主从线程”中的“从”。而且它并不能操作 DOM。
然后来一张图:
有一个初级面试题是这么描述的:10w条数据怎么更高效的展示?答案当然是“切片加载”!
const total = 100000;
let oContainer = document.querySelector('#container');
const once = 2000;
const page = total/once;
const index = 0;
function insert(curTotal, curIndex) {
if(curTotal < 0) return;
// 在异步的基础上调用多次
setTimeout(()=> {
for(let i=0; i< once; i++) {
let oLi = document.createElement('li');
oLi.innerHTML = curIndex + i;
oContainer.appendChild(oLi)
}
insert(curTotal - once, curIndex + once)
}, 0)
}
insert(total, index)
结合上面的图示,你应该可以“模拟”出为什么这么做能提升性能。
为了能更加“明示”,我们可以这么修改题目:如果一次性加载完10w条数据,数据渲染完成的时间怎么获取?
let date = Date.now();
for(let i=0; i< 100000; i++) {
let oLi = document.createElement('li');
oLi.innerHTML = 1 + i;
oContainer.appendChild(oLi)
}
console.log('时间', Date.now() - date)
setTimeout(()=> {
console.log('渲染', Date.now() - date)
}, 0)