处理耗时较长的任务笔记

在前端开发中,处理耗时较长的任务(Long Tasks)是性能优化的核心难点。如果主线程被占用超过 50ms,用户就会感觉到页面卡顿(掉帧)。

为了解决这个问题,深入探讨几种核心技术方案,并编写一套较为完整的、接近生产环境的任务调度与优化系统。这将包含以下几个模块:

  1. 基于生成器(Generator)的时间分片调度器:将同步大任务拆解为异步小任务。
  2. 多线程并行计算框架(Web Worker Pool):利用多核 CPU 处理纯计算任务。
  3. 高优先级抢占式调度模拟(类似于 React Scheduler):通过 MessageChannel 实现宏任务调度。
  4. 大量数据渲染优化(虚拟列表核心实现):解决渲染层面的长任务。

第一部分:通用时间分片调度器 (Time Slicing Scheduler)

这是解决主线程阻塞最直接的方法。我们将利用 ES6 的 Generator 函数特性,配合 requestAnimationFrameMessageChannel,将一个巨大的循环拆分成多个可以在每一帧之间暂停执行的小块。

javascript 复制代码
/**
 * 模块一:TimeSlicer.js
 * 描述:一个基于生成器函数的通用时间分片控制器。
 * 它可以暂停、恢复任务,并确保每帧执行时间不超过阈值(如 16ms)。
 */

class TimeSlicer {
    constructor(options = {}) {
        // 默认一帧的时间预算,留给浏览器渲染的时间
        this.frameBudget = options.frameBudget || 12; // 12ms 给 JS,4ms 给渲染
        this.fps = options.fps || 60;
        this.isRunning = false;
        this.queue = []; // 任务队列
        
        // 绑定上下文
        this._performWork = this._performWork.bind(this);
    }

    /**
     * 添加一个需要分片执行的任务
     * @param {Generator Function} generatorFunc - 生成器函数
     * @param {Object} context - 执行上下文
     * @param {...any} args - 参数
     */
    addTask(generatorFunc, context = null, ...args) {
        if (typeof generatorFunc !== 'function' || generatorFunc.constructor.name !== 'GeneratorFunction') {
            throw new Error('TimeSlicer: 任务必须是一个 Generator 函数');
        }

        const task = {
            iterator: generatorFunc.apply(context, args),
            priority: 0, // 预留优先级字段
            createdTime: Date.now()
        };

        this.queue.push(task);
        this._schedule();
    }

    /**
     * 调度核心
     * 如果当前没有运行,则启动调度
     */
    _schedule() {
        if (!this.isRunning && this.queue.length > 0) {
            this.isRunning = true;
            // 优先使用 MessageChannel (宏任务),其次 requestAnimationFrame
            if (typeof MessageChannel !== 'undefined') {
                const channel = new MessageChannel();
                channel.port2.onmessage = this._performWork;
                channel.port1.postMessage(null);
            } else {
                setTimeout(this._performWork, 0);
            }
        }
    }

    /**
     * 执行工作单元
     * 在给定的时间预算内尽可能多地执行 generator.next()
     */
    _performWork() {
        const startTime = performance.now();

        // 只要队列里有任务,且当前帧还有剩余时间,就继续执行
        while (this.queue.length > 0 && (performance.now() - startTime < this.frameBudget)) {
            const currentTask = this.queue[0];
            
            try {
                // 执行一步
                const result = currentTask.iterator.next();

                // 如果当前任务完成 (done: true)
                if (result.done) {
                    this.queue.shift(); // 移除已完成任务
                    // 可以在这里触发任务完成的回调
                    console.log(`[TimeSlicer] 任务完成,耗时: ${(Date.now() - currentTask.createdTime)}ms`);
                } 
                // 如果没完成,它会保留在队列头部,下一次循环继续执行
            } catch (error) {
                console.error('[TimeSlicer] 任务执行出错:', error);
                this.queue.shift(); // 出错移除任务,防止死循环
            }
        }

        // 检查是否还有剩余任务
        if (this.queue.length > 0) {
            // 让出主线程,等待下一次调度
            // 使用 requestAnimationFrame 配合 MessageChannel 达到最佳效果
            // 这里简化逻辑,直接重新调度
            if (typeof MessageChannel !== 'undefined') {
                const channel = new MessageChannel();
                channel.port2.onmessage = this._performWork;
                channel.port1.postMessage(null);
            } else {
                setTimeout(this._performWork, 0);
            }
        } else {
            this.isRunning = false;
        }
    }
}

// ==========================================
// 使用示例代码:处理 10万条数据的复杂计算
// ==========================================

const slicer = new TimeSlicer();

/**
 * 模拟一个耗时的大型计算任务
 * 假设我们需要处理一个巨大的数组,每项都要进行数学运算
 */
function* heavyCalculationTask(dataSize) {
    const results = [];
    console.log(`[Task] 开始处理 ${dataSize} 条数据...`);

    for (let i = 0; i < dataSize; i++) {
        // 模拟复杂计算: 三角函数、开方等
        let temp = Math.sqrt(i) * Math.tan(i) + Math.random();
        // 模拟 DOM 操作(如果需要,虽然不建议在逻辑中混杂 DOM)
        // 仅仅是纯计算
        results.push(temp);

        // 关键点:每处理 1000 条数据,或者每一步 yield 一次
        // 粒度越细,响应越好,但总耗时会微增
        if (i % 500 === 0) {
            yield; // 暂停,交还控制权给调度器
        }
    }

    console.log(`[Task] 所有数据处理完毕,结果长度: ${results.length}`);
    return results;
}

// 启动任务:处理 100,000 条数据
// 如果不使用 TimeSlicer,这行代码会直接卡死浏览器 2-3秒
slicer.addTask(heavyCalculationTask, null, 100000);

// 可以同时添加另一个任务,它们会交替执行
slicer.addTask(function* () {
    for(let i=0; i<50; i++) {
        console.log(`[Task 2] 穿插执行中... ${i}`);
        yield;
    }
});

第二部分:Web Worker 线程池 (Thread Pool)

时间分片虽然解决了卡顿,但任务仍然在主线程运行,总耗时并没有减少(甚至因为调度开销变长了)。对于纯计算任务(如图像处理、大文件解析、加密解密),最好的方案是移出主线程。

为了避免频繁创建 Worker 带来的开销,我们需要实现一个 Worker 线程池

javascript 复制代码
/**
 * 模块二:WorkerPool.js
 * 描述:管理一组 Web Worker,实现负载均衡和任务复用。
 */

class WorkerPool {
    constructor(workerScript, poolSize = 4) {
        this.workerScript = workerScript;
        this.poolSize = poolSize || navigator.hardwareConcurrency || 4;
        this.workers = []; // 存储 { worker, isBusy, id }
        this.queue = []; // 等待处理的任务队列
        this.taskMap = new Map(); // 存储 taskId -> { resolve, reject }
        
        this._initPool();
    }

    _initPool() {
        for (let i = 0; i < this.poolSize; i++) {
            const worker = new Worker(this.workerScript);
            
            worker.onmessage = (e) => this._handleWorkerMessage(e, i);
            worker.onerror = (e) => this._handleWorkerError(e, i);
            
            this.workers.push({
                id: i,
                worker: worker,
                isBusy: false
            });
        }
        console.log(`[WorkerPool] 初始化完成,包含 ${this.poolSize} 个线程`);
    }

    /**
     * 提交任务到线程池
     * @param {string} type - 任务类型
     * @param {any} data - 数据
     * @returns {Promise}
     */
    run(type, data) {
        return new Promise((resolve, reject) => {
            const taskId = this._generateUUID();
            const task = {
                taskId,
                type,
                data,
                resolve,
                reject
            };

            // 尝试寻找空闲 Worker
            const idleWorkerIndex = this.workers.findIndex(w => !w.isBusy);

            if (idleWorkerIndex !== -1) {
                this._dispatchTask(idleWorkerIndex, task);
            } else {
                // 所有 Worker 都在忙,加入队列
                this.queue.push(task);
            }
        });
    }

    _dispatchTask(workerIndex, task) {
        const workerObj = this.workers[workerIndex];
        workerObj.isBusy = true;

        // 记录任务回调,以便收到消息时触发
        this.taskMap.set(task.taskId, {
            resolve: task.resolve,
            reject: task.reject
        });

        // 发送给 Worker
        workerObj.worker.postMessage({
            taskId: task.taskId,
            type: task.type,
            data: task.data
        });
    }

    _handleWorkerMessage(e, workerIndex) {
        const { taskId, result, error } = e.data;
        const workerObj = this.workers[workerIndex];
        
        // 标记为空闲
        workerObj.isBusy = false;

        // 找到对应的 Promise
        if (this.taskMap.has(taskId)) {
            const { resolve, reject } = this.taskMap.get(taskId);
            if (error) {
                reject(error);
            } else {
                resolve(result);
            }
            this.taskMap.delete(taskId);
        }

        // 检查队列中是否有等待的任务
        if (this.queue.length > 0) {
            const nextTask = this.queue.shift();
            this._dispatchTask(workerIndex, nextTask);
        }
    }

    _handleWorkerError(e, workerIndex) {
        console.error(`[WorkerPool] Worker ${workerIndex} 发生底层错误`, e);
        // 实际生产中可能需要重启该 Worker
        this.workers[workerIndex].isBusy = false;
    }

    _generateUUID() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
            var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }
    
    terminate() {
        this.workers.forEach(w => w.worker.terminate());
        this.workers = [];
        this.queue = [];
        this.taskMap.clear();
    }
}

/* 
   --------------------------------------------------
   配套的 Worker 脚本内容 (worker-script.js) 
   (在实际项目中,这通常是一个单独的文件,或者通过 Blob URL 生成)
   --------------------------------------------------
*/

/*
// worker-script.js 伪代码实现
self.onmessage = function(e) {
    const { taskId, type, data } = e.data;

    try {
        let result;
        switch (type) {
            case 'SORT_LARGE_ARRAY':
                // 模拟耗时排序
                result = data.sort((a, b) => a - b);
                break;
            case 'IMAGE_FILTER':
                // 模拟图像像素处理
                result = applyFilter(data); 
                break;
            default:
                throw new Error('Unknown task type');
        }

        self.postMessage({ taskId, result });
    } catch (err) {
        self.postMessage({ taskId, error: err.message });
    }
};

function applyFilter(pixels) {
    // 模拟 O(n) 遍历
    let sum = 0;
    for(let i=0; i<10000000; i++) { sum += i }
    return pixels;
}
*/

// 使用示例
// const pool = new WorkerPool('worker-script.js');
// pool.run('SORT_LARGE_ARRAY', [5, 1, 9, ...]).then(res => console.log(res));

第三部分:任务优先级与空闲调度 (Idle & Priority Scheduler)

除了时间分片,我们还可以利用 requestIdleCallback 在浏览器空闲时执行低优先级任务(如埋点上报、预加载数据)。为了兼容性和更好的控制,我们实现一个带优先级的任务队列。

javascript 复制代码
/**
 * 模块三:PriorityScheduler.js
 * 描述:模拟 React Scheduler,支持优先级(UserBlocking, Normal, Low, Idle)。
 */

const Priority = {
    Immediate: 1, // 点击事件等,必须立即执行
    UserBlocking: 2, // 滚动、输入,需要在短时间内完成
    Normal: 3, // 普通数据请求
    Low: 4, // 动画后续处理
    Idle: 5 // 埋点、预加载
};

class PriorityScheduler {
    constructor() {
        this.taskQueue = [];
        this.isMessageLoopRunning = false;
        
        // 使用 MessageChannel 进行任务循环 tick
        const channel = new MessageChannel();
        this.port = channel.port2;
        channel.port1.onmessage = this._performWorkUntilDeadline.bind(this);
    }

    /**
     * 调度任务
     * @param {Function} callback 
     * @param {number} priorityLevel 
     */
    scheduleCallback(priorityLevel, callback) {
        const startTime = performance.now();
        let timeout;

        // 根据优先级设置过期时间
        switch (priorityLevel) {
            case Priority.Immediate: timeout = -1; break;
            case Priority.UserBlocking: timeout = 250; break;
            case Priority.Normal: timeout = 5000; break;
            case Priority.Low: timeout = 10000; break;
            case Priority.Idle: timeout = 1073741823; break; // Max Int 32 bit approx
            default: timeout = 5000;
        }

        const expirationTime = startTime + timeout;

        const newTask = {
            callback,
            priorityLevel,
            startTime,
            expirationTime,
            sortIndex: -1 // 用于最小堆排序,这里简化为数组排序
        };

        // 插入队列并按过期时间排序(模拟最小堆)
        this.taskQueue.push(newTask);
        this.taskQueue.sort((a, b) => a.expirationTime - b.expirationTime);

        if (!this.isMessageLoopRunning) {
            this.isMessageLoopRunning = true;
            this.port.postMessage(null);
        }
    }

    _performWorkUntilDeadline() {
        const currentTime = performance.now();
        const deadline = currentTime + 5; // 每次给 5ms 的时间片

        let currentTask = this.taskQueue[0];

        while (currentTask) {
            // 如果任务还没过期,但当前帧时间片用完了,暂停,让给浏览器渲染
            if (currentTask.expirationTime > currentTime && performance.now() >= deadline) {
                break;
            }

            // 执行任务
            const callback = currentTask.callback;
            // 可以在这里传入 didTimeout 参数告知任务是否超时
            const didTimeout = currentTask.expirationTime <= currentTime;
            
            // 从队列移除
            this.taskQueue.shift();

            try {
                // 执行回调
                const continuationCallback = callback(didTimeout);
                
                // 如果任务返回了一个函数,说明它还没做完(支持任务中断与恢复)
                if (typeof continuationCallback === 'function') {
                    // 重新加入队列,保持原有的过期时间
                    currentTask.callback = continuationCallback;
                    this.taskQueue.push(currentTask);
                    this.taskQueue.sort((a, b) => a.expirationTime - b.expirationTime);
                }
            } catch (e) {
                console.error('Task error:', e);
            }

            currentTask = this.taskQueue[0];
        }

        if (this.taskQueue.length > 0) {
            // 还有任务,继续请求下一个 tick
            this.port.postMessage(null);
        } else {
            this.isMessageLoopRunning = false;
        }
    }
}

// 使用示例
const scheduler = new PriorityScheduler();

// 低优先级任务:发送统计数据
scheduler.scheduleCallback(Priority.Idle, (didTimeout) => {
    console.log('Idle task executing, didTimeout:', didTimeout);
    // 模拟重负载
    const start = Date.now();
    while(Date.now() - start < 10) {} 
});

// 高优先级任务:响应用户点击
scheduler.scheduleCallback(Priority.Immediate, () => {
    console.log('Immediate task executing!');
});

第四部分:虚拟列表 (Virtual List) - 解决渲染长任务

当数据量达到几万条时,最大的"长任务"通常不是 JS 计算,而是 DOM 的 Layout 和 Paint。虚拟列表技术只渲染可视区域内的元素,极大减少 DOM 节点数量。

为了保证代码量和细节,我们不使用 React/Vue,而是用原生 JS 实现一个高效的虚拟滚动类。

javascript 复制代码
/**
 * 模块四:VirtualScroller.js
 * 描述:原生高性能虚拟滚动实现。
 * 支持动态高度估算(简化版),DOM 复用,和滚动节流。
 */

class VirtualScroller {
    /**
     * @param {HTMLElement} container - 滚动容器
     * @param {Array} items - 数据源
     * @param {Function} rowRenderer - 行渲染函数 (index, data) => HTMLElement
     * @param {number} itemHeight - 固定行高
     */
    constructor(container, items, rowRenderer, itemHeight = 50) {
        this.container = container;
        this.items = items;
        this.rowRenderer = rowRenderer;
        this.itemHeight = itemHeight;
        
        // 内部状态
        this.visibleCount = 0;
        this.startIndex = 0;
        this.endIndex = 0;
        this.scrollTop = 0;
        
        // 缓冲区域(上下多渲染几个,防止白屏)
        this.buffer = 5; 

        this._initDOM();
        this._bindEvents();
        this._update();
    }

    _initDOM() {
        this.container.style.overflowY = 'auto';
        this.container.style.position = 'relative';

        // 创建占位高度层 (Phantom)
        // 它的高度等于 总数据量 * 行高,用于撑开滚动条
        this.phantomContent = document.createElement('div');
        this.phantomContent.style.position = 'absolute';
        this.phantomContent.style.left = '0';
        this.phantomContent.style.top = '0';
        this.phantomContent.style.right = '0';
        this.phantomContent.style.zIndex = '-1';
        this.phantomContent.style.height = `${this.items.length * this.itemHeight}px`;
        
        // 创建实际内容层 (Real Content)
        // 这里的元素会绝对定位,或者通过 transform 偏移
        this.content = document.createElement('div');
        this.content.style.position = 'absolute';
        this.content.style.left = '0';
        this.content.style.right = '0';
        this.content.style.top = '0';
        
        this.container.appendChild(this.phantomContent);
        this.container.appendChild(this.content);
    }

    _bindEvents() {
        // 简单的节流处理
        let ticking = false;
        this.container.addEventListener('scroll', (e) => {
            if (!ticking) {
                window.requestAnimationFrame(() => {
                    this.scrollTop = e.target.scrollTop;
                    this._update();
                    ticking = false;
                });
                ticking = true;
            }
        });
    }

    _update() {
        // 1. 计算可视区域能放下多少个元素
        const containerHeight = this.container.clientHeight;
        this.visibleCount = Math.ceil(containerHeight / this.itemHeight);

        // 2. 计算起始索引
        // 向下取整,确保滚动平滑
        this.startIndex = Math.floor(this.scrollTop / this.itemHeight);
        
        // 3. 考虑缓冲区
        const renderStart = Math.max(0, this.startIndex - this.buffer);
        
        // 4. 计算结束索引
        const renderEnd = Math.min(
            this.items.length, 
            this.startIndex + this.visibleCount + this.buffer
        );

        // 5. 渲染这一段数据
        this._renderRows(renderStart, renderEnd);
        
        // 6. 偏移 content 层,让它处于正确的位置
        // 因为我们只渲染了一部分,所以需要把这部分内容移动到视觉上的位置
        // 这里的偏移量应该是 renderStart * itemHeight
        const offset = renderStart * this.itemHeight;
        this.content.style.transform = `translateY(${offset}px)`;
    }

    _renderRows(start, end) {
        // 简单的 Diff 算法:全清空再添加 (生产环境应复用 DOM)
        // 为了演示代码量和逻辑,这里做一个简单的 DOM 复用池逻辑
        
        const fragment = document.createDocumentFragment();
        
        // 清空当前渲染层
        this.content.innerHTML = '';

        for (let i = start; i < end; i++) {
            const itemData = this.items[i];
            const node = this.rowRenderer(i, itemData);
            
            // 设置必要的样式确保高度正确
            node.style.height = `${this.itemHeight}px`;
            node.style.boxSizing = 'border-box'; // 避免 padding 影响高度
            
            fragment.appendChild(node);
        }

        this.content.appendChild(fragment);
    }

    /**
     * 动态更新数据
     */
    updateData(newItems) {
        this.items = newItems;
        this.phantomContent.style.height = `${this.items.length * this.itemHeight}px`;
        this._update();
    }
}

// ====================================
// VirtualScroller 使用测试
// ====================================
/*
// HTML 结构: <div id="scroll-container" style="height: 500px; width: 300px;"></div>

const data = Array.from({ length: 100000 }, (_, i) => ({ id: i, text: `Row Item ${i}` }));
const container = document.getElementById('scroll-container');

const scroller = new VirtualScroller(container, data, (index, item) => {
    const div = document.createElement('div');
    div.textContent = `${item.id} - ${item.text}`;
    div.style.borderBottom = '1px solid #ccc';
    div.style.display = 'flex';
    div.style.alignItems = 'center';
    div.style.paddingLeft = '10px';
    
    // 添加一些复杂的 DOM 结构来模拟渲染压力
    // 如果没有虚拟列表,10万个这样的 DOM 会瞬间卡死
    const span = document.createElement('span');
    span.innerText = ' [Detail]';
    span.style.color = 'blue';
    div.appendChild(span);
    
    return div;
}, 40);
*/

总结与进阶思考

以上代码展示了解决前端"长任务"的四个维度的工程化实现。要真正掌握优化,需要理解以下核心思想:

  1. 让出主线程 (Yielding) : JS 是单线程的,必须通过 TimeSlicer (时间分片) 将大任务切碎,给 UI 渲染留出呼吸的时间。
  2. 并行计算 (Parallelism) : 使用 WorkerPool 将不涉及 DOM 的纯算法逻辑移到另一个线程。
  3. 优先级调度 (Scheduling) : 并不是所有任务都一样重要。PriorityScheduler 确保用户交互(点击、输入)永远优先于数据处理。
  4. 按需渲染 (Lazy Rendering) : VirtualScroller 证明了无论数据多大,只要视口有限,DOM 的数量就应该保持恒定。

这些代码模块可以直接组合使用。例如,在一个大型数据分析看板中:

  • 使用 WorkerPool 在后台拉取并解析 10MB 的 JSON 数据。
  • 解析完成后,使用 TimeSlicer 对数据进行预处理(格式化、计算环比)。
  • 最终展示时,使用 VirtualScroller 将十万条表格数据流畅地渲染出来。
相关推荐
消失的旧时光-19432 小时前
Flutter Scaffold 全面解析:打造页面骨架的最佳实践(附场景示例 + 踩坑分享)
前端·flutter
三门2 小时前
开源版扣子私有化部署
前端
麦麦大数据2 小时前
F048 体育新闻推荐系统vue+flask
前端·vue.js·flask·推荐算法·体育·体育新闻
风止何安啊2 小时前
JS 对象:从 “散装” 到 “精装” 的晋级之路
前端·javascript·node.js
Bug快跑-12 小时前
Java、C# 和 C++ 并发编程的深度比较与应用场景
java·开发语言·前端
Achieve前端实验室2 小时前
【每日一面】如何解决内存泄漏
前端·javascript·面试
小肚肚肚肚肚哦2 小时前
🎮 从 NES 到现代 Web —— 像素风组件库 Pixel UI React 版本,欢迎大家一起参与这个项目
前端·vue.js·react.js
y***03172 小时前
Node.js npm 安装过程中 EBUSY 错误的分析与解决方案
前端·npm·node.js
肥猪大大2 小时前
Rsbuild迁移之node-sass引发的血案
前端·javascript