首屏优化篇(典)

对于常见的性能优化,如防抖节流、懒加载、虚拟列表等,还可以以此为基础进行怎么样的优化呢?

鹅厂二面:

  1. 发布订阅了解过没? 还可以怎么优化...

  2. ...

  3. 虚拟列表了解过没? 还可以怎么优化...

首屏优化篇

现在的前端开发领域,都是前后端分离,前端框架主流的都是 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缓存函数? - 掘金 (juejin.cn)

// 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
复制代码
  1. 根据业务场景合理调整时间间隔:一般设置为200ms
  2. 开启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++;
}
复制代码
  1. 使用异步事件处理函数,避免阻塞主线程

使用setTimeout、requestAnimationFrame等

  1. 使用事件委托,避免在每个元素注册事件
<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>
复制代码
  1. 缓存事件结果(刚才讲过) 或者缓存事件处理函数

Vue 3中引入了缓存事件处理函数的优化,可以减少每次渲染时都创建新的事件处理函数实例,从而提高性能。具体实现方式为,将事件处理函数缓存起来,并在渲染时复用它们。

  • 确保只有必要的事件被注册和触发

虚拟列表及优化

虚拟列表是一种在UI界面中优化大量数据展示的技术,其基本思路是只在屏幕上显示可视区域内的数据,而不是将所有数据都加载到内存中。当用户滚动时,系统会动态加载新的数据,同时卸载已经离开屏幕的数据。

overflow: scroll 来隐藏数据,用splice动态的切割需要展示的数组数据

  1. 计算列表高度:首先需要计算出整个列表的高度,以便于计算可视区域的大小。
  2. 计算可视区域:根据当前列表高度和屏幕高度,计算出可视区域的大小和位置。
  3. 渲染可视区域:根据可视区域的位置和大小,渲染出可视区域内的所有元素。
  4. 监听滚动事件:当用户滚动时,通过监听滚动事件,计算出新的可视区域的位置和大小。
  5. 动态加载数据:当可视区域发生变化时,通过计算可视区域内需要加载的数据,动态加载新的数据。
  6. 卸载已离开的数据:同时,需要卸载已经离开可视区域的数据,以节省内存空间。
<!-- 简单实现虚拟列表 -->
<!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()
}

猜你喜欢

转载自blog.csdn.net/a1014981613/article/details/130286073