全量加载、懒加载、延迟加载、虚拟列表、canvas、异步分片

699 阅读3分钟

背景

前端遇到大数据量的场景,为了提高用户的使用体验,我们需要做很多优化手段。

我们可以从以下的方向去优化:

  • 接口层
    • 分段获取
  • 数据层
    • 分段存储
    • 提高索引速度
      • 字典树
    • 提高存取速度
  • 渲染层

本文围绕渲染层的优化来讲解

思维链

1、懒加载

初始化只渲染首屏的数据,当用户滚动时,渲染额外数据。

优点

  • 首屏加载快

缺点

  • 随着用户的持续性滚动,真实dom和内存占用会越来越多
  • 滚动过快有白屏

2、延迟加载

首屏正常渲染,渲染完后,异步渲染其他部分。

我们往往会使用requestAnimationFrame、requestIdleCallback、setTimeout等

特性requestAnimationFramerequestIdleCallbacksetTimeout
优点• 浏览器优化,每帧执行一次
• 不会造成丢帧
• 节省系统资源,页面不是激活状态时,动画暂停
• 在浏览器空闲时执行
• 不影响用户交互和动画
• 可以设置超时时间
• 使用简单
• 兼容性好
• 可以设置延迟时间
缺点• 不能设置执行时间间隔
• 动画的开始/取消需要手动控制
• 兼容性不如setTimeout
• 兼容性最差
• 执行时机不可控
• 可能永远不会执行
• 精度不高
• 容易造成丢帧
• 嵌套调用可能会导致调用栈溢出

优点

  • 很大程度解决了滚动白屏问题
  • 对于依赖真实dom的场景非常友好

缺点

  • 一开始内存占用就很大

3、虚拟加载

无论用户滚动到哪个位置,都只渲染用户可视区域的内容

优点

  • 真实dom少
  • 内存占用固定且少

缺点

  • 滚动过快有白屏

4、canvas

直接抛弃真实dom的渲染,使用canvas来渲染,渲染性能非常强。

优点

  • 渲染快
  • 可优化手段很多
    • 虚拟canvas
    • 对于复杂渲染/一次性全量重绘:使用webgl
    • 局部重绘

缺点

  • 实现非常复杂

5、异步分片

  • 大量真实dom的渲染,可以拆分成多个渲染子任务
  • 对于canvas的渲染,我们可以拆分成多个栅格,从而拆分成多个渲染子任务

这些渲染子任务如果一次性执行,会导致js单线程的卡顿,所以我们需要控制每一帧里执行的子任务的时间,比如每一帧为16ms,那么我们可以拿其中的10ms来执行子任务,剩下的6ms来响应用户的交互,这样就不会导致js单线程的卡顿。

// 模拟一个耗时任务队列
const tasks = new Array(10000).fill(0).map((_, index) => () => {
    // 模拟每个任务的执行
    let result = 0;
    for(let i = 0; i < 10000; i++) {
        result += Math.random();
    }
    console.log(`完成任务 ${index}`);
});

class TimeSliceExecutor {
    constructor(tasks) {
        this.tasks = tasks;
        this.timeLimit = 10; // 每帧分配10ms执行任务
    }

    // 执行任务队列
    execute() {
        if (this.tasks.length === 0) {
            console.log('所有任务执行完成');
            return;
        }

        const startTime = performance.now();
        
        while (this.tasks.length > 0) {
            // 检查是否超过当前帧的时间限制
            if (performance.now() - startTime > this.timeLimit) {
                // 超过时间限制,在下一帧继续执行
                requestAnimationFrame(() => this.execute());
                break;
            }

            // 执行一个任务
            const task = this.tasks.shift();
            task();
        }
    }

    // 开始执行
    start() {
        requestAnimationFrame(() => this.execute());
    }
}

// 使用示例
const executor = new TimeSliceExecutor(tasks);
executor.start();
5-1、任务优先级

可以很明显看出,异步分片每一帧只用了10ms,如果剩余的6ms没响应用户的交互的话,那么我们每一帧就浪费了6ms。

这不是我们希望看到的。

我们需要利用上这6ms,怎么用呢?

我们可以利用这6ms来处理一些优先级高的任务,比如:

  • 用户交互
  • 动画
  • 数据更新

比如用户交互,这就需要我们代理原生的用户交互事件,给这个事件设置一个优先级,然后进行处理。

如果这6ms里没有级别高的任务怎么办呢?

那么我们可以将这6ms完全用于异步分片的任务。

5-2、任务中断

在执行多个渲染子任务时,如果某一刻响应了用户的交互事件,这个事件会导致新的ui更新,即此刻的渲染子任务里有一些是已经过时的,那么我们就需要中断这些过时的任务,然后重新开始新的任务。

也就是说子任务需要具备中断的能力。

其实很简单,用户交互事件执行完后,给过时的子任务打上outdate标签,在执行子任务列表时,如果check到有outdate标签的子任务,那么就跳过这个任务。

requestAnimeFrame始终是ui有关的,当视图卡住时,那么异步分片就不会执行。怎么办呢?

我们可以使用MessageChannel来避免这个问题。

function processJsonAsync(tasks, batchSize = 100) {
    return new Promise(resolve => {
        const channel = new MessageChannel();
        const { port1, port2 } = channel;

        let currentIndex = 0;
        const totalItems = tasks.length;
        const results = [];

        port1.onmessage = () => {
            if (currentIndex >= totalItems) {
                // 处理完成
                port1.close();
                port2.close();
                resolve(results);
                return;
            }

            // 处理当前批次
            const endIndex = Math.min(currentIndex + batchSize, totalItems);
            const currentBatch = tasks.slice(currentIndex, endIndex);

            // 处理批次中的每一项
            currentBatch.forEach((item, index) => {
                const result = processItem(item, currentIndex + index);
                if (result !== undefined) {
                    results.push(result);
                }
            });

            // 更新进度
            const progress = Math.floor((endIndex / totalItems) * 100);
            console.log(`处理进度: ${ progress }% `);

            // 更新索引
            currentIndex = endIndex;

            // 请求处理下一批
            setTimeout(() => {
                port2.postMessage('继续');
            }, 0);
        };

        // 启动第一批处理
        port2.postMessage('开始');
    });
}

const tasks = new Array(100000).fill(0).map((_, index) => () => {
    // 模拟每个任务的执行
    let result = 0;
    for (let i = 0; i < 10000; i++) {
        result += Math.random();
    }
    console.log(`完成任务 ${ index } `);
});

processJsonAsync(
    tasks,
    100 
)

为什么会添加

setTimeout(() => {
    port2.postMessage('继续');
}, 0);

如果不加的话,那processJsonAsync会不断往微任务里添加postMessage和onMessage的回调,导致宏任务的饿死,同时导致栈溢出