对于常见的性能优化,如防抖节流、懒加载、虚拟列表等,还可以以此为基础进行怎么样的优化呢?
鹅厂二面:
发布订阅了解过没? 还可以怎么优化...
...
虚拟列表了解过没? 还可以怎么优化...
首屏优化篇
现在的前端开发领域,都是前后端分离,前端框架主流的都是 SPA,MPA;这就意味着,页面渲染以及等待的白屏时间,成为我们需要解决的问题点;而且大项目,这个问题尤为突出。
- webpack 可以实现按需加载,减小我们首屏需要加载的代码体积;
@babel/plugin-syntax-dynamic-import
插件,允许使用动态导入语法来动态加载代码用
babel-loader
来处理JavaScript文件,并在选项中启用@babel/plugin-syntax-dynamic-import
插件
module.exports = {
// ...
module: {
rules: [
// ...
{
test: /.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: ['@babel/plugin-syntax-dynamic-import']
}
}
}
]
}
};
// 动态加载一个模块
import('./LazyComponent.vue').then(module => {
this.showLazyComponent = true;
});
复制代码
- 再配合上 CDN 以及一些静态代码(框架,组件库等等…)缓存技术,可以很好的缓解这个加载渲染的时间过长的问题。
但即便如此,首屏的加载依然还是存在这个加载以及渲染的等待时间问题;
骨架屏
骨架屏在内容还没有出现之前的页面骨架填充,以免留白
方案一、
在 index.html 中的 div#app 中来实现骨架屏,程序渲染后就会替换掉 index.html 里面的 div#app 骨架屏内容;
方案二、使用一个Base64的图片来作为骨架屏
小米商城的移动端页面采用的就是这个方法,它是使用了一个Base64的图片来作为骨架屏。
按照方案一的方案,将这个 Base64 的图片写在我们的 index.html 模块中的 div#app 里面。
方案三、v-if
动态切换组件来完成骨架屏
防抖节流及优化
用于控制事件的触发频率,减少不必要的计算或请求,从而提升页面性能和用户体验。
// 防抖是指在一定时间内,多次触发同一事件,只执行最后一次
// 节流是指在一定时间内,多次触发同一事件,只执行一次
function debounce_throttle(delay, immediate = false) { // 默认防抖
let timer;
if(immediate) { // 立即执行,节流,n秒执行一次
return function(fn, ...args) {
if(timer) return timer;
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
}
} else { // 非立即执行,防抖,n秒内重复点击只执行最后一次
return function(fn, ...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
}
}
// 使用
const throttleScroll = debounce_throttle(200);
const fn = () => console.log('执行一波');
window.addEventListener('scroll', throttleScroll.bind(null, fn));
复制代码
优化防抖节流
1. 使用函数缓存来优化性能(重点!!)。可以使用函数缓存来缓存函数的执行结果,避免重复执行,从而提高性能。(也就是vue中的计算属性computed懒计算缓存)
也可以使用 LRU Cache 缓存函数执行结果,LRU 缓存常用数据且固定大小不会造成内存占用过大的问题,这里不仅仅是用在防抖节流
// LRU 实现
class LRU {
constructor(n) {
this.size = n; // 初始化内存条
this.cacheMap = new Map() // 新插入的数据排在后面,旧数据放在前面
}
put(domain, info) {
this.cacheMap.has(domain) && this.cacheMap.delete(domain);
this.cacheMap.size >= this.size && this.cacheMap.delete(this.cacheMap.keys().next().value);
this.cacheMap.set(domain, info);
}
get(domain) {
if(!this.cacheMap.has(domain)) return false;
const info = this.cacheMap.get(domain);
this.put(domain, info);
return info;
}
}
// 使用高阶函数,闭包隐藏私有变量_Cache
function memoize(func) {
const _Cache = new LRU(50); // 缓存50个函数结果
return function(...args) {
const key = JSON.stringify(args);
if (cache.get(key)) {
return cache.get(key);
} else {
const result = func.apply(this, args);
cache.set(key, result);
return result;
}
}
}
// 使用 memoize 优化阶乘计算的性能
const factorial = memoize(function(n) {
if (n === 0 || n === 1) {
return 1;
} else {
return n * factorial(n - 1);
}
});
console.log(factorial(5)); // 第一次计算,结果为 120
console.log(factorial(5)); // 直接从缓存中读取,结果为 120
复制代码
- 根据业务场景合理调整时间间隔:一般设置为200ms
- 开启passive,passive 事件监听器允许浏览器在事件处理过程中自动进行性能优化,从而提高页面的滚动性能和响应速度。
window.addEventListener('scroll', fn, {passive: true});
复制代码
懒加载及优化
当数据进入可视区域再加载
比如图片的懒加载,可以使用自定义属性[data-src],在需要加载的时候,再获取自定义属性值并给img添加src路径即可
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
}
.container img {
max-width: 100%;
height: auto;
opacity: 0;
transition: opacity 0.3s ease-in;
}
#aaa {
width: 300px;
height: 100vh;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<div id="aaa">...</div>
<!-- 图片需要延迟加载的区域 -->
<div class="container">
<img data-src="https://th.bing.com/th/id/OIP.WurMmN05Db16P84LH7MD9AHaEo?w=293&h=183&c=7&r=0&o=5&dpr=1.3&pid=1.7" alt="Image 1">
<img data-src="https://th.bing.com/th/id/OIP.kB-Ovasi0GW67-rmwnAcwAHaEo?w=293&h=182&c=7&r=0&o=5&dpr=1.3&pid=1.7" alt="Image 2">
<img data-src="https://th.bing.com/th/id/OIP.d0uSI7WjUmxhaR_MXssxRQHaE8?w=279&h=186&c=7&r=0&o=5&dpr=1.3&pid=1.7" alt="Image 3">
<img data-src="https://th.bing.com/th/id/OIP.FRXTWwFuZjaNh8EWrdEmEAHaFS?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7" alt="Image 4">
</div>
<script>
// 获取所有需要延迟加载的图片元素
const images = document.querySelectorAll('[data-src]');
// 当前窗口可视区域高度
const viewHeight = window.innerHeight || document.documentElement.clientHeight;
// 加载图片
function loadImage(image) {
const src = image.getAttribute('data-src');
if (!src) return;
image.src = src;
image.removeAttribute('data-src');
image.style.opacity = 1;
}
// 判断图片是否进入可视区域
function checkImage(image) {
const rect = image.getBoundingClientRect();
return rect.bottom >= 0 && rect.top < viewHeight;
}
// 懒加载函数
function lazyLoad() {
for (let i = 0; i < images.length; i++) {
if (checkImage(images[i])) {
loadImage(images[i]);
}
}
}
// 滚动事件监听
function throttle() { // 首次执行
let isThrottled = false;
return function(fn) {
if (!isThrottled) {
isThrottled = true;
setTimeout(() => {
fn();
isThrottled = false;
}, 200); // 设置执行频率,200ms
}
}
}
const throttleScroll = throttle()
window.addEventListener('scroll', throttleScroll.bind(null, lazyLoad));
// 页面初次加载时执行一次懒加载
lazyLoad();
</script>
</body>
</html>
复制代码
优化懒加载
- 图片预加载
在内存中进行提前进行图片的加载,使用时直接从内存中读取
<!-- 图片预处理,在内存中进行提前加载-->
<script>
const imgList = [
"https://p6-passport.byteacctimg.com/img/user-avatar/fa2e0968fe519340b42fd210e696214c~300x300.image"
];
let count = 0;
imgList.forEach((img) => {
let image = new Image();
image.src = img;
img.onload = () => {
count++;
if(count === imgList.length) console.log('所有图片都预加载完成');
}
})
</script>
复制代码
- 防抖节流
- 对于图片的加载,可以使用http2,因为http2的多路复用可以并发请求资源而不会阻塞其他资源的请求
发布订阅及优化
node的Event模块
// 简单实现发布订阅
// 1. 同一名称事件可能有多个不同的执行函数
// 2. 通过"on"函数添加事件
// 3. 通过"emit"函数触发事件
class EventEmitter {
constructor() {
this.events = {};
}
on(event, fn) {
if(!this.events[event]) this.events[event] = [];
this.events[event].push(fn);
}
emit(event) {
this.events[event].forEach(fn => fn());
}
}
const Event = new EventEmitter();
Event.on('call', () => {
console.log('call');
})
Event.emit('call');
复制代码
优化
1. 使用事件队列(重点!!)
如vue的nextTick在下一次事件循环统一更新
{ {i}} {i: 0}; for(let index = 0; index < 1000; index++) i++; 复制代码
vue会对数据进行劫持(vue的Scheduler调度器),如果没有事件队列优化,需要进行1000次dom的更新,无疑会带来严重的性能问题,使用了nextTick优化,会在下一次事件循环中只执行最后一次的操作,也就是只会操作一次dom,只会执行
i++ --> 1000
预计实现:下面的代码只会触发一次set
const obj = new Proxy({i: 1}, {
set(...args) {
console.log('触发')
return Reflect.set(...args);
}
})
for(let i = 0; i < 100; i++) {
obj.i++;
}
// 触发
复制代码
使用Promise微队列和事件队列实现:
对于事件队列,存储的值应该是一个对象,包含id值(监听的属性)和run回调函数
也就是实现Component类,监听每个Porxy对象的属性值,比如之前的
obj.i++;
,需要合并成最后一次操作
{id: any, run: Function}
合并去重队列的实现 (去重id相同的对象)
const arr = [{id: 1, run: ()=>{}}, {id: 1, run: ()=>{}}, {id: 2, run: ()=>{}}];
const result = Array.from(new Set(arr.map(item => item.id)))
.map(id => arr.find(item => item.id === id));
console.log(result); // 输出:[{id: 1}, {id: 2}]
复制代码
最终实现:
// 单例模式,实例化同一个类,对于同个对象只会触发第一次new,这里用来监听一个对象数据
class Component {
constructor() {
this.callbacks = [];
this.pending = false;
this.nextTickHandler = () => { // 添加入微任务队列执行
Promise.resolve().then(this.runCallbacks);
};
this.runCallbacks = () => { // 执行回调
this.pending = false;
const copies = this.callbacks.slice(0);
this.callbacks.length = 0;
// 去重事件并执行
Array.from(new Set(copies.map(item => item.id)))
.map(id => copies.find(item => item.id === id)).forEach(watcher => watcher.run());
};
}
nextTick(callback, key) {
this.callbacks.push({id: key, run: callback});
if (!this.pending) {
this.pending = true;
this.nextTickHandler();
}
}
}
const monitor = singleton(Component) // 设计单例
function singleton(className) {
let ins; // 隐藏私有变量
return new Proxy(className, {
// handler.construct() 方法用于拦截 new 操作符
construct(target, ...args) {
if(!ins) ins = new target(...args);
return ins;
}
})
}
const obj = new Proxy({i: 1}, {
set(target, key, value, receiver) {
const _monitor = new monitor(); // 设计单例监听一个对象
_monitor.nextTick(() => { // 模拟操作dom
console.log('触发');
}, key)
return Reflect.set(target, key, value, receiver);
}
});
for (let i = 0; i < 100; i++) {
obj.i++;
}
复制代码
- 使用异步事件处理函数,避免阻塞主线程
使用setTimeout、requestAnimationFrame等
- 使用事件委托,避免在每个元素注册事件
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<script>
ul.onclick = function(e) {
if(e.target.tagName === 'LI') {
console.log(e.target.textContent);
}
}
</script>
复制代码
- 缓存事件结果(刚才讲过) 或者缓存事件处理函数
Vue 3中引入了缓存事件处理函数的优化,可以减少每次渲染时都创建新的事件处理函数实例,从而提高性能。具体实现方式为,将事件处理函数缓存起来,并在渲染时复用它们。
- 确保只有必要的事件被注册和触发
虚拟列表及优化
虚拟列表是一种在UI界面中优化大量数据展示的技术,其基本思路是只在屏幕上显示可视区域内的数据,而不是将所有数据都加载到内存中。当用户滚动时,系统会动态加载新的数据,同时卸载已经离开屏幕的数据。
overflow: scroll
来隐藏数据,用splice动态的切割需要展示的数组数据
- 计算列表高度:首先需要计算出整个列表的高度,以便于计算可视区域的大小。
- 计算可视区域:根据当前列表高度和屏幕高度,计算出可视区域的大小和位置。
- 渲染可视区域:根据可视区域的位置和大小,渲染出可视区域内的所有元素。
- 监听滚动事件:当用户滚动时,通过监听滚动事件,计算出新的可视区域的位置和大小。
- 动态加载数据:当可视区域发生变化时,通过计算可视区域内需要加载的数据,动态加载新的数据。
- 卸载已离开的数据:同时,需要卸载已经离开可视区域的数据,以节省内存空间。
<!-- 简单实现虚拟列表 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>虚拟滚动列表</title>
<script src="https://cdn.staticfile.org/vue/2.7.0/vue.min.js"></script>
<style>
* {
padding: 0;
margin: 0;
}
.container {
width: 50%;
overflow-y: auto;
box-sizing: border-box;
border: 1px solid black;
}
.list-item {
list-style: none;
border: 1px solid red;
}
</style>
</head>
<body>
<div id="app">
<!-- 外层盒子,固定高度 -->
<div class="container" :style="`height: ${boxH}px`" @scroll="handleScroll">
<!-- 内层滚动盒子, 高度是虚拟的数据的整体高度!!!, 这样使得滚动条更像真实的 -->
<div class="scroll-box" :style="`height: ${allHeight}px`">
<!-- 真正显示区域, 需要通过trasform 来让显示区域一直在屏幕中,而不是滑走 -->
<ul :style="`transform:translateY(${offsetY}px)`">
<li
v-for="item in nowList" :key="item"
:style="`height: ${itemH}px`"
class="list-item"
>{
{item}}</li>
</ul>
</div>
</div>
</div>
<script>
new Vue({
el:"#app",
data() {
return {
boxH: 700, // 外层盒子高度
itemH: 100, // 每个元素的高度
ListNum: 100, // 整体个数
list: [], // 列表整体数据
nowList: [], // 目前显示列表
offsetY: 0, // 显示区域动态偏移量
}
},
created() {
// 初始化第一页面的数据
this.init()
},
computed: {
allHeight() {
return this.ListNum * this.itemH
},
pageNum() {
return Math.ceil(this.boxH / this.itemH)
}
},
methods: {
init() {
// 1. 模拟整个列表元素
const list = []
for(let i = 0; i < this.ListNum; i++) {
list.push(i)
}
this.list = list
// 2. 取得当前第一页的显示数据
this.nowList = this.list.slice(0, this.pageNum + 1) // 注意高度卷起一半
},
handleScroll(e) {
// e.target.scrollTop 卷起高度, 需注意滑动单个元素显示一半的情况
const scrollTop = e.target.scrollTop
// 1.保持显示区域一直在屏幕上
this.offsetY = scrollTop - (scrollTop % this.itemH)
// 2.计算卷起多少个,替换更新
let startIndex = Math.floor(scrollTop / this.itemH)
this.nowList = this.list.slice(startIndex, startIndex + this.pageNum)
}
}
})
</script>
</body>
</html>
复制代码
优化
- 缓存列表项
上下多出列表项用于加载缓存
如展示列表项为10项,可以一次性先加载30项,上下多出10项作为缓存,滑动滚动条超过10项在加载新的内容
-
分页
-
防抖节流
// 滚动用节流,也就是立即执行
handleScroll(e) {
// 记录变量lastUpdateTime表示上次最后滚动的时间
if(Date.now() - this.lastUpdateTime <= 100) return;
...
// 更新上一次刷新时间
this.lastUpdateTime = Date.now()
}