❝燧石受到的敲打越厉害,发出的光就越灿烂。——卢梭 ❞
大家好,我是「柒八九」。
前言
「打工人 打工魂 打工人都是人上人」。是不是还沉浸在2024
的放假通知中,小伙该收收心了。毕竟,你多打一天的工,老板就离他在游艇中喝着香槟和美女一起海钓的梦想又更进一步了。好了,玩归玩,闹归闹。作为一个职业打工人,我们还是要着眼于当下。
在前几天,我们写了一篇Rust 编译为WebAssembly 在前端项目中使用的文章,简单的描述了Rust
如何编译为wasm
在浏览器中使用,本意是想表达Rust
和wasm
是可以在浏览器中使用,并且还有更深的意思就是wasm
在前端真的真的会有大放异彩的一天。在发布文章后,在一些平台中,总有人充斥着质疑声。
大概,他也是出于一些好意,然后也想找一些理由,让我们迷途知返,幡然醒悟。我认为想要说服一个人,「讲事实,摆道理」是一个最优路线。当然,我也没想着通过几句话说服别人。那就说的委婉点哇,那就用事实和道理,说服我自己,让我能够更有动力去学习。
莫言曾说做人切记:「法不轻传,道不贱卖,师不顺路,医不叩门,你永远叫不醒一个装睡的人,即便你再唤醒他,他是否愿意醒还是个问题。绝大部分人活着都是为了睡得更香,而不是为了觉醒」。 虽然这话在这里有点重,但是我认为也可以作为一个做事准则,不要好为人师。
在前面的文章中多次提到,国内技术存在「滞后性」,而大部分抗拒Rust/Wasm
的人,也是拿国内的环境说事。其实吧,我不是崇洋媚外之人,但是不得不承认有些东西,国外的月亮确实比较圆。(如果这句刺痛了你,不好意思,这是我的无心之举。我是一个坚定的马克思主义理论工作者)
今天,我们就以国外一篇文章Photoshop is now on the web![1]为主体框架,来讲讲Photoshop
团队通过WebAssembly
Emscripten
、Web Components
Lit
、Service Workers
Workbox
以及新的Web API,如何将一个桌面「重应用」,迁移到浏览器环境下的。其代表着「将高度复杂和图形密集型软件引入浏览器的一个巨大里程碑」。
在将如此重的应用搬上浏览器是一件极其伟大的事情,这其中涉及了很多新奇的技术还有性能优化的东西,并且通过学习它的实现过程,我们还可以从中散发到我们平时的开发任务中。
这就是,「站在巨人的肩膀上,你会看的更高」。
文中出现了很多我们之前介绍过的东西。我们会按照我本人的知识体系做一定的删减和增加。放心,内核的东西都不会丢。如果大家想观看原文,可以查看原文。(原文只是一些知识体系的罗列,相信大家两者都看了,会有一个清晰的判断)
好了,天不早了,干点正事哇。
我们能所学到的知识点
❝
- 前置知识点
- 愿景:将Photoshop引入浏览器
- 新的Web功能释放了Photoshop的潜力
- 优化Photoshop在浏览器中的性能
- 使用TensorFlow.js集成本地设备上的机器学习
❞
1. 前置知识点
❝「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」 同时,由于阅读我文章的群体有很多,所以有些知识点可能「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」。 ❞
源(origin)
源(origin
)是
- 「协议」,例如
HTTP
或HTTPS
) - 「主机名」
- 「端口」(如果有的话,
HTTP
的默认端口是80
,而HTTPS
的默认端口是443
)
的组合。
例如,给定网址 https://www.example.com:443/foo
,它的origin
为 https://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 对象」:
可以使用构造函数 Blob
或 Blob()
工厂函数来创建 Blob
对象。Blob
构造函数接受一个数组(通常是 Uint8Array
数组)作为参数,这些数组将被组合成一个 Blob
对象。
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
。 - 读取本地文件以进行处理或预览。
用途
FileReader
、URL.createObjectURL()
、createImageBitmap()
和 XMLHttpRequest.send()
可以接受Blob
对象用于特定的数据处理。
「FileReader」:
FileReader
是用于读取文件内容的 JavaScript
对象。要将 Blob
数据展示,可以使用 FileReader
读取 Blob
数据,然后在读取完成后执行回调函数来处理数据。
// 选择文件的输入元素
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>
。
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 元素上。
// 一个 <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
数据并展示它。以下是一个获取并展示图片的示例:
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
一直是图像编辑和图形设计的王者,兼容Windows
和macOS
两个皆然不同的系统。但将其从桌面解放出来,就像打开了新世界的大门,让我们对未来的浏览器应用有了更多的展望和遐想。
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
的页面可以查看相同origin
的OPFS
数据,因此https://A.com/C/test
可以查看与https://A.com/
的OPFS
数据。
每个origin
都有自己独立的OPFS
,这意味着https://A.com
的OPFS
与 https://B.com
等站点的OPFS
完全不同。
而对于OPFS
的存储形式,我们可以参照本地系统。在Windows
上,用户可见文件系统的根目录是 C:
。对于OPFS
,相当于每个origin
都可以通过调用异步方法 navigator.storage.getDirectory()
访问一个最初为空的OPFS
根目录。
就像浏览器中的其他存储机制(例如 localStorage
或 IndexedDB
)一样,OPFS
也受浏览器配额限制。如果用户清除所有浏览数据或所有网站数据,OPFS
也会被删除。
使用方式
使用OPFS
的方法有两种:在主线程
上或在 Web Worker
中使用。
Web Worker
不能阻塞主线程,这意味着在此上下文中,API 可以同步,同步 API 的速度更快,因为它们无需处理promise
- 主线程上通常不允许同步API
无论是在主线程
上或在 Web Worker
中使用,第一步首先就是获取对「根目录的访问权限」,这样OPFS
使得可以快速创建、读取、写入和删除文件。
const opfsRoot = await navigator.storage.getDirectory();
有了根文件夹后,我们分别使用 getFileHandle()
和 getDirectoryHandle()
方法创建「文件」和「文件夹」。传递 {create: true}
后,系统会创建不存在的文件或文件夹。以新创建的目录为起点调用这些函数,以构建文件层次结构。
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()
方法不仅可以创建新的文件或者文件夹,我们还可以通过指定特定的参数,来访问「先前创建」的文件和文件夹。
const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
.getDirectoryHandle('my first folder');
既然,文件目录有了,我们更希望的是能够在其中存储相关的数据信息。此时我们通过调用 createWritable()
将数据流传输到文件中,这会创建一个指向该文件的 FileSystemWritableFileStream
,然后通过 write()
写入相应内容。最后,对数据流执行 close()
操作。
const contents = '前端柒八九';
// 获取可写流。
const writable = await fileHandle.createWritable();
// 将文件内容写入流。
await writable.write(contents);
// 关闭流,从而保存文件内容。
await writable.close();
前面,讲过OPFS
并不能被用户看到,在前面的操作中,我们新建的文件,写入了内容,此时所有的操作都是对用户不可见的,那如果没有方式让这些数据可见,那岂不是「脱裤子放屁,多此一举」。好在,人家已经给我们想好招了。
我们可以通过fileHandle.getFile()
获取关联的 File
对象。File 对象
是一种特定类型的 Blob
,可以在 Blob
能够使用的任何上下文中使用。这样我们就可以通过指定的API(在「前置知识点」中有过介绍)将其转换成其他数据类型。并且我们可以访问这些转换后的数据,并将其提供给「用户可见的文件系统」。
const file = await fileHandle.getFile();
console.log(await file.text());
上面是OPFS
的基础语法,其实要想发挥其最大的功效,还是需要借助Web Worker
。毕竟,我们既然用到了OPFS
,那肯定是要解决在浏览器中操作「大文件」所遇到的阻塞主线程等令人抓狂的性能问题。
并且,由于Web Worker
不会阻塞主线程,因此在此上下文中允许使用OPFS
的同步方法。
我们可以通过同步句柄
,来操作对应的文件。同步句柄
可以通过调用 createSyncAccessHandle()
从常规 FileSystemFileHandle
中获取。
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
中,在合适的方式进行数据的展示。
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的
WebAssembl
y在DevTools
中的调试支持是非常有价值的。
如果大家对WebAssembly
还不是很了解的话,可以翻阅我们之前写的浏览器第四种语言-WebAssembly。针对wasm
的概念性东西这里就不在过多介绍了。在里面我们还介绍了利用Emscripten
实现了 将C/C
代码编译为WebAssembly
并且,在我们之前还介绍过,C
与JS
代码之间的互操作。感兴趣的可以参看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] (需要