某电子的 Altria 的部落格某电子的 Altria 的部落格
首页
博客
关于
首页
博客
关于
  • 文章列表

    • 博客文章
    • 使用Stream在前端实现大数据量的表格数据导出
    • 10分钟搭建自己的个人博客
    • 如何在家打造自己的多媒体影视墙
    • 家用路由器组网指南

使用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()
})

基于此,我们可以构想一个实现:

  1. fetch API 请求接口时,不仅可以基于浏览器和后台,原生返回stream,也可以前端自己return一个stream。
  2. 我们可以构建一对流导出表格那里拿到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的编码规则。

最近更新:: 2026/1/17 13:05
Contributors: eater-altria
Prev
博客文章
Next
10分钟搭建自己的个人博客