燧石受到的敲打越厉害,发出的光就越灿烂。——卢梭
大家好,我是柒八九。
前言
打工人 打工魂 打工人都是人上人。是不是还沉浸在2024
的放假通知中,小伙该收收心了。毕竟,你多打一天的工,老板就离他在游艇中喝着香槟和美女一起海钓的梦想又更进一步了。好了,玩归玩,闹归闹。作为一个职业打工人,我们还是要着眼于当下。
在前几天,我们写了一篇Rust 编译为WebAssembly 在前端项目中使用的文章,简单的描述了Rust
如何编译为wasm
在浏览器中使用,本意是想表达Rust
和wasm
是可以在浏览器中使用,并且还有更深的意思就是wasm
在前端真的真的会有大放异彩的一天。在发布文章后,在一些平台中,总有人充斥着质疑声。
大概,他也是出于一些好意,然后也想找一些理由,让我们迷途知返,幡然醒悟。我认为想要说服一个人,讲事实,摆道理是一个最优路线。当然,我也没想着通过几句话说服别人。那就说的委婉点哇,那就用事实和道理,说服我自己,让我能够更有动力去学习。
莫言曾说做人切记:法不轻传,道不贱卖,师不顺路,医不叩门,你永远叫不醒一个装睡的人,即便你再唤醒他,他是否愿意醒还是个问题。绝大部分人活着都是为了睡得更香,而不是为了觉醒。 虽然这话在这里有点重,但是我认为也可以作为一个做事准则,不要好为人师。
在前面的文章中多次提到,国内技术存在滞后性,而大部分抗拒Rust/Wasm
的人,也是拿国内的环境说事。其实吧,我不是崇洋媚外之人,但是不得不承认有些东西,国外的月亮确实比较圆。(如果这句刺痛了你,不好意思,这是我的无心之举。我是一个坚定的马克思主义理论工作者)
今天,我们就以国外一篇文章Photoshop is now on the web!为主体框架,来讲讲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 | 是否“同源”或“跨源” |
www.A.com:443 | https://**www.B.com**:443 | 跨源:不同的域名 |
https://login.A.com:443 | 跨源:不同的子域名 | |
http://www.A.com:443 | 跨源:不同的协议 | |
www.A.com:**80** | 跨源:不同的端口 | |
www.A.com:443 | 同源:完全匹配 | |
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。请查看浏览器控制台。\n\n" + 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> 中。请查看浏览器控制台。\n\n" + err); return; } }
世上本没有路走的人多了也就成了路。
如果想了解更多OPFS
可以参考