分类
js

用 express.js 实现流式输出 HTTP 响应

0. 前言

我们先来总结一下客户端与服务器端的数据交互方式:

  1. ajax,即 XMLHttpRequest,最常见的数据交互方式,适用于较轻量级的一次性请求,比如用户登录、CRUD 等。
  2. SSE,服务器端事件,适用于有大量服务器端推送、且不需要从客户端发送数据的需求,比如盯股票行情
  3. WebSocket,适用于上下行数据都很多、都很频繁的情况,比如在线聊天、网络游戏等

单从技术角度来看,大概是这几个。根据需求变化,我也可以组合出更多方案。比如,类似 Showman 生成视频这种需求,耗时很长,对服务器资源占用很高,就比较适合:

  1. 发起请求,产生一条任务记录,进入队列
  2. 服务器上运行独立的进程,从队列中取任务出来执行,并记录日志
  3. 客户端不断检查任务进度

1. 需求

今天的需求,介于轻量一次性,与需要服务器长时间执行之间,即 FastTest 中的发布功能。这里,我们有以下需求:

  1. 管理员点击 Publish 按钮,开始发布静态网站
  2. 服务器需要先保存数据,然后调用 webpack 发布所有语言版本
  3. 只有少数管理员会使用此功能,服务器压力不大
  4. 管理员希望能了解发布进度,及每一步的状态

所以,提交任务后等待服务器慢慢跑就不合适;等待请求完成一次性看到所有结果也不合适;最合适的,就是基于长链接,不断获取返回信息,并在前端实时看到进度。也即是:流式输出 HTTP 响应。

2. 实现

2.1 后端

后端实现我选择 node.js + express.js,在后端领域,这两个我比较熟悉。express.js 是在 node.js 网络模块上进行封装得来,使用起来很简单,也支持原生 node.js 方法。

首先我们创建一个服务器:

const express = require('express');
const app = express();
const port = 3100;

app.listen(port, () => {
  console.log('FastTest Admin API at: ', port);
});

然后,我们在需要流式返回响应的接口里设置相应头 Content-type: application/octet-stream。接下来,只要我们不断向输出流写入内容就可以了。哦,对了,结束的时候,我们还要关闭输出流。

app.post('/data', async(req, res, next) => {
  res.setHeader('Content-type', 'application/octet-stream');
  
  // 第一次返回
  res.write('Local data saved. Start to build dist。files.\n');

  // 数次返回
  for (const item of items) {
    await doSomething(item);
    res.write(`${item} done successfully.\n`);
  }

  // 最后,全部完成
  res.write('All done.');
  // 关闭输出流
  res.end();
});

2.2 axios

axios 是很流行的 ajax 库,它进行了 Promise 封装,用起来很方便。这里我们要用 onDownloadProgress(即 axios 对 XMLHttpRequest.progress 的封装)获取下载进度,它会在每次服务器返回响应字符串的时候更新,我们只需要截取上次响应之后,这次响应新增的内容,即可。

function publish(data, onDownloadProgress) {
  return axios.post('/data', data, {
    onDownloadProgress,
  });
}

async function doPublish() {
  if (isLoading.value) {
    return;
  }

  isPublishing.value = true;
  message.value = status.value = null;

  try {
    const { cases, lang } = store.state;
    let offset = 0;
    await publish({ cases, lang }, ({ target: xhr }) => {
      // responseText 包含了从一开始到此刻的全部响应内容,所以我们需要从上次结束的位置截取,获得新增的内容
      const { responseText } = xhr;
      const chunk = responseText.substring(offset);
      // 记录这一次的结束位置
      offset = responseText.length;
      currentStatus.value = chunk;
    });
    status.value = true;
    currentStatus.value = '';
    message.value = 'Published successfully.';
  } catch (e) {
    message.value = 'Failed to publish. ' + e.message;
  }
  isPublishing.value = false;
}

2.3 Vue3

相对来说,Vue3 的部分最容易。这里我用了 animate.css 的 flash 效果,让信息更显眼。除此之外,就是简单的赋值。

<template lang="pug">
.alert.alert-info.mb-0.me-2.py-1.px-3.animated.flash.infinite.slower(
  v-if="currentStatus",
) {{currentStatus}}
</template>

3. 效果演示

流式输出效果演示

4. 部署

一般来说,我们很少会直接用 node.js 当服务器,多半会启动 node.js 服务,然后用 nginx 反向代理给用户访问。这里需要注意,nginx 默认会将响应内容存入缓冲区,然后批量返回给客户端。这会导致流式输出无效,变成常规的执行完毕后一次性输出。

所以我们必须修改 nginx 的配置:

# 仅展示有关配置
location /data {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://localhost:3100;
        # 关闭代理缓冲区,所有响应实时输出
        proxy_buffering off;
}

5. 总结 & 扩展阅读

前些天我帮朋友做了个小项目,叫 FastTest。项目很简单,不过我主动扩展了技术选型,使用了非常多的技术,都是浅显入门的程度,非常适合用来做基础入门学习。本文也是从这个项目中提炼出来的。建议大家有空的时候看看,拉到本地,跑起来试试看效果。

文中的几段代码,分别位于:

项目的详细介绍请见:超全面全栈入门项目:fastest

扩展阅读

分类
js

检查超宽元素的脚本

有时候我们制作页面,搞着搞着发现超宽,出现横向滚动条。于是我们就要想办法调整样式,但是往往超宽的只有那么一两个元素,并不是很好找,所以我就写了下面一个脚本,在页面里跑一下就能找到超宽的元素,然后针对性调整一下样式就可以了:

// 这里的 375 主要针对基于 iPhone 6 开发移动端页面时
function traverse(parent) {
  let target;
  for (const elem of parent.children) {
    const rect = elem.getBoundingClientRect();
    const {left, width} = rect;
    if (left + width > 375) {
      target = elem;
      break;
    }
    target = traverse(elem);
    if (target) {
      return target;
    }
  }
  if (target) {
    return target;
  }
}

使用的时候,打开页面的开发者工具,将这段代码复制到 console 里面,然后执行 traverse(document.body) 就可以找到超宽的元素,然后想办法调整它即可。

当然我们也可以继续用这个函数探索可疑元素,找到更具体的超宽元素;或者找到其它超宽元素。这些就留给大家自行探索吧。

分类
js

函数,栈,try…catch,以及异步

前两天《记一个 `try…catch` 异步函数的坑》发出后,有同学表示对最后一句不解:

异步函数的调用可能并不在当前栈,也无法被 try…catch 在当前栈捕获到错误。

要解释清楚,一两行是不够的,于是写这篇文章展开解释一下。(这篇文章是基础向。)

函数与栈

首先来看这样一段代码:

function a() {
  // ...
}

function b() {
  // 代码1 
  a();
  // 代码2
}

function c() {
  // 代码3
  b()
  // 代码4
}

c();

它会怎么执行呢?简单来说是这样:

  1. 构建一个栈
  2. c 推入栈,开始执行 代码3
  3. b 推入栈,开始执行 代码1
  4. 将 a 推入栈,开始执行
  5. a 执行完,出栈
  6. 开始执行 代码 2
  7. b 执行完,出栈
  8. 开始执行 代码 4
  9. c 执行完,出栈

栈是一个先入后出的数据结构,你可以把它当成一个桶,先放进去的东西会被后放进去的东西压在下面,需要先把上层的东西,也就是后放进去的东西拿出来才能拿到先放进去的东西。

发生错误

如果代码执行中发生错误,就会在错误处中断,并逐个出栈。此时我们不仅能看到错误本身,还能看到错误发生处的完整栈信息,对 debug 很有帮助。

try...catch

异步函数出现之前,try...catch 只能捕获当前栈的错误。比如,同样是上面那段代码,稍微改动一下:

// 之前定义 a b c 的代码不变

function d() {
  throw new Error('oh my god');
}

try {
  c();
} catch (e) {
  console.error(e);
}

d();

这段代码里,c() 执行时如果有错误,可以被 try...catch 捕获到;但是 d() 位于另一个栈(或同一个栈的另一个堆叠),就无法捕获。

异步 callback & Promise

Promise 成为规范被纳入标准之前,我们处理异步操作时需要使用回调(callback)。比如侦听事件:

try {
  $('.btn').on('click', function (e) {
    console.log('hello');
  });
} catch (e) {
  console.error(e);
}

请注意:这里添加侦听函数的代码,也就是 $().on() 的部分,是在当前栈执行的;而用户点击后执行的操作,即 console.log('hello') 的部分,是在将来某个事件调用栈里执行的。所以,如果后面的函数里有问题,那么如上面所示的代码是无法捕获到错误的。

Promise 也一样。比如下面这段代码:

new Promise((resolve) => {
  // 执行完后回调
  func0(resolve);
})
  .then(() => {
    return func1();
  })
  .then(() => {
    return func2();
  });

这里的 func0func1func2 三个函数都是在不同调用栈里执行的,所以如果你在最外面 try...catch,无法捕获到错误。

这样会给我们编写代码带来一些困难。比如,有时候我们需要同时发起好几个网络请求,有些会成功有些会失败。我们并不知道每个失败请求对应的构造函数是怎么执行的,只能依靠请求内容进行判断,就比较麻烦。

这个问题,在异步函数中得到了解决。

异步函数 async function

这个变化主要是 await 带来的。

前篇文章所说,在不使用 await 的情况下,try...catch 只会捕获当前调用栈的错误。对于异步函数来说,它在当前栈会返回 Promise 实例,然后就顺利结束,不会抛出任何错误。所以 try...catch 也无法捕获任何错误。

但是添加 await 之后,情况就不同了。这个时候,try...catch 会捕获整个 Promise 里所有的错误,即使函数位于不同调用栈,也可以正确捕获并且显示在一起,方便调试。

分类
js

JavaScript 获取正则表达式中子表达式的个数

正如标题所示,我厂有这么一个需求。我不会,老板鄙视我后丢过来一个链接:stackoverflow: Count the capture groups in a qr regex?

看不太懂 Perl,但是这个思路很棒。所以改写成 JS 版,并记录如下:

function countCapturingGroups(r){
  r = new RegExp(`|${r.source}`);
  const result = ''.match(r);
  return result.length - 1;
}

const result = countCapturingGroups(/fo(.)b(..)/);
console.log(result); // 2

它的原理是这样的。构建一个新正则,包含两部分:空字符和目标正则。空字符正则会完成与目标字符串的匹配,保证有结果(不然的话就会返回 null。接下来 | 会保证后面的正则也是有效的,可以生成包含子表达式结果的数组。

我们知道,结果是个类数组,结构大约是:

  1. 全部匹配字符串
  2. 0~N 子表达式结果
  3. 其它一些属性

所以用其长度 – 1 就能获得子表达式的个数。从功耗上来说,这个应该是很节省了。

分类
js

使用 Proxy 创建有魔术属性/方法的类

前几天在 SF 回答了这个问题:如何使用proxy,如何在内部拦截get方法,然后翻了翻以前写的博客:使用 Proxy 添加魔术属性/方法,发现上次写完代理对象就停笔了,所以今天补全一下:创建有魔术属性/方法的类。这样就比较完整了。

JavaScript 构造函数的特点

ES6 增加了 class 关键字,梳理了面向对象的语法,现在我们可以这样定义一个类:

class Person {
  constructor(name) {
    this.name = name; 
  }

  hello() {
    return `Hello, my name is ${this.name}.`;
  }
}

如果你有其它面向对象语言的经验,应该很容易理解这段代码。

不过 JS 的构造函数特别,它支持 return 一个其它对象,作为 new SomeClass() 的结果。(如果不 return 或者 return 一个空对象,那么 new SomeClass() 得到的就是 SomeClass 的实例。)

也就是说,原则上,我们可以这么做:

class Person {
  constructor(name) {
    this.name = name;
    return {name: 'Meathill'};
  }
}

const person = new Person('张三');
console.log(person.name); // 'Meathill'

结合 Proxy

理解了上一节的内容,我们就很容易得到这样一个类:

class Person {
  constructor(name) {
    this.name = name;

    return new Proxy(this, {
      get(target, property) {
        if (property in target) {
          return target[property];
        }
        console.warn('Sorry, I can't do that.');
      }
    }
  }
}

在这个类的构造函数里,我返回了一个 Proxy 实例,代理了对真正 Person 实例的访问。当访问的属性/方法在实例上时,就返回需要的属性/方法,否则的话,输出警告。

实际上,Proxy 的 getset 的功能远不止如此,上面的代码只是一些演示。

用途

魔法属性/方法主要有以下用途:

  1. 对象 a 要使用一部分对象 b 的功能,但是又不方便直接用原型链。比如上一篇文章的场景,我提供类 VElement 作为接口,实际完成工作的是另一个沙箱中的 Element。
  2. 不知道会怎么访问对象,希望所有访问都照顾到。
  3. 希望捕获到对对象的修改,也就是 Vue 3.0 的核心修改。

Vue 3.0

Vue 1.x & 2.x 期间,都在使用 ES5 的 Object.defineProperty 拦截对对象的修改,实现响应式。这样的做法看起来很神奇,给 Vue 带来了巨大的成功。但是这样做也有坏处:

  1. 声明实例时需要很多预处理工作,而且数据量越大处理的时间越久
  2. 不支持某些数组操作
  3. 不支持其它数据类型,比如 Set、Map
  4. 不支持后续的数据观察

使用 Proxy 之后,以上问题全部都迎刃而解,甚至,因为 Proxy 是原生 API,性能表现更好,取得了内存减半、速度加倍的效果。

想了解更多 Vue 3.0 的新特性,可以去看我在 SF 的分享:迎接 Vue 3.0。(注:这是免费广告,我不会从新购用户取得收益。)


参考文章:

Constructor, operator “new”(构造函数和操作符 “new”)

分类
js 技术

使用 Proxy 添加魔术属性/方法

最近在开发我厂的 QA 工具时,遇到一个问题。我需要模拟 Puppeteer 的所有方法,以便兼容原先的 JS 文件。Puppeteer 提供一个 .asElement() 方法,可以把函数执行结果转换成一个伪 DOM Element(如果函数返回的就是 DOM Element 的话),然后我们就可以在 Node.js 里调用原本属于 DOM 的方法,比如 .focus()。Pupputeer 会替我们完成映射和函数调用,并且返回结果。

对于大部分对象来说,我只要模拟对应的属性、方法,然后用自己的函数实现功能即可。但是 DOM Element 有上百个属性和方法,手工实现一遍实在太低效了。必须寻找其它途径。

好在我之前看过 Proxy 的介绍,赶紧翻出文档和书又复习两遍,就大概知道怎么做了。

Proxy 类如其名,可以“代理”对某个对象的访问。你可以把他理解成明星的经纪人。明星成名之前都是自己处理一切事务,有了经纪人之后,大部分事务就由经纪人负责,但仍然有一些事情需要明星自己处理。

Proxy 的用法很简单,实例化时,把要代理的对象传进去,定义一下代理方法就好。

const obj = { name: 'meathill' };
new Proxy(obj, {
  get(target, property) {
    // 如果对象中有要求的属性或方法,则返回
    if (property in target) {
      return target[property];
    }
    // 没有的话,进行其它处理
    return 'hello';
  }
});

obj.name // 'meathill'
obj.age // 'hello'
obj.sex // 'hello'

接下来,比如我们访问 obj.foo,那么代理就会生效,它会先检查 obj,如果这个对象上本来就有 foo 属性,就会返回;如果没有,则会调用我们定义的方法来处理。

如此一来,我们可以定义一个 VElement 类,这个类可以实现一些特殊方法,比如 .type(str) 输入,.click() 点击等;然后用 Proxy 代理其它方法和属性,让对象进入插件 Context 执行。


关于如何创建具有魔法属性/方法的类,请移步阅读 使用 Proxy 创建有魔术属性/方法的类


Proxy 还有其它方法也很有用,尤其是 get 对应的 set ,以后再介绍。大家可以自己抽空研究下。

参考

  • 阮一峰的 ES6
  • 《深入理解 ES6》

分类
js

Safari 下 Date 不支持”2018-01-01 00:00:00“

前两天发现一个小程序的问题,Android 正常,iPhone 出错。我们都知道,Debug 的关键在定位,如果是某些特殊环节,不常见的错误,就会浪费很多时间。

这个 Bug 也是如此,反复拉锯之后终于发现,问题出在下面这句:

let a = new Date(`${date} 00:00:00`);

date 是服务器端返回的值,我是把它和后面的 00:00:00 连起来,记作某天零点零刻,和今天的零点零刻做减法,计算日期差,并按照日期差来决定接下来的逻辑。这段代码在开发工具(包括 Mac)、Android 手机上运行都正常,只有在 iPhone 上不正常,于是我打开 Safari——苹果这点做得不错,桌面版 Safari 环境和 iOS 几乎没有差别,该出的问题一定会出——果然复现了这个问题。

按照规范,中国的日期格式是:“2018/01/01”,Safari 只支持这个格式。而 2018-01-012018-01-01T00:00:00 ISO 格式,Safari 也支持,但是会以格林威治时间为准,和我们有8小时的时差。Chrome 和 Android 内嵌的 WebView(基于 Blink 或者 Webkit)则都支持,所以在本地和 Android 手机上没有问题。

分类
js

JavaScript 异步开发全攻略

之前在 GitChat 做过一次分享:《JavaScript 异步开发全攻略》。在我看来,原始内容可能不够完美,但通过后来的维护,可以把它打磨得越来越好。昨天在 SF 上回答了一个 Vuex 的 action 里使用 Promise 的问题,然后就想去补充一下这方面的内容。结果发现 GitChat 竟然不支持编辑文章,只能把内容发给运营人工修改。

索性把内容放到 Gitbook 上好了,反正 GitChat SEO 也不做,文章也自然移动到第二页了。试了试,没有被墙,很好。于是简单整理了一下,上传。

欢迎阅读,欢迎分享,因为我会不时更新新内容,请关注 star:

JavaScript 异步开发全攻略

分类
js

bower.json 指定版本

为了使用部署脚本部署代码而不是登录到服务器上用命令行,我需要尽量简化部署步骤。

相对来说,composer 会简单一些,因为只要提交 composer.lock 文件然后 composer install --no-dev 即可。经测试 bower install --production 它是不会自己去更新了安装新依赖的,只会从缓存里安装。所以需要想个办法。

Value must be a valid semver range, a Git URL, or a URL (inc. tarball and zipball).

按照官网上的描述,只允许版本号、Git URL 或者安装包 URL。然则我试了一下,写 “#+版本号” 也是可以的,所以我们现在是这样:

{
  "dependencies": {
    "tiger-prawn": "#68fa36ceb88c1b0e9b9472b6901d957f424e50b7"
  }
}
分类
js

在Chrome 扩展中使用 Handlebars

Chrome 扩展可以访问到各种用户敏感数据,比如 cookie 之类,所以 Chrome 团队对它的限制非常严格(见 Using eval in Chrome Extensions. Safely.),比如常规环境下完全不能使用 evalnew Function()。这给我们使用 Handlebars 之类的模板工具带来不小的麻烦。

一个解决方案就是使用上文中介绍的 sandbox。将可能使用到 eval 的代码放到一个独立沙盒中,不让它访问到那些敏感信息;然后通过 postMessage API 与之进行数据通讯,完成模板生成工作。

我觉得这个方案不太理想。首先我不喜欢 <iframe>;其次每次渲染模板还要 post 来 post 去,渲染结果也是异步传递(这点暂时存疑)。

另一套方案,则是利用 Handlebars 的预编译功能,将模板在开发环境中编译成函数,在扩展中直接使用。这样做的好处也很明显,因为发布插件时也会先处理代码,这个时候将模板处理完,工作效率更高。

等写完再补充具体代码吧。