javaScript 单线程、异步、并发、Promise、let块级作用域传递 笔记

39 阅读5分钟

JavaScript的线程与异步

作为Java老鸟,学习JavaScript似乎需要更多适应。故事的起因是需要利用JavaScript,编写一段压缩图片的代码。

// 假设有一个<input type="file">元素用于上传图片
const inputElement = document.querySelector('input[type="file"]');

// 当用户选择图片后触发
inputElement.addEventListener('change', async (event) => {
  const file = event.target.files[0];

  if (file) {
    const img = new Image();
    const reader = new FileReader();

    reader.onload = function(e) {
      img.src = e.target.result;
      img.onload = async () => {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');

        // 设置canvas的宽度和高度来调整图片尺寸
        const maxWidth = 800;
        const maxHeight = 600;
        let newWidth = img.width;
        let newHeight = img.height;

        if (newWidth > maxWidth) {
          newWidth = maxWidth;
          newHeight = (img.height * maxWidth) / img.width;
        }

        if (newHeight > maxHeight) {
          newHeight = maxHeight;
          newWidth = (img.width * maxHeight) / img.height;
        }

        canvas.width = newWidth;
        canvas.height = newHeight;

        // 在canvas上绘制压缩后的图片
        ctx.drawImage(img, 0, 0, newWidth, newHeight);

        // 将压缩后的图片数据转为DataURL
        // const compressedDataURL = canvas.toDataURL('image/jpeg', 0.7); // 第二个参数是图片质量,0.7表示70%的质量
        // console.log('压缩后的图片DataURL:', compressedDataURL);


        // 将压缩后的图片转为文件(Blob)
        canvas.toBlob((compressedBlob) => {
              // 创建与输入File一样格式的File对象
              const compressedFile = new File([compressedBlob], file.name, { type: file.type });
              // 将压缩后的图像显示在页面上
              outputImage.src = URL.createObjectURL(compressedFile);
              console.log('压缩后的File对象:', compressedFile);
        }, file.type, 0.7); // 第三个参数是图片质量,0.7表示70%的质量
      };
    };

    reader.readAsDataURL(file);
  }
});

作为Javaer,第一反应是JavaScript通过 readAsDataURL 以及 img.src = e.target.result 启动了一个异步线程,读取,压缩,生成新的文件都是在新的线程里完成,且整个流程和主线程并行发生。后来发现并非如此。

JavaScript设计以来,对用户开放的都是单个线程。但前端有很多与后端交互、图片渲染等逻辑,全部单线程执行会导致页面假死。因此,JavaScript巧妙地设计了异步流程,以完成异步流程,同时不阻塞用户的主线程。(设计缘由可搜索“JavaScript+单线程+异步”,或参考博客:阮一峰-eventLoop

JavaScript的“单线程”,是指JavaScript引擎线程,对每一个window,只有一个线程作为主线程执行js业务代码。但其实JavaScript的运行环境(浏览器内核)是多线程的,且确实有需要异步操作的场景(如网络I/O,FileReader,image.load,setTimeout等),所以JavaScript会利用环境里的其他线程实现异步。

具体操作为:对于需要异步操作/加载的场景,JavaScript引擎会自动提交加载任务给浏览器的其他加载线程。由其他线程加载完毕后,将任务放回到主线程的任务队列中。主线程完成当前手头任务,会从任务队列取到队列中被新放入的任务,执行异步逻辑(方法经常被命名为onload等),以此实现异步操作,且不阻塞主线程的用户感知。 举例:

console.log(1);
setTimeout(function(){console.log(2);},1000);
console.log(3);

以上代码永远输出 1 3 2

setTimeout(function(){console.log(1);}, 0);
console.log(2);

以上代码永远输出 2 1

所以,回到本节开始的例子,fileReader的读取,和img对象的加载,都是交由浏览器的其他线程处理的,确实是一个“新的线程”,或者说,非业务主线程。但是两者声明的onload方法内的逻辑(如canvas创建,压缩图片,生成文件等),都是由业务主线程在收到浏览器加载完毕的任务后,在业务主线程中串行完成的。

JavaScript的promise与let块级作用域传递

故事的起因依然是压缩图片的代码,但需要对一个<input type='file'>内的多个图片进行压缩。第一版代码写好了,但发现有两个担忧点,和一个问题点,如下:

...//前置的获取files代码
let fileObj = document.getElementById('uploadFile');
let files = fileObj.files;
let compressedArray = [];
compressedArray.length = files.length;
for (let i = 0; i < files.length; i++) {
    let file = files[i];
    const img = new Image();
    const fileReader = new FileReader();
    fileReader.onload = function(e) {
        img.src = e.target.result;
        img.onload = async () => {
            ...// 构造canvas,获取压缩后文件compressedFile,省略
            let compressedFile;
            //担忧点1:这里的i会不会被多个线程共同使用,导致都拿到了0或者1,互相覆盖?
            //担忧点2:这里的i会不会在文件读取、图片加载、压缩处理过后,已经自增为files.length了?
            compressedArray[i] = compressedFile;
        }
    }
}
//问题点:此处的compressedArray还没有真的设置完毕。该怎么办?需要类似Java的countDownLatch?或者AtomicInt?
const fileTransfer = new DataTransfer();
for (let i = 0; i < compressedArray.length; i++) {
    fileTransfer.items.add(compressedArray[i]);
}
fileObj.files = fileTransfer.files;

以下分别回答上述问题。

担忧点1:

这是一个Java老手+JS初学者才会出现的担忧 - -|||。事实上如上所说,JS的业务代码都是单线程执行的,所以不会出现一个变量被“多个业务线程”争夺的问题。也是因此,JS不需要类似Java中的CountDownLatch或者AtomicInt的类似物。

担忧点2:

这个风险其实是存在的,取决于i的声明是var还是let。前者会有风险,而后者不会有。
目前代码中,变量 ilet声明,会自动创建块级作用域,块级作用域会在异步执行发起时自动传递到异步业务中。对块级作用域,笔者目前的理解是:它会让异步流程能享受到和同步调用类似的上下文状态(如i的取值),但与此同时也会让异步流程不再感知运行时状态。比如在上述代码中,执行赋值语句compressedArray[i] = compressedFile;时,i的运行时状态可能一定已经自增为files.length了。但是异步流程获取到的i依然是块级作用域中的状态,也就是对不同的异步调用,i的值是0,1,2...等发起异步加载时的值。
如果变量i的声明改为var,由于var不会创造作用域,且全局共享,所以异步流程执行赋值语句compressedArray[i] = compressedFile;时,此处的i必然为files.length,进而导致所有异步的compressedFile都写入同一个位置,造成相互覆盖。

但是对于块级作用域,笔者目前经验尚浅,不能彻底分析。上述程序中的i对每次循环的异步调用,确实可以借助块级作用域,保持0,1,2...的状态。
但是如果将i的声明提前到for循环外时(代码如下),此时异步调用获取到的i都是files.length,且线程间会相互干扰。推断是每个异步调用的作用域被共享了,但思维仍不清晰。

let i;
for (i=0;i < size; i++) {...}

此外,其实我们可以不利用块级作用域特性,而借助其他手段,来保证异步内访问数据的稳定性,如闭包、给传入异步流程对象添加自定义属性 等。可参考:面试题总结2-大厂

问题点:

尽管JS作为单线程运行,不需要CountDownLatch或者AtomicInt,但JS也有需要感知多个异步任务执行情况的场景。如此处,需要等待全部compress完毕,再执行后续流程。
对该情况,目前常见的处理方式是依赖Promise完成。
具体语法读者可自行搜索JavaScript Promise,修改后的代码如下:

...//前置的获取files代码
let fileObj = document.getElementById('uploadFile');
let files = fileObj.files;
let promiseArray = [];
let compressedArray = [];
compressedArray.length = files.length;
promiseArray.length = files.length;
for (let i = 0; i < files.length; i++) {
    let file = files[i];
    promiseArray[i] = new Promise((resolve, reject) => {
        const img = new Image();
        const fileReader = new FileReader();
        fileReader.onload = function(e) {
            img.src = e.target.result;
            img.onload = async () => {
                ...// 构造canvas,获取压缩后文件compressedFile,省略
                let compressedFile;
                compressedArray[i] = compressedFile;
                // 标记promise完成
                resolve();
            }
        }
    });
}
Promise.all(promiseArray).then((responses) => {
    // 等待全部promise执行resolve后,执行。
    const fileTransfer = new DataTransfer();
    for (let i = 0; i < compressedArray.length; i++) {
        fileTransfer.items.add(compressedArray[i]);
    }
    fileObj.files = fileTransfer.files; 
});

此时, new Promise块内的代码依然是串行执行的(非callback),但onload的部分如上所说,不会阻塞主线程,而是提交一个事件给浏览器线程后,继续向下执行。浏览器加载线程执行完加载后,将任务置入主线程任务列表,由主线程闲下来后串行执行。
Promise.all().then()能感知全部promise执行完毕的事件,再执行then中的逻辑。
注意此处的Promise.add().then()依然为“异步”执行,不阻塞,会等待主线程将其他Promise执行完毕后,再执行此任务。