使用Stream在前端实现大数据量的表格数据导出
优化表格导出我们用到了两个最核心的工具和概念。一个是流,流的意义在于大幅度降低了内存占用,原先我的电脑无法导出的数据,现在可以正确导出。另一个是Service Worker,Service Worker的目的是拦截前端指向后台的请求,从而让前端自己给自己准备数据。
问题分析
我们的项目当中,有一个纯粹的前端实现的表格导出功能。之所以由前端实现,是因为我们的表格页面是一个可配置的页面,通过一套配置,可以兼容不同接口的不同数据,这导致表格当中为了正确展示数据有一套复杂的 format 配置逻辑,接口数据经过 format 之后展示为用户在页面上看到的数据,而我们希望导出的数据和用户看到的数据保持一致,那最简单的实现思路自然就是前端导出
目前导出数据面临的问题,我们认为可以概括为以下几点:
大数据量下载比较慢
这一点没啥很好的办法,至少对于前端来说没有办法解决。这里列一个数据:我们构建了一个20万条数据的测试页面用于导出。在zifan的电脑上,下载全部数据大概需要不到35秒,而整个流程跑完需要50-60秒,在我电脑上下载时间一样,整体时间1分10秒,可以看到实际上导出仍然占据了一半的时间。后台从数据库扔出原始数据花了35秒,而前端将原始数据处理、转换成二进制,然后调用流把二进制推送并保存在用户PC上也花了35秒,也许后台接口时间是可以继续优化的。
数据format有问题
对数据的format慢。数据format过程,会依赖写在json当中的format_value,这个字段可能是表达式。前端执行动态表达式存在多种方法,性能由上到下依次为:
- 使用new function构建一个函数。这种构建的函数,其作用域不在构建函数的位置,不会影响执行处的作用域,所以V8可以对其进行优化。
- 使用eval(), eval执行表达式的地方,表达式内部享受表达式外部的命名空间,只要一个函数内使用了eval,V8就会完全跳过对这段代码的优化,使用最简单的C++实现的解释器来解释执行,没有JIT,不会被编译为机器码。
- 使用自己构建的解释器执行,比如我们可以用JS自举,用加S写一个解释器解释JS,而JS解释器又被C++解释,即便实现解释器的JS代码被V8充分优化,其性能肯定也不如原生C++的性能,所以第三点的性能肯定低于2
很不幸的是,我们的format_value就是依靠这个方式来执行的。我们使用一个叫eval5的库,经过了解,这个库是使用JS构建了一个可以对ES5代码进行解释的JS解释器。第三种方式不一定就是不能用的,自己实现解释器可以更好的控制输入的表达式,比如可以把一些在JS合法的用法禁用掉,或者派生新的写法等,但是我们暂时没有用到这样的能力,但是却需要为此支付代价,这显然不够合理。
而动态执行表达式对数据进行处理,目前来看是一个刚需,所以暂时也没有很好的办法来规避,在·这个大前提下,我们无法跳过这一步,但是可以尝试使用效率较高的new function来实现。我们把新建的function保存起来,下次调同样的方法,会使用原来的那个函数,这样还可以实现让V8对新建的函数进行JIT编译。
导出容易爆内存
如果导出本身比较慢,那么我们仍然可以尝试分析一下哪里慢,从而进行优化,但是因为爆内存的关系,现在测试页面的20万数据我是无法正确下载的。每次都是内存耗尽导致下载失败。我也没办法打出一个性能快照,只能将这个功能看作不可用。·
巨量数据作为一个Array保存在页面当中,就已经非常占用内存了,而xlsx格式是一个经过压缩的xml,我们使用的SheetJS库使用一个巨大的Blob保存xlsx数据,在转换完成之后触发下载,将xlsx保存到本地。这个过程当中,Blob大小会不断扩大,直到内存爆炸。
几种常见的前端可以实现下载的方式
我们这次使用了Service Worker来实现下载。除此之外,还有其他的几种方式,这里分别列举一下:
纯后台实现。
大部分我们看到的下载都是这种方式实现的。只要设置合适的响应头,数据不仅可以分片传输到用户手中,用户打开对应的链接也会触发下载。
一套常见的响应头如下:
'Content-Type': 'application/CSV; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Content-Disposition': `attachment; filename*=UTF-8''${fileName}`,
这个过程的复杂逻辑都在后台,前端之需要简单适配。
我们现在遇到的问题,理论上也可以通过这个方式实现,建一个云函数进行导出操作。但是工作量会比较大,因为我们涉及到大量的数据格式化操作,这些逻辑比较复杂,我们需要把这些全部移植到一个云函数上,外加云函数还需要请求接口,需要移植的代码不少,时间太赶的话,这个我们可能做不完。于是我们没有采取这个方式。
前端基于Blob实现
Blob) 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取。前端可以把需要的数据准备好之后,创建一个Blob,然后再基于URL.createObjectURL()创建一个指向Blob的URL,我们之需要创建一个跳转到这个url的a标签,a标签添加download属性,然后模拟点击,也可以开始下载文件。
同时,我们也可以直接用后台给的数据创建blob来实现下载。当后台没有设置合适的响应头,或者接口压根不是get时,我们也可以先请求数据,把拿到的数据专为blob,再创建url模拟点击,这也是blob下载的其他使用场景。
绝大多数场景下,如果后台直接发送了二进制数据给我们,我们可以很简单拿到一个Blob。如果需要纯前端构造Blob,要么是先拼接保存信息的字符串,然后将字符串转为Blob,要么是操作一个array,向其中不断push取值范围为[0,255]范围的值,然后将Array转为Buffer再转为blob。这两种过程都需要频繁的在堆上申请分配内存,即allocating,这个过程比较慢,随着可用内存越来越小,碎片化越来越大,性能也会越来越低。
实际上之前我们就是采取的这个方式,我们下载的excel文件再被下载之前就是一个blob。
但我们是没办法动态的向blob里面添加数据的,它是不可变的。所以我们必须先准备好所有数据,然后构造blob,然后下载。于是这就导致了两个问题:
- Blob大小有限制,不能下载太大的文件。
- 在下载开始之前,需要先准备blob,于是给人的感觉是下载需要很久才开始,体验不好。
- 如果文件足够大,blob即便能装下,也会因为占据大量的内存,导致卡顿。
这些问题都是我们现在面对的真实问题,所以我们自然也不能继续用这个。
Service Worker + Stream的方式
这是本次优化使用的方式。暂时不了解也没关系,我们马上介绍,这里不做赘述。
Stream
流的概念
流这个概念在后台来说非常常见,因为TCP本身就具有流的属性,所以任何可以用来编写网络后台的语言,基本上都可以很简单实现Stream。我们以Nodejs为例子:
const http = require("http")
http.createServer((request, response) => {
response.writeHead(200, {
'Content-Type': 'text/html',
'Transfer-Encoding': 'chunked'
})
let count = 0
const timer = setInterval(() => {
response.write('chunked\r\n');
count +=1;
if(count > 10) {
clearInterval(timer);
response.end();
}
}, 1000)
}).listen(8000);
我们甚至不需要依赖第三方包,使用nodejs自带的http,就可以实现,当我们本地跑这个代码的时候,访问对应的地址,可以看到服务端每隔一秒就会向浏览器推送数据,直到触发 response.end();,请求才正式结束。
流的一大优势在于,其对于数据的读取是一次性的。流就向一个河流一样。A在上游,把东西扔到河里,B在下游,从河里打捞。B打捞到东西之后,可以选择把东西保存下来,也可以选择不保存,对于不保存的东西,东西就会流走。
在许多情况下,计算机是不需要将所有数据保存到内存当中的。举一个最简单例子,就是我们从服务器,下载一个100GB的文件。我们触发接口,服务器去文件系统读取特定文件,代码会把读取到的文件进行编码,变成数据帧发送。这个过程当中,服务器当然可以先读取100GB的内容再发给我们。这种情况下它需要100GB的内存空间保存100GB的数据,同时还需要100GB的空间保存编码之后的数据,然后一股脑扔出来。但是它也可以读取一部分、编码一部分、发送一部分 ,这显然可以大大降低内存占用,这就是流的意义。
如何利用流
Streams API - Web APIs | MDN (mozilla.org)
通过MDN文档可知,目前JS可以使用三种不同的流:
- readable stream 可读流,数据会先进入队列,当用户读取时,从队列当中出来。
- readable byte stream 可读的流,但队列为空时可以不进入队列,直接到用户侧,实现0拷贝传输。
- writable stream 可写流,可以向流当中推送数据
这里我们使用了readable stream 和writable stream。如果在一些比较老的浏览器当中,这两个流需要我们分别构建并绑定,从而实现向一个writable当中当中写数据,另一个readable当中可以读数据的数据传输。但是现在,我们可以很简单的实现这个操作:我们可以一行代码构建出一对流,一个可读,一个可写。
对于一个可写流,我们可以多次调用write方法,向内部推送数据。在推送完成之后,调用close方法,关闭流。
而对于一个可写流,每次调用read方法都会读取到不同的数据,第一次read得到的,是第一个write写进去的数据,第二次read得到的,是第二次write写入的。在么有触发close时间时,如果流当中没有数据——可能是还没有写入,也可能是所有数据都已经被读取出来——那么读取操作就会pending。而如果触发了close时间,那么读取会返回done = true
我们看一个实例代码:
import {
TransformStream,
} from 'node:stream/web'; // nodejs 需要import,浏览器不需要
const {writable, readable} = new TransformStream()
const writer = writable.getWriter();
const reader = readable.getReader();
reader.read().then((data) => {
console.log(data);
reader.read().then((data) => {
console.log(data);
})
})
writer.write(123).then(() => {
writer.close()
})
基于此,我们可以构想一个实现:
- fetch API 请求接口时,不仅可以基于浏览器和后台,原生返回stream,也可以前端自己return一个stream。
- 我们可以构建一对流导出表格那里拿到writable Stream,不断向其中写入数据。而将readable Stream,基于fetch API返回给前端。
其代码有点类似于这样:
// 实例代码
fetch('/export_data').then(list => {
const {writable, readable} = new TransformStream()
const writer = writable.getWriter();
const pump = async () => {
list.forEach(item => {
await writer.write(item);
})
}
pumb();
return new Response(readable)
})
但是经过尝试,这样的方式似乎无法完成下载,只能实现正常的传输。我们可以注意到,许多网站的下载,往往会跳转到一个空白页面,然后浏览器开始下载,页面自动关闭。这是因为如果需要触发浏览器的默认下载,需要浏览器打开这么一个网页。这个网页可以是一个新的标签,也可以是一个iframe。但如果这样,就不能使用fetch的方式自己构造请求了——此时下载是一个浏览器默认行为了。
此时,我们需要另一个工具来解决问题——Service Worker
Service Worker
Service worker和普通的worker一样,它工作在另一个线程,但是它和普通Worker的区别是,它是专门为了回应前端发起的请求而实现的,它是一个在JS主线程和后台之间的代理。
Service Worker最常见的场景是PWA应用,这些网页应用会需要在断网的时候可以提供访问,它拦截前端发起的请求,经过特定的逻辑,基于缓存或者其他什么给前端响应。
使用Service Worker,我们可以完全得拦截掉对特定路径下接口的访问,此时,即便是通过打开新标签的方式,我们也能拦下来,把Stream的逻辑给加进去,从而就可以实现下载。
整体流程
- 主线程激活service worker线程
- 主线程创建一对可通信的通道(MessageChannel),这对信道(port1, port2)将用于主线程和service worker通信。
- 主线程向service worker发送消息,消息内容为:下载文件的名字、port2
- 主线程的发送消息,触发service worker的onmessage事件,service worker收到文件名和port2
- worker为下载文件生成一个下载链接来区分,并为此生成一对流。worker把下载链接为key,以可读流为value,把信息保存在一个Map当中。
- worker将下载链接和可写流通过port2发给主线程
- 触发主线程的port1.onmessage方法,主线程拿到下载链接和可写流。
- 主线程创建一个iframe,src = 下载链接,触发请求操作
- 一切请求操作都会触发service-worker的onfetch时间。
- service worker判断请求url是不是自己生成过流的url(查看对应的流是否在Map里)。如果是,则自己修改请求头,并且以可读流作为请求的响应。如果不是泽直接return,不影响其他请求。
- 当可读流作为响应返回之后,触发下载。但此时我们仍然没有向可写流当中写入数据,所以虽然下载任务被创建,但是下载无法真正开始。
- 主线程把可写流传给需要写入数据的业务代码,业务代码按需写入数据。写入流当中的数据从另一侧出来,表现为下载开始,有下载速度。
- 当需要写入的数据完全写完之后,关闭可写流。下载完成。如果遇到问题,执行abort,异常关闭操作,下载被取消。
具体实现
具体的逻辑是如下:
service-worker 需要初始化
// packages/nges-web/src/service-worker/index.js self.addEventListener('install', () => { self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); });主线程需要先注册对应的service worker。service worker代码无法通过import的方式导出,我们必须把它放在当前域名下的一个path下,比如:https://nges.qq.com/static/js/sw/index.js ,这样就可以通过以下的方式进行注册:
navigator.serviceWorker.register('/static/js/sw/index.js');注册逻辑的完整代码如下:
async function register() { // 返回service-worker实例 const registed = await navigator.serviceWorker.getRegistration('/static/js/sw/index.js'); if (registed?.active) return registed.active; // 如果已被激活直接返回 const swRegistration = await navigator.serviceWorker.register('/static/js/sw/index.js'); //尝试注册 const sw = swRegistration.installing || swRegistration.waiting; let listen; return new Promise((resolve, rejcet) => { if (sw) { sw.addEventListener( 'statechange', (listen = () => { if (sw.state === 'activated') { sw.removeEventListener('statechange', listen); swRegistration.active ? resolve(swRegistration.active) : rejcet(); //返回激活实例 } else { rejcet(); } }), ); } }); }
拿到service- worker实例之后,我们需要与之通信,因为service-worker的原理是拦截特定接口地址,我们需要在这里把下载路径所需要的文件名传给service-worker;
async function createDownloadStream(filename) { const { port1, port2 } = new MessageChannel(); //port2和port1是一对可以互相通信的端口。 const sw = await register(); // postMessage 会触发sw的onmessage,它基于onmessage事件,拿到我们传给它的filename port2 sw.postMessage({ filename }, [port2]); // 将port2传给sw,sw可以通过port2串数据给port1,通过port2可以拿到 return new Promise((r) => { port1.onmessage = (e) => { const iframe = document.createElement('iframe'); iframe.hidden = true; iframe.src = e.data.download; iframe.name = 'iframe'; document.body.appendChild(iframe); // 触发下载,但是此时我们没有往可读流写数据,所以下载开始之后速度为0 r(e.data.writable); }; }); }同时我们也需要在service-worker当中监听onmessage事件:
const map = new Map(); self.onmessage = (event) => { const { data } = event; const filename = encodeURIComponent(data.filename.replace(/\//g, ':')); const downloadUrl = `${self.registration.scope + Math.random()}/${filename}`; // 给url+随机数,区分对同一个文件的不同下载 const port2 = event.ports[0]; // [stream, data] // eslint-disable-next-line no-undef const { readable, writable } = new TransformStream(); //生成一对流 const metadata = [readable, data]; map.set(downloadUrl, metadata); // 自己保存可读流 port2.postMessage({ download: downloadUrl, writable }, [writable]); //可写流通过扔进来的port2传出去 };实现拦截请求的onfetch方法:
self.onfetch = (event) => { const { url } = event.request; const metadata = map.get(url); // 基于url拿到对应的可读流 if (!metadata) return null; // 如果url对应的流不存在,就返回null,不影响浏览器走远程接口 map.delete(url); const [stream, data] = metadata; // Make filename RFC5987 compatible const fileName = encodeURIComponent(data.filename) .replace(/['()]/g, escape) .replace(/\*/g, '%2A'); const headers = new Headers({ // 设置请求头 'Content-Type': 'application/CSV; charset=utf-8', 'Transfer-Encoding': 'chunked', 'response-content-disposition': 'attachment', 'Content-Disposition': `attachment; filename*=UTF-8''${fileName}`, }); event.respondWith(new Response(stream, { headers })); // 返回可读流 };当用户点击导出时,调用
createDownloadStream, 拿到对应的可写流。就可以向其中根据业务逻辑写入数据了
导出文件选择了导出csv,原因是csv编码足够简单,纯文本编码,实现起来也简单,如果要导出xlsx的话,我们恐怕需要学习一下xlsx的编码规则。