师夷长技以制夷:跟着PS学前端技术

2023-10-27 14:07:28 浏览数 (2)

❝燧石受到的敲打越厉害,发出的光就越灿烂。——卢梭 ❞

大家好,我是「柒八九」

前言

「打工人 打工魂 打工人都是人上人」。是不是还沉浸在2024的放假通知中,小伙该收收心了。毕竟,你多打一天的工,老板就离他在游艇中喝着香槟和美女一起海钓的梦想又更进一步了。好了,玩归玩,闹归闹。作为一个职业打工人,我们还是要着眼于当下。

在前几天,我们写了一篇Rust 编译为WebAssembly 在前端项目中使用的文章,简单的描述了Rust如何编译为wasm在浏览器中使用,本意是想表达Rustwasm是可以在浏览器中使用,并且还有更深的意思就是wasm在前端真的真的会有大放异彩的一天。在发布文章后,在一些平台中,总有人充斥着质疑声。

大概,他也是出于一些好意,然后也想找一些理由,让我们迷途知返,幡然醒悟。我认为想要说服一个人,「讲事实,摆道理」是一个最优路线。当然,我也没想着通过几句话说服别人。那就说的委婉点哇,那就用事实和道理,说服我自己,让我能够更有动力去学习。

莫言曾说做人切记:「法不轻传,道不贱卖,师不顺路,医不叩门,你永远叫不醒一个装睡的人,即便你再唤醒他,他是否愿意醒还是个问题。绝大部分人活着都是为了睡得更香,而不是为了觉醒」。 虽然这话在这里有点重,但是我认为也可以作为一个做事准则,不要好为人师。

在前面的文章中多次提到,国内技术存在「滞后性」,而大部分抗拒Rust/Wasm的人,也是拿国内的环境说事。其实吧,我不是崇洋媚外之人,但是不得不承认有些东西,国外的月亮确实比较圆。(如果这句刺痛了你,不好意思,这是我的无心之举。我是一个坚定的马克思主义理论工作者)

今天,我们就以国外一篇文章Photoshop is now on the web![1]为主体框架,来讲讲Photoshop团队通过WebAssembly EmscriptenWeb Components LitService Workers Workbox以及新的Web API,如何将一个桌面「重应用」,迁移到浏览器环境下的。其代表着「将高度复杂和图形密集型软件引入浏览器的一个巨大里程碑」

在将如此重的应用搬上浏览器是一件极其伟大的事情,这其中涉及了很多新奇的技术还有性能优化的东西,并且通过学习它的实现过程,我们还可以从中散发到我们平时的开发任务中。

这就是,「站在巨人的肩膀上,你会看的更高」

文中出现了很多我们之前介绍过的东西。我们会按照我本人的知识体系做一定的删减和增加。放心,内核的东西都不会丢。如果大家想观看原文,可以查看原文。(原文只是一些知识体系的罗列,相信大家两者都看了,会有一个清晰的判断)

好了,天不早了,干点正事哇。

我们能所学到的知识点

  1. 前置知识点
  2. 愿景:将Photoshop引入浏览器
  3. 新的Web功能释放了Photoshop的潜力
  4. 优化Photoshop在浏览器中的性能
  5. 使用TensorFlow.js集成本地设备上的机器学习


1. 前置知识点

「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」 同时,由于阅读我文章的群体有很多,所以有些知识点可能「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」。 ❞

源(origin)

源(origin)是

  1. 「协议」,例如 HTTPHTTPS
  2. 「主机名」
  3. 「端口」(如果有的话,HTTP的默认端口是80,而HTTPS的默认端口是443

的组合。

例如,给定网址 https://www.example.com:443/foo,它的originhttps://www.example.com:443

同源(same-origin)和跨源(cross-origin)

❝具有相同协议、主机名和端口组合的网站会被视为「同源」网站。所有其他项都被视为**跨源 ❞

Origin A

Origin B

是否“同源”或“跨源”

https://www.A.com:443

https://「www.B.com」:443

跨源:不同的「域名」

https://「login」.A.com:443

跨源:不同的「子域名」

「http」://www.A.com:443

跨源:不同的「协议」

https://www.A.com:「80」

跨源:不同的「端口」

「https://www.A.com:443」

「同源」:完全匹配

「https://www.A.com」

「同源」:隐式端口号匹配(443)


Blob 数据类型

Blob(Binary Large Object)是一种二进制大型对象数据类型,它代表了一段任意类型的二进制数据。Blob 数据通常用于存储大量的二进制数据,如图像、音频、视频、文件等。

「创建 Blob 对象」:

可以使用构造函数 BlobBlob() 工厂函数来创建 Blob 对象。Blob 构造函数接受一个数组(通常是 Uint8Array 数组)作为参数,这些数组将被组合成一个 Blob 对象。

代码语言:javascript复制
const textData = 'Hello, Blob!';
const blob = new Blob([textData], { type: 'text/plain' });

上述代码创建了一个包含文本数据的 Blob 对象,并指定了数据类型为纯文本。

「Blob 类型」:

Blob 对象可以包含不同类型的数据,例如文本、图像、音频、视频等。通过设置 type 参数,可以指定 Blob 对象的数据类型。以下是一些常见的 Blob 类型:

  • 'text/plain': 纯文本数据。
  • 'image/jpeg': JPEG 图像数据。
  • 'audio/mp3': MP3 音频数据。
  • 'video/mp4': MP4 视频数据。
  • 'application/pdf': PDF 文件数据。

「Blob 方法」:

Blob 对象具有一些方法,使我们可以执行以下操作:

  • slice(start?: number, end?: number, contentType?: string): 创建并返回 Blob 对象的切片。
  • stream(): 返回一个 ReadableStream,可用于逐块读取 Blob 数据。
  • text(): 返回 Blob 数据的文本表示。
  • arrayBuffer(): 返回 Blob 数据的 ArrayBuffer
  • size: Blob 数据的大小,以字节为单位。
  • type: Blob 数据的 MIME 类型。

「Blob 用途」:

Blob 对象在前端开发中广泛用于以下方面:

  • 加载和展示图像、音频和视频。
  • 上传文件和数据到服务器。
  • 缓存资源以提高性能,如 Service Workers
  • 读取本地文件以进行处理或预览。

用途

FileReaderURL.createObjectURL()createImageBitmap()XMLHttpRequest.send() 可以接受Blob对象用于特定的数据处理。

「FileReader」:

FileReader 是用于读取文件内容的 JavaScript 对象。要将 Blob 数据展示,可以使用 FileReader 读取 Blob 数据,然后在读取完成后执行回调函数来处理数据。

代码语言:javascript复制
  // 选择文件的输入元素
 const fileInput = document.getElementById('fileInput'); 
  // 用于显示图像的 <img> 元素
 const imageElement = document.getElementById('imageElement');

 fileInput.addEventListener('change', function (e) {
     const file = e.target.files[0];
     if (file) {
         const reader = new FileReader();
         reader.onload = function (e) {
             // 将 <img> 的来源设置为 Blob 数据
             imageElement.src = e.target.result; 
         };
         // 以数据 URL 的形式读取 Blob 数据
         reader.readAsDataURL(file); 
     }
 });

「URL.createObjectURL()」:

URL.createObjectURL() 是用于创建 Blob URL 的函数。我们可以将 Blob 数据转换为 Blob URL,然后将其分配给支持 Blob URL 的 HTML 元素,例如 <img><a>

代码语言:javascript复制
 const blob = new Blob(['前端柒八九!'], { type: 'text/plain' });
 const blobURL = URL.createObjectURL(blob);
 // 一个用于链接到 Blob 的 <a> 元素
 const linkElement = document.getElementById('linkElement'); 
 // 将 Blob URL 分配给链接的 href 属性
 linkElement.href = blobURL; 

「createImageBitmap()」:

createImageBitmap() 是用于创建图像位图的函数。我们可以使用它来处理 Blob 数据并将其转换为图像位图,然后将位图绘制到支持绘图的 HTML 元素上。

代码语言:javascript复制
// 一个 <canvas> 元素
const canvas = document.getElementById('canvas'); 
const blob = new Blob(['Your Blob Data'], { type: 'image/jpeg' });

createImageBitmap(blob).then(function (imageBitmap) {
    const context = canvas.getContext('2d');
    context.drawImage(imageBitmap, 0, 0);
});

「XMLHttpRequest.send()」:

使用 XMLHttpRequest 可以将 Blob 数据发送到服务器,或者从服务器获取 Blob 数据并展示它。以下是一个获取并展示图片的示例:

代码语言:javascript复制
const xhr = new XMLHttpRequest();
xhr.open('GET', 'your-image-url.jpg', true);
xhr.responseType = 'blob';
xhr.onload = function () {
    if (this.status === 200) {
        const blob = this.response;
        const blobURL = URL.createObjectURL(blob);
        // 用于显示图像的 <img> 元素
        const imageElement = document.getElementById('imageElement'); 
        imageElement.src = blobURL;
    }
};
xhr.send();

2. 愿景:将Photoshop引入浏览器

几十年来,Photoshop一直是图像编辑和图形设计的王者,兼容WindowsmacOS两个皆然不同的系统。但将其从桌面解放出来,就像打开了新世界的大门,让我们对未来的浏览器应用有了更多的展望和遐想。

  • Web便捷性为用户可以仅通过浏览器即可开始编辑和协作,「无需安装」。而且他们可以在「不同设备之间无缝切换」
  • 可链接性使工作流程共享成为可能。Photoshop文档可以通过URL访问,而不是把我们的心神淹没在文件系统中。创作者可以轻松地将链接发送给合作者。
  • 跨平台的灵活性。Web作为高级载体,可以过滤掉底层操作系统。Photoshop可以触达多个平台的用户。

然而,实现这一愿景面临着重大的「技术挑战」,需要重新思考像Photoshop这样强度大的应用程序如何在Web上运行。

3. 新的Web功能释放了Photoshop的潜力

近年来,通过标准化和实现,新的Web功能如雨后春笋般的涌现,最终可以实现类似Photoshop的应用程序。

3.1 使用Origin Private File System实现高性能本地文件访问

Photoshop的操作涉及读写可能非常庞大的PSD文件。这需要对「本地文件系统」进行有效的访问。新的Origin Private File System API(OPFS)提供了一个快速的、特定于来源的「虚拟文件系统」

兼容性

看到一个新的技术,我们的第一反应就是它的兼容性如何。毕竟,想在浏览器中大放异彩,需要宿主的支持。下图是OPFS的在桌面浏览器中的支持程度-92%是一个不错的结果。那就意味着,我们可以放心大胆的在主流的浏览器中使用它了。这是一个很好的开局。

概念介绍

私有文件系统(OPFS)是文件系统API的一部分,是页面的来源提供的存储端点,不像常规文件系统那样对用户可见。它提供对一种「特殊类型的文件的访问」,经过高度优化以提供性能,并提供内容的就地写入访问。

上面提到OPFS与常规文件系统是不一样的。OPFS并不能被用户看到。顾名思义,OPFS中的文件和文件夹不是面向用户的。OPFS中的文件和文件夹是基于网站的origin私有的。例如:网页https://A.com/B/的源是https://A.com/(:443),所有共享相同origin的页面可以查看相同originOPFS数据,因此https://A.com/C/test 可以查看与https://A.com/OPFS数据。

每个origin都有自己独立的OPFS,这意味着https://A.comOPFShttps://B.com 等站点的OPFS完全不同。

而对于OPFS的存储形式,我们可以参照本地系统。在Windows上,用户可见文件系统的根目录是 C:。对于OPFS,相当于每个origin都可以通过调用异步方法 navigator.storage.getDirectory()访问一个最初为空的OPFS根目录。

就像浏览器中的其他存储机制(例如 localStorageIndexedDB)一样,OPFS也受浏览器配额限制。如果用户清除所有浏览数据或所有网站数据,OPFS也会被删除。

使用方式

使用OPFS的方法有两种:在主线程上或在 Web Worker 中使用。

  • Web Worker 不能阻塞主线程,这意味着在此上下文中,API 可以同步,同步 API 的速度更快,因为它们无需处理 promise
  • 主线程上通常不允许同步API

无论是在主线程上或在 Web Worker 中使用,第一步首先就是获取对「根目录的访问权限」,这样OPFS使得可以快速创建、读取、写入和删除文件。

代码语言:javascript复制
const opfsRoot = await navigator.storage.getDirectory();

有了根文件夹后,我们分别使用 getFileHandle()getDirectoryHandle() 方法创建「文件」「文件夹」。传递 {create: true} 后,系统会创建不存在的文件或文件夹。以新创建的目录为起点调用这些函数,以构建文件层次结构。

代码语言:javascript复制
const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('my first nested folder', {create: true});

最终形成的目录结构如下:

getFileHandle() getDirectoryHandle() 方法不仅可以创建新的文件或者文件夹,我们还可以通过指定特定的参数,来访问「先前创建」的文件和文件夹。

代码语言:javascript复制
const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder');

既然,文件目录有了,我们更希望的是能够在其中存储相关的数据信息。此时我们通过调用 createWritable() 将数据流传输到文件中,这会创建一个指向该文件的 FileSystemWritableFileStream,然后通过 write() 写入相应内容。最后,对数据流执行 close() 操作。

代码语言:javascript复制
const contents = '前端柒八九';
// 获取可写流。
const writable = await fileHandle.createWritable();
// 将文件内容写入流。
await writable.write(contents);
// 关闭流,从而保存文件内容。
await writable.close();

前面,讲过OPFS并不能被用户看到,在前面的操作中,我们新建的文件,写入了内容,此时所有的操作都是对用户不可见的,那如果没有方式让这些数据可见,那岂不是「脱裤子放屁,多此一举」。好在,人家已经给我们想好招了。

我们可以通过fileHandle.getFile()获取关联的 File对象。File 对象是一种特定类型的 Blob,可以在 Blob 能够使用的任何上下文中使用。这样我们就可以通过指定的API(在「前置知识点」中有过介绍)将其转换成其他数据类型。并且我们可以访问这些转换后的数据,并将其提供给「用户可见的文件系统」

代码语言:javascript复制
const file = await fileHandle.getFile();
console.log(await file.text());

上面是OPFS的基础语法,其实要想发挥其最大的功效,还是需要借助Web Worker。毕竟,我们既然用到了OPFS,那肯定是要解决在浏览器中操作「大文件」所遇到的阻塞主线程等令人抓狂的性能问题。

并且,由于Web Worker 不会阻塞主线程,因此在此上下文中允许使用OPFS的同步方法。

我们可以通过同步句柄,来操作对应的文件。同步句柄可以通过调用 createSyncAccessHandle() 从常规 FileSystemFileHandle 中获取。

代码语言:javascript复制
const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

有了同步访问句柄后,我们就可以以极其快速且同步的方式操作文件。

  • getSize():返回文件的大小(以字节为单位)。
  • write():将缓冲区的内容写入文件(可选在给定偏移量处),并返回写入的字节数。检查返回的写入字节数,允许调用方检测并处理错误及部分写入。
  • read():将文件内容读取到缓冲区(可以选择在给定偏移量处)。
  • truncate():将文件大小调整为指定大小。
  • flush():确保文件内容包含通过 write() 完成的所有修改。
  • close():关闭访问句柄。

这个本地高性能文件系统对于在浏览器中实现PS的高要求文件工作流程至关重要。

启发

想必大家或多多少的知晓,在传统桌面版本的PS,要处理一个文件是很大的。但是,PS团队确利用了OPFS完美的解决了这个顽疾。其实,这也算是给我们一个莫大的启发,如果我们以后在接到类似要操作大文件的需求时候,在即有技术不满足性能要求的情况下,是不是可以利用OPFS来为我们开辟一个新思路。

案例提供

假如,现在我们有一个体积很大的 <canvas> 元素,我们想在页面中进行展示,但是这个文件不变的,如果我们每次通过网络加载,并且每次都渲染的话,那在每次页面状态变更的时候,会有一小段页面「真空」时段,这是我们无法忍受的。 那么我们是不是换种方式,将该<canvas>转换为Blob -PNG的形式,并且存储到OPFS中,在合适的方式进行数据的展示。

代码语言:javascript复制
async function doOpfsDemo() {
    // 打开网站(origin)的私有文件系统的“根目录”:
    let storageRoot = null;
    try {
        storageRoot = await navigator.storage.getDirectory();
    } catch (err) {
        console.error(err);
        alert("无法打开 OPFS。请查看浏览器控制台。nn"   err);
        return;
    }

    // 从页面 DOM 获取 <canvas> 元素:
    const canvasElem = document.getElementById('myCanvas');

    // 保存图像:
    await saveCanvasToPngInOriginPrivateFileSystem(storageRoot, canvasElem);

    // 重新加载图像:
    await loadPngFromOriginPrivateFileSystemIntoCanvas(storageRoot, canvasElem);
}

async function saveCanvasToPngInOriginPrivateFileSystem(storageRoot, canvasElem) {
    // 将 <canvas> 的图像保存为 PNG 文件到内存中的 Blob 对象:(参考:https://stackoverflow.com/a/57942679/159145)
    const imagePngBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));

    // 在新的子目录 "art" 中创建一个空(零字节)文件:"mywaifu.png":
    const newSubDir = await storageRoot.getDirectoryHandle("art", { "create": true });
    const newFile = await newSubDir.getFileHandle("mywaifu.png", { "create": true });

    // 以可写流的形式(FileSystemWritableFileStream)打开 `mywaifu.png` 文件:
    const wtr = await newFile.createWritable();
    try {
        // 直接写入 Blob 对象:
        await wtr.write(imagePngBlob);
    } finally {
        // 安全地关闭文件流写入器:
        await wtr.close();
    }
}

async function loadPngFromOriginPrivateFileSystemIntoCanvas(storageRoot, canvasElem) {
    const artSubDir = await storageRoot.getDirectoryHandle("art");
    const savedFile = await artSubDir.getFileHandle("mywaifu.png");

    // 将 `savedFile` 作为 DOM `File` 对象获取(与 `FileSystemFileHandle` 对象不同):
    const pngFile = await savedFile.getFile();

    // 将其加载到 ImageBitmap 对象中,可以直接绘制到 <canvas>。不再需要使用 URL.createObjectURL 和 <img/>。参考:https://developer.mozilla.org/en-US/docs/Web/API/createImageBitmap
    // 但仍然需要在绘制后 `.close()` ImageBitmap,否则会出现内存泄漏。使用 try/finally 块处理这个问题。
    try {
        const loadedBitmap = await createImageBitmap(pngFile);
        try {
            const ctx = canvasElem.getContext('2d');
            ctx.clearRect(/*x:*/ 0, /*y:*/ 0, ctx.canvas.width, ctx.canvas.height); // 在绘制加载的图像之前清除画布。
            ctx.drawImage(loadedBitmap, /*x:*/ 0, /*y:*/ 0);
        } finally {
            loadedBitmap.close();
        }
    } catch (err) {
        console.error(err);
        alert("无法将以前保存的图像加载到 <canvas> 中。请查看浏览器控制台。nn"   err);
        return;
    }
}

「世上本没有路走的人多了也就成了路」。 ❞

如果想了解更多OPFS可以参考

  • MDN-opfs[2]
  • web.dev-opfs[3]

3.2 发挥WebAssembly的威力

WebAssembly对于在JavaScript中重新创建Photoshop的计算密集型图形处理是一个不可或缺的要素。Adobe使用Emscripten编译器将他们「现有的C/C 代码库」移植到WebAssembly模块中。

兼容性

还是熟悉的配方,我们通过caniuse来查看,桌面浏览器对WebAssembly的支持程度。哇塞,形势一片大好。各大厂商都意识到这个「神兽」能给我们带来更多意想不到的可能性。

其实,暂且不看市面上公司如何使用,从各个厂商的积极程度也侧面反应了,我们的预期。

几个WebAssembly的功能至关重要:

  • 线程 - Photoshop使用「工作线程」以并行方式执行任务,比如处理图像块
  • SIMD - SIMD矢量指令加速像素操作和过滤。
  • 异常处理 - C 异常广泛用于整个Photoshop的代码库。
  • 流式实例化 - Photoshop的80MB WASM模块需要流式编译。
  • 调试 - Chrome的WebAssembly在DevTools中的调试支持是非常有价值的。

如果大家对WebAssembly还不是很了解的话,可以翻阅我们之前写的浏览器第四种语言-WebAssembly。针对wasm的概念性东西这里就不在过多介绍了。在里面我们还介绍了利用Emscripten实现了 将C/C 代码编译为WebAssembly

并且,在我们之前还介绍过,CJS代码之间的互操作。感兴趣的可以参看WebAssembly-C与JS互相操作。

而我们来讲讲SIMD的东西。


SIMD

SIMD代表单指令,多数据。是Single Instruction, Multiple Data的缩写。 SIMD操作这个术语指的是一种计算方法,它能够「通过单个指令来处理多个数据」。相比之下,传统的顺序方法使用一条指令来处理每个单独的数据,这被称为「标量操作」。 ❞

以简单的加法为例,下面说明了标量操作SIMD操作之间的差异。

使用传统的标量操作,必须「依次执行」四个加法指令才能获得如图(a)所示的总和。与此同时,SIMD「只使用一条加法指令」就能获得相同的结果,如图(b)所示。「由于处理相同数量的数据所需的指令更少,SIMD操作比标量操作具有更高的效率」

这里简单的说一句题外话:「标量」这个词是不是感觉似曾相识。其实,我们在介绍Rust数据结构的时候就有过接触呢。

这下估计就知道「标量操作」就是单一操作了。

SIMD指令是一类特殊指令,通过「同时对多个数据元素执行相同的操作」,来充分利用应用程序中的「数据并行性」。计算密集型应用程序,如「音频/视频编解码器、图像处理器」,都是利用SIMD指令来加速性能的示例。

SIMD的限制

尽管SIMD操作具有能够在一条指令中处理多个数据的优势,但它们只能应用于特定预定义的处理模式。下图展示了一个这样的模式,在该模式中,「所有数据都执行相同的加法操作」

SIMD操作不能用于以不同方式处理多个数据。下图中提供了一个典型的示例,其中一些数据需要相加,而其他数据需要相减、相乘或相除。

SIMD操作就是需要「所有数据都是执行相同的操作」) ❞

想了解更多关于SIMD概念性的东西,可以参看SIMD基础介绍[4] (需要

0 人点赞