Things I Learned (2019-09)

Performance Measure🔗

JavaScript

浏览器提供了 performance 用于测量 JavaScript 的一些运行效率,并在浏览器的对应位置(如 Chrome 的 Performance Tab)生成火焰图,可以方便的查看程序调用栈的执行效率。简单的操作如下:

function getMarkName(name) {
  return `mark: ${name}`;
}
function beginMark(name) {
  performance.mark(getMarkName(name));
}
function endMark(name) {
  const markName = getMarkName(name);
  try {
    performance.measure(name, markName);
  } catch (e) {
    // 如果 markName 无法被找到(也就是 beginMark 函数没有被调用)
    // 那么程序在 performance.measure 的时候会报错
    // 这里无需将报错抛出,直接吞掉就可以了
  }
  performance.clearMarks(markName);
  performance.clearMeasure(name);
}

function main() {
  beginMark('label name');
  // 需要进行的操作
  endMark('label name');
}

具体来说,通过 performance.mark 函数标记一个点,然后在需要测量的程序执行完成之后,通过 performance.measure 来计算当前和最初 mark 的点之间的运行时间。最终,这一段结果会在 Chrome 的 Performance Timings 中形成对应的火焰图数据。

performance.measure 也支持三个参数的调用,三个参数分别是 label 的名称,起始 mark 的名称以及终止 mark 的名称。如果省略最后一个参数,那么终止的时间点就是当前 performance.measure 调用的时间点。

最后,通过 performance.clearMarksperformance.clearMeasure 删除标记,清理不必要的内存使用。

更多的介绍,可以参考 MDN 的文档。React 中也使用了类似的技术用于在 Performance 中生成每个 Component 渲染花费的时间,相关的代码可以参考 ReactDebugFiberPerf.js


Mobile Shake🔗

JavaScript

JavaScript 中提供了 devicemotion 事件,可以用于监听设备各个方向上受到的力(加速度)。有了这个事件,就可以用于判断当前用户是否在进行类似“摇一摇”之类的操作,方便开发基于特定交互的一些功能。

具体来说,devicemotion 事件会提供 accelerationIncludingGravity 数据,作为一个对象分别提供 xyz 三个方向上的加速度。通过不同时间点上加速度值的不同,就可以判断当前用户是否在进行摇晃手机的操作了。

使用 devicemotion 的示例代码如下:

function handler(event) {
  const { x, y, z } = event.accelerationIncludingGravity;
  // do stuff here
}
window.addEventListener('devicemotion', handler);

判断是否在摇晃手机,简单来说,只需要判断当前的各方向加速度之差,是否有至少两个超过了给定的阈值。shake.js 中就使用了这样的方法来判断当前用户是否在摇晃手机,具体的代码可以参考源码

devicemotion 更多的信息,可以参考 MDN


SourceMap in Webpack🔗

Build

在 Webpack 的编译过程中,可以通过 devtool 的配置选项选择以什么样的形式输出 SourceMap。Webpack 提供了非常多的选择方案,不同的选项可以达到的效果是不一样的,也会极大的影响最终编译的时间。Webpack 的官方文档,从编译时间、重编译时间(针对 Watch 下的修改重编译)以及最终的使用效果三个纬度,给出了各个配置选项的实际效果。(中文版文档在这里

一般情况下:生产环境最终的打包,建议使用 source-map 作为配置。这种配置会将 SourceMap 文件打包到另外一个独立的文件中,线上代码不会暴露源文件,同时提供了最精细的代码映射关系,方便线上代码的调试和问题定位。当然,这种配置的缺点也非常明显,就是构建过程比较花费时间,因此一般只建议在最终要上线的版本中使用这种配置。

对于开发环境 Watch 模式下打包 Hot Reload 的版本,建议使用 eval-source-map 或者 cheap-module-eval-source-map。这两种模式,都会将代码用 eval 函数包裹起来,重编译的速度比较快,区别主要在于 SourceMap 的生成方案。前者会生成高品质的 SourceMap,因而初次构建的速度会比较慢,但是提供了行和列的映射;后者只提供了行层面的代码映射,因此会更加快一些,但是断点的效果会略打折扣(无法提供到列的映射关系)。

更多的类型以及各种情况的说明,可以参考官方文档(链接在上面给出)。


Webpack Speed Measure🔗

Build

优化的第一步,是知道瓶颈在哪里。

在针对 Webpack 编译速度优化的过程中,知道哪些 loader / plugin 运行耗费了很多时间就显得非常重要了。

Speed Measure Plugin 是一款针对 Webpack 的插件,只需要一些非常简单的操作,插件就可以在已有配置的基础上,给 Webpack 的打包过程增加必要的计时功能,同时清晰的列举出各个 loader / plugin 在本次编译中的耗时情况。Plugin 的文档在这里

简单来说,只需要在原有的配置基础上,这么额外包一层 Speed Measure Plugin 就可以了:

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin({ disable: false });

const config = { /* ... */ };

module.exports = smp.wrap(config);

这里,disable: false 表示需要 Speed Measure Plugin 记录时间,这也是默认的值,可以不传。如果赋值是 true,那么就会告知 Speed Measure Plugin 不要做任何处理,结果等同于没有使用 Speed Measure Plugin。如果需要经常对 Webpack 的打包进行优化,可以将 Speed Measure Plugin 的代码写入到库中,并通过环境变量等方法在打包的时候开启或关闭这个记录的功能。


Add Context in Mochawesome Report🔗

Cypress

mochawesome 是为 Mocha 提供的一个 Report 库,可以用于生成不错的 HTML 报告(见 npm)。库本身提供了一个 addContext 的 API,可以用于在运行 Test 的时候,存入额外的信息到 Context 中,最终在生成 HTML 报告的时候,将这部分 Context 信息写入对应的测试用例内。

参考代码如下:

const addContext = require('mochawesome/addContext');

describe('test suite', function () {
  it('unit test', function () {
    addContext(this, 'content');
    // or
    addContext(this, {
      title: 'title',
      value: 'value or object'
    });
  });
});

几点说明:

  1. beforeEachafterEach 的钩子内调用 addContext 也是允许的;
  2. 如果给定的第二个参数是 URL 或是一个图片的话,mochawesome 可以有相对应的展示;
  3. 记得 it 函数的第二个参数不要使用箭头函数,否则 this 的指向会有问题

然而,在 Cypress 中如果试图直接使用上述方法运行代码,会发现并不能成功。最终生成的报告内并没有对应的 context 信息。其原因在于,Cypress 在运行的过程中,原本被赋值的 context 属性被覆盖掉了,导致虽然进行了 addContext 的赋值,但是最终的结果中并没有保留这部分数据。

一个可行的解决方案是,在 test:after:run 事件中再进行赋值,保证结果生效。示例代码如下:

const addContext = require('mochawesome/addContext');

Cypress.Commands.add('addContext', (content) => {
  cy.once('test:after:run', test => {
    addContext({ test }, content);
  });
});

几点说明:

  1. 因为 addContext API 本质上就是往 test 对象上写 context 数据,而 Cypress 的 API 正好提供了 test 对象,因而第一个参数不需要传 this,直接将 test 以合适的方法传入就可以了;
  2. 上面的代码定义了一个 Cypress 的命令方便各个地方调用,类似的代码改成一个普通的函数也是可以的;
  3. 需要用 cy.once 保证这个代码只会被调用一次,这样其他的测试用例中不会有类似的数据被写入

Suspense & Lazy in React🔗

JavaScript

在用 React 处理业务的过程中,经常会遇到这样的场景:某一个 UI 需要等待网络请求来展示,在等待的过程中,需要显示 Loading 界面,并在请求完成后,显示真正的 UI。这种情况,和按需加载模块的行为非常类似。既然 React.Suspense + React.lazy 可以组合用于按需加载模块时候的 UI 展示,那么是否可以使用同样的组合来完成类似等待网络请求的 UI 显示呢?答案是肯定的。下面给出一个示例代码:

function sleep(time) {
  return new Promise(resolve => setTimeout(resolve, time));
}

const fakeFetch = () => sleep(1000).then(() => "finished!");

const Component = React.lazy(() =>
  fakeFetch().then(text => ({
    default: () => <div>{text}</div>
  }))
);

const App = () => (
  <div className="App">
    <React.Suspense fallback={<div>loading...</div>}>
      <h1>Hello World</h1>
      <Component />
    </React.Suspense>
  </div>
);

如此一来,在 Promise 没有返回的时候,组件会显示 <div>loading...</div>。而等到 Promise resolve 之后,就会显示真正的 UI。

几点说明:

  1. React.lazy 本身是为 import() 设计的,所以在 Promise 返回的时候,需要将组件放到 default 属性下面,保持和 import() 的行为一致;
  2. React.SuspenseReact.lazy 的组合,本质上内部是使用了 throw + componentDidCatch 的方式进行实现的,因而如果不使用 React.lazy,直接在组件内 throw Promise,也可以达到类似的效果:
const fakeFetch = fn => sleep(1000).then(() => fn("finished!"));

let data = "before";
const Component = () => {
  if (data === "before") {
    throw fakeFetch(newData => {
      data = newData;
    });
  }
  return <div>{data}</div>;
};

const App = () => (
  <div className="App">
    <React.Suspense fallback={<div>loading...</div>}>
      <h1>Hello World</h1>
      <Component />
    </React.Suspense>
  </div>
);

Open Window Async🔗

JavaScript

一般情况下,只有当用户有操作的情况下,在一个 tick 里,JavaScript 通过 window.open 或是 <a target="_blank"> HTML 元素直接 click 打开新的窗口才能正常弹出。如果一旦涉及到异步的操作,弹框就会默认被浏览器阻止,无法正常显示。

这样设计的初衷,是为了防止前端随意弹框,影响到用户正常的体验。然而,在某些情况下,用户操作后需要经过网络请求,返回结果后才知道应该如何展示弹框。这种情况下,简单的 fetch().then(() => window.open()) 肯定是不行的。需要一些 Hack 的方案,如下。

在用户进行了操作之后,首先先打开一个新的窗口,等到异步操作返回之后,再通过 JavaScript 修改这个窗口的地址,从而达到异步打开窗口的目的。示例代码如下:

element.onclick = async () => {
  const win = window.open('');
  // 模拟异步操作
  await sleep(5000);
  win.location.href = 'actual location';
};

这样操作可能的问题及解决方法:

  1. 如果在异步的过程中本窗口被关闭了,就会留下一个空白的新窗口。因而,需要监听 beforeunload 事件,以保证必要时候可以关闭新打开的窗口;
  2. 如果异步的时间比较长,打开一个空白的窗口用户体验较差(打开后默认会获得焦点)。这种情况下,可以打开一个静态的页面,展示一个 loading 的 UI 以告诉用户当前正在进行的操作。待异步操作完成,再通过 postMessage 等方式通知窗口进行页面的跳转。

Git Worktree🔗

Git

在实际的开发过程中,经常有多分支并发操作的情况,比如:

  1. PC 软件需要维护多个版本,在新的版本分支上开发新功能,同时维护旧的版本以修复问题;
  2. 针对 Gerrit 这类只允许单 commit 迁入的工具,一个版本开发多个功能,可能需要分成多个分支同时进行

这种情况下,在版本间切换往往有两个常见的方式:

  1. 将当前的代码 stash 后,切换分支,进行对应的处理,处理完了再回到原来的分支 git stash pop 继续原先的开发工作;
  2. 直接 git clone 一个新的仓库,在上面完成必要的工作

第一种方案的问题主要是,切换多次的话,很容易搞不清楚当前分支下还有哪些是 stash 的,管理起来有点麻烦。有时候方便起见,也会直接将当前的内容 commit 到分支上,再进行切换。考虑到 git hook 的存在,commit 可能还需要加上 --no-verify

git add -A && git commit -m "wip" --no-verify

第二种方案的问题主要是,多个文件仓库重复下载了多次 .git 目录,在一些大型项目中,这里会导致大量的硬盘空间被浪费。

Git 在 2.5 版本中提供了 worktree 的功能,用于解决这一痛点。在一个 Git 项目中,只需要执行如下的命令,就可以新创建一个文件仓库:

git worktree add -b new-branch-name /path/to/folder origin/branch/name

新创建的文件仓库被放在 /path/to/folder 中,使用的仓库名称是 new-branch-name,基于 origin 上的 branch/name。如果只需要使用一个已经存在的分支,可以简化成:

git worktree add /path/to/folder local/branch/name

之后,在 /path/to/folder 中就可以进行常规的开发了。值得一提的是,原 Git 目录下的 Hook 文件也会一并同步到新的工作目录下,可以直接使用。通过查看目录下的文件,不难发现 Git 的同步方式。事实上,在 WorkTree 目录下,并没有一个 .git 目录,取而代之的,只有一个 .git 文件,里面标注了真正的 .git 目录应该去那里查找。比如:

gitdir: /path/to/actual/.git/worktrees/name

也正因为如此,WorkTree 下所有的 Git 配置都是同步的。

如果需要查看当前的 Git 中到底有多少个 WorkTree,可以使用下面的命令:

git worktree list

命令会列出所有 WorkTree 的目录以及当前使用的分支名称。

在开发完成后,如果希望删除 WorkTree,可以使用下面的命令:

git worktree remove /path/to/folder

删除完成后,可以通过 git worktree list 来检查是否真的被删除了。

延伸阅读:


Babel JSX Improvement🔗

Build

在 React 的开发中,需要在 Component 的 render 函数或是 Functional Component 的函数中,返回一个定义好的 JSX 内容,用于表示具体需要渲染出来的 UI 样式。Babel 或 TypeScript 会在编译时将这个对象转化成一个 JavaScript 可以理解的一般函数调用(具体调用的函数根据库的不同可能存在差异,对于 React 来说就是 React.createElement 函数,对于 Preact 来说则是 h 函数)。这个函数会在运行时被执行,并返回一个普通的 Object。React 拿到这个 Object 之后,就可以根据其中的内容来渲染出对应的 UI(根据具体执行的环境,这个步骤可能通过 React-DOM 或 React Native 来完成)。

既然 JSX 的部分会被编译成普通的函数调用,并在运行时被反复执行,这里必然会有一些性能上的损耗。

而在实际的开发中,存在着很多的组件,实际需要返回的 JSX 是固定不变的。比如说:

const LoadableButton = ({ loading, ...rest }) => {
  if (loading) return <Loading />;
  return <Button {...rest} />;
}

在上面这个例子中,实际上条件的第一种结果,返回的 JSX 是一个固定的值。手动的优化可以这么写:

const loadingComponent = <Loading />;
const LoadableButton = ({ loading, ...rest }) => {
  if (loading) return loadingComponent;
  return <Button {...rest} />;
}

这样,每次当 loading = true 的时候,都会直接返回 loadingComponent,而不需要反复执行 React.createElement(Loading) 这个函数去拿到最终的返回 Object。除了在运行时减少了重复计算,节省了时间和内存开销(这个在 re-render 非常频繁的时候有一定的优势),另一个好处是,React 可以通过比较返回的结果知道 Object 并没有发生变化,从而直接结束渲染的流程,不再进行接下来更深层次的渲染。

这部分的操作,其实可以交给编译器去完成。Babel 有一个插件 @babel/plugin-transform-react-constant-elements 可以拿来做这方面的优化,具体的使用方式以及可能存在的问题可以参考文档

当然,上面的优化依然存在小的瑕疵:因为把创建 Object 的操作提到了初始化的时候就直接进行了,如果存在大量类似的优化,会导致 JavaScript 初始运行的速度被减慢。大量的 Object 被事先创建了出来,而实际上这部分内容都远还没有到需要用的时候。一个更极致的优化可以这么写:

let loadingComponent;
const LoadableButton = ({ loading, ...rest }) => {
  if (loading) {
    if (!loadingComponent) loadingComponent = <Loading />;
    return loadingComponent;
  }
  return <Button {...rest} />;
}

这部分的操作就不是 Babel 插件原生支持的了。

从目前的实际情况来看,生成 JSX 对应 Object 拖慢初始化的例子暂时还不存在(毕竟 React.createElement 的执行速度并不是非常慢,而且一个项目中的 JSX 数量也不会非常庞大)。如果有必要,可以 GitHub 上提出 PR,按照类似上面提到的方式进行进一步的优化。


compareDocumentPosition🔗

JavaScript

在判断一个 DOM 节点是否包含另一个节点的时候,常常用到 contains 这个 API。在实际的使用从过程中,也经常会遇到这样的情况,需要判断 A 是否包含 B,返回是 false,但经过排查,发现其实 A 和 B 就是同一个节点。这种情况下,光用 contains API 就有点不够用了。同时,也暴露了这个 API 本身能力的局限性。

在 DOM Level 3 的规范中,定义了一个新的 API,compareDocumentPosition。相比于 containscompareDocumentPosition 提供了更强大的判断结果。

compareDocumentPosition 这个 API 比较后会返回一个数字,通过二进制位的比较,可以用于判断两个节点之间的关系。假设调用的函数为 A.compareDocumentPosition(B),那么返回值具体支持的类型如下:

常量名 含义
Node.DOCUMENT_POSITION_DISCONNECTED 1 不在一个文档中
Node.DOCUMENT_POSITION_PRECEDING 2 B 在 A 之前
Node.DOCUMENT_POSITION_FOLLOWING 4 B 在 A 之后
Node.DOCUMENT_POSITION_CONTAINS 8 B 包含 A
Node.DOCUMENT_POSITION_CONTAINED_BY 16 A 包含 B
Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC 32 A 和 B 的位置关系取决于具体的实现方式(不由规范确定)

这里之所以使用二进制位表示位置关系,一个很重要的原因就是:API 有可能会一次性返回多个结果。举个例子,假设 A.contains(B) 返回 true。那么,在调用 A.compareDocumentPosition(B) 的时候,返回值是 20,也就是 Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_CONTAINED_BY 的结果。换句话说,B 元素在文档中的位置在 A 的后面,同时 B 也是 A 的一个子元素。

这里,Node.DOCUMENT_POSITION_DISCONNECTED 表示两个节点不再同一个文档中,有几种可能的情况:

  1. A 和 B 中某一个存在于 iframe 中,因而两者不属于同一个文档(A.ownerDocument !== B.ownerDocument);
  2. A 和 B 中某一个元素被删除了(或没有插入到 DOM 中),导致两者不属于同一个文档(可以通过 A.parentElementB.parentElement 判断是否被删除,被删后就没有父元素了)

另外,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC 有两种情况:

  1. A 和 B 没有任何相同的 container,这种情况和 Node.DOCUMENT_POSITION_DISCONNECTED 是等价的。换句话说,当有 Node.DOCUMENT_POSITION_DISCONNECTED 的时候,一定同时有 Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC
  2. A 和 B 是同一个元素的两个属性值,这种情况下,谁先谁后是由具体实现决定的。比如,Element.attributes 返回一个 NamedNodeMap。根据规范 的定义,NamedNodeMap 不维护一个具体的顺序,但同时提供使用 index 访问的 API。也就是说,Element.attributes 中的任意两个字段,是没有定义上的先后之分的(虽然可能通过不同的下标获取到)。具体来说:
// div = <div id="id" class="class></div>
const attributes = div.attributes;
const result = attributes[0].compareDocumentPosition(attributes[1]);
// result = 36
console.log(result);

这里,compareDocumentPosition 返回的结果是 36,即 Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC | Node.DOCUMENT_POSITION_PRECEDING。因此,在实际使用 API 的时候,有必要检查是否有 Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC 这一位,如果有的话,其他的结果都可以忽略不计了。

另外,如果 A 和 B 是同一个元素,那么返回的结果将是 0,因为 A 和 B 的关系不属于上面列出的任何一种情况。同时,也不难发现,只有当 A 和 B 是同一个元素的时候,才会出现返回值是 0 的情况。


Git Pull Rebase🔗

Git

默认情况下,使用 git pull 拉取最新代码的时候,Git 会触发 git merge 来进行远端代码和本地代码的合并。如果两份代码之间没有冲突,那么 Merge 行为可以进行 Fast Forward,最终的结果是比较“干净”的 Commit;然而,如果 Fast Forward 无法进行,那么最终的显示效果,是 git 的历史中会多出一条 Merge 的 commit。

在绝大多数情况下,这类 Merge commit 都是多余的。这种时候,一般会建议使用 git pull --rebase 命令来拉取代码。这样,拿到最新代码后,Git 会使用 rebase 而不是 merge 来进行远端代码和本地代码的合并(关于 Merge 和 Rebase 的一些讨论,可以参考 Atlassian 的文章)。

当然,每次都这么写会比较繁琐。一个简单的方法,是通过 Shell 进行下面的 Git 配置:

git config --global pull.rebase true

或者,等价的,可以在 ~/.gitconfig 文件中,增加如下的配置信息:

[pull]
  rebase = true

(针对 Git 版本小于 1.7.9 的情况,配置可以参考这里

如此一来,git pull 的默认行为就会从 merge 变成 rebase。

在这种情况下,如果希望使用 merge 的行为,可以写 git pull --no-rebase


Download Chunk via ServiceWorker🔗

JavaScript

现在 Web 端的视频播放,大多采用基于 HLS 或是 MPEG Dash 的方案,将视频内容分解成一系列小型 HTTP 文件片段,每段都包含很短长度的可播放片段,由前端逐个拉取片段并播放,最终形成完整的播放视频。

对于一些云存储网站来说,也可以通过类似的方案来为用户提供下载服务。在分片下载文件的过程中,服务商可以对下载的用户进行校验。同时,由于需要分段下载内容并拼接,避免了单一 URL 造成盗链等问题。然而,一个用户体验的问题是,这种形式的下载如何可以给用户一个更好的用户体验:显然不能将分段的下载内容直接呈现给用户,用户也不应该关心这些分片的内容;如果要等到前端将所有内容下载完成并拼接后再呈现给客户,那么在文件较大的时候会让用户等待很久,用户体验不佳。

这时候,就可以用到 Service Worker 的 Proxy 功能了,可以在前端进行拼接数据的过程中,给用户等同于一般下载文件的体验。

大致的流程代码如下:

首先,需要在 Service Worker 和 Main 线程见建立一个通信机制。比如,可以选择使用 MessageChannel。在 Main 线程创建一个 MessageChannel,然后将 Channel 发送给 Service Worker。之后两者通过这个 Channel 进行数据的沟通(主要是 Main 将下载好的文件片段发送给 Service Worker)。

接着,在 Service Worker 端的 MessageChannel 收到新的数据之后,创建一个 ReadableStream 并将数据写入这个 Stream。

最后,Main 会通过 JavaScript 访问一个不存在的下载链接,里面应该包含一个 ID,用于指明需要的文件具体是哪一个(主要是考虑到多个文件同时下载的情况)。Service Worker 通过 fetch 事件拦截这个请求,并通过 URL 中的 ID 找到对应的 ReadableStream,并将这个 Stream 作为 Response 返回。这样,在浏览器的下载页面就可以看到该文件正在被下载。和原生的下载体验一致,这里也可以看到下载的名称、当前的速度、剩余的时间等信息。

如此,一个完整的流程就走完了。前端下载文件分片,将分片数据发送给 Service Worker,Service Worker 收到数据之后,将数据写入到 ReadableStream 中去;同时,这个 ReadableStream 以 Response 的形式返回给 Main 线程,将这个拼接中的文件逐步下载到本地。

Fetch 事件的代理代码如下:

self.onfetch = function(event) {
  const { url } = event.request;
  // 跳过一般的请求
  if (!isDownloadUrl(url)) return event.respondWith(fetch(event.request));

  const headers = new Headers();
  headers.append('Content-Type', 'application/octet-stream; charset=UTF-8');

  // 获取 URL 中的 ID 数据
  // 相当于 Main 线程通过 URL 传递参数给 Service Worker,用于表示想要下载的具体数据
  const id = getDownloadFileID(url);
  const streamInfo = streamMapping[id];

  if (!streamInfo) {
    // 没有找到数据的情况,返回 404
    return event.respondWith(new Response('Not Found', {
      headers,
      status: 404,
      statusText: 'Not Found'
    }));
  }

  const { filename } = streamInfo;
  // Content-Disposition 中的 filename 必须是 US-ASCII
  // http://tools.ietf.org/html/rfc2183#section-2.3
  const asciiName = filename.replace(/[^\x20-\x7e\xa0-\xff]/g, '?');
  // 通过 filename*=UTF-8''xxx 这样的方式,可以让浏览器使用 UTF-8 的文件名
  const encodedName = encodeURIComponent(filename)
    .replace(/['()]/g, escape)
    .replace(/\*/g, "%2A");
  headers.append(
    'Content-Disposition',
    `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
  );
  headers.append('Content-Length', `${streamInfo.filesize}`);
  headers.append('X-Content-Type-Options', 'nosniff');
  // 将 Service Worker 中的 stream 作为 Response 返回
  // 只要 Stream 没有完结,浏览器的下载行为就会继续,直到 Stream 停止
  return event.respondWith(new Response(streamInfo.stream, {
    headers
  }));
}

关于上面代码的两个延伸阅读:

  1. 虽然 Content-Disposition 默认只能写 ASCII 的文件名,但是 UTF-8 的文件名也是可以设置的。关于 filename*=UTF-8''xxx 这种设置方案,在 StackOverflow 上有相关讨论
  2. X-COntent-Type-Options 设置为 nosniff 可以阻止浏览器的 MIME 类型嗅探,更多讨论可以参考 MDN

创建和使用 Stream 的代码如下:

function getStream() {
  return new Promise((resolve) => {
    const stream = new ReadableStream({
      start: function(controller) {
        resolve(stream);
      },
      pull: function(controller) {
        return new Promise((resolve, reject) => {
          // 当从 Stream 获取数据的时候,返回一个 Promise
          // 并在 onUpdate 赋值,等待 Main 线程的数据
          // 当 Main 线程传递新数据之后,调用这里的 onUpdate 函数,将 data 传入
          // 接下来通过 FileReader 读取数据,转化成 Uint8Array,放入 Stream 中
          // 在清除 onUpdate 函数,等待下一次 Pull
          streamInfo.onUpdate = (data, callback) => {
            const reader = new FileReader();
            reader.onload = (e) => {
              const { target } = e;
              if (streamInfo.stream) {
                controller.enqueue(new Uint8Array(target.result));
                streamInfo.onUpdate = null;
                resolve();
                callback();
              } else {
                reject();
              }
            };
            reader.readAsArrayBuffer(data);
          };
        });
      }
    });
  });
}

从 Main 获取数据并更新给 Stream 的代码如下:

self.onmessage = function (event) {
  const { data, ports } = event;
  const [portA = null, portB = null] = ports;
  const promise = new Promise((resolve) => {
    // 根据消息类型,选择创建一个新的 stream 或是往一个已经创建的 stream 中写入数据
    if (data.type === 'create') {
      getStream().then(stream => {
        streamInfo[data.id] = {
          filesize: data.filesize,
          filename: data.filename,
          stream
        };
        resolve();
      });
    } else if (data.type === 'insert') {
      portB.onmessage = (msg) => {
        const { chunk } = msg.data;
        if (streamInfo[data.id].onUpdate) {
          streamInfo[data.id].onUpdate(chunk, resolve);
        } else {
          // 等待 onUpdate API 创建...
        }
      }
    }
  });
  event.waitUntil(promise);
}

MyAirBridge 网站使用了类似上面提到的技术来下载中的文件内容。Service Worker 的代码参考这里


Save file in Chrome🔗

Chrome

在 Web 环境中,一般对内容的存储都是依托于 Cookie 或是 LocalStorage 进行的(个别会使用 IndexDB)。其实,在早些时候,Web 曾推出过一个 FileSystem 的标准(已经废弃),用于将数据直接存储到本地的沙盒环境中,方便日后的使用。这个 API 目前只有 Chrome 进行了实现。

这篇文章 针对 FileSystem API 做了详细的介绍。这个 GitHub 仓库 则在 FileSystem 原生 API 的基础上,进行了二次封装。(注:第一个链接给出的文章,部分代码有误,可能无法正常运行。实际使用过程中,可以参考第二个链接给出的 GitHub 仓库中的相关代码进行调整)

假设,需要实现一个分片的文件下载功能,即文件被服务器分割成很多块,通过 JavaScript 依次下载这些内容,再在本地拼接后提交给用户。这里,考虑到文件可能非常大,如果只是存储在内存中,一旦用户刷新页面或是遇到其他问题,已经下载的内容就都失效了,只能重新再来一次。这种情况下,可以考虑使用 FileSystem API 将分片的文件内容下载后先存放在本地的沙盒文件中,等到全部下载完成之后,再将拼接好的内容提交给用户。

下面给出一个实例代码,用以介绍 FileSystem API 的可能使用方法:

/**
 * 实际中 Chrome 给出的 API 只用 window.webkitRequestFileSystem
 */
const requestFileSystem = window.requestFileSystem ||
  window.webkitRequestFileSystem;

/**
 * 下载 Link 并保存文件为 filename
 * 只是示例代码,实际的可行方案请参考 file-saver 的实现
 */
function download(link, filename) {
  const a = document.createElement('a');
  a.href = link;
  a.target = '_blank';
  a.download = filename;
  a.click();
}

function save(blob, filename) {
  function errorHandler(e) {
    console.log(e);
  }
  function handler(fs) {
    /**
     * 获取名为 filename 的文件,{ create: true } 表示如果文件不存在,就创建一个
     * fileEntry 中包含的 API 可以用于对这个文件进行操作
     */
    fs.root.getFile(filename, { create: true }, (fileEntry) => {
      fileEntry.createWriter(writer => {
        /**
         * 指定文件的写入位置在当前文件内容的末尾
         */
        writer.seek(writer.length);
        writer.onwriteend = () => {
          /**
           * FileSystem 中的文件,可以通过类似如下的 Link 获取到:
           * filesystem:https://xxx.com/persistent/filename
           * 具体的 URL 地址通过 `fileEntry.toURL()` 获取
           */
          const url = fileEntry.toURL();
          download(url, filename);
        };
        writer.onerror = console.error;
        writer.write(blob);
      }, errorHandler);
    }, errorHandler);
  }
  /**
   * 对于 PERSISTENT 存储的文件,需要事先通过浏览器询问权限
   * 声明需要使用的大小为 blob.size
   * 第二个参数是 success callback,在成功后调用,可以在这里进行文件读写
   * 第三个参数是 error callback,用于处理报错
   */
  navigator.webkitPersistentStorage.requestQuota(
    blob.size,
    grantedBytes => {
      /**
       * 以 PERSISTENT 的方式,写入 grantedBytes 这么多的内容
       * 允许写入会执行 handler,否则会执行 errorHandler
       */
      requestFileSystem(window.PERSISTENT, grantedBytes, handler, errorHandler);
    },
    console.error
  );
}

/**
 * 示例代码的调用,将 hello world 写入到 output.txt 文件中
 */
save(new Blob(['hello world'], { type: 'text/plain' }), 'output.txt')

上面这个例子,展示了如何将 Blob / File 写入到本地沙盒的文件中(例子中写入到了 output.txt 文件内)。有几点需要注意:

  1. 文件是写入到沙盒环境中的,因而虽然 fileEntry.fullPath 的值是 /output.txt,并不代表真的可以在根目录下找到 output.txt 文件
  2. window.PERSISTENTwindow.TEMPORARY 是两种可能的存储方式。如果是 PERSISTENT 的,那么需要用户授权(也就是 requestQuota 做的事情)且清理需要程序或用户手动执行;如果是 TEMPORARY 类型的存储方式,那么浏览器可能会在某些情况下自动清理文件(比如,当空间不够的时候)
  3. 通过 fileEntry.toURL API 可以拿到当前文件存储对应的 URL 地址,进而可以通过常规手段将这个内容下载到本地
  4. 代码中的 errorHandler 函数写的比较粗糙,更丰富的 Error Handler 写法,可以参考 chromestore.js 中的代码

MyAirBridge 网站可能使用了类似上面提到的技术来存储下载中的文件内容。


BDO Element🔗

HTML

针对某些语言(如阿拉伯文字希伯来文字),文字的排列顺序是从右往左,而不是一般的从左往右排列。因此,如果有两种语言同时出现在一个文档中,那么就有可能一个段落,同时有从左往右书写的文字以及从右往左书写的文字。这种两类文字混排的情况,就是双向文稿(Bidirectional Text)。

针对这种情况,HTML 提供了一个特殊的元素 bdo,用于处理文档中特殊的文字流排列。这里 bdo 的全称是 Bidirectional Text Override。举例如下:

<p>
  This is Text Left to Right
  <bdo dir="rtl" style="color:red">Right to Left part</bdo>.
  Rest of the world.
</p>

展示效果如下:

This is Text Left to Right Right to Left part. Rest of the world.

dir 这个属性,可以用于设置当前文字应该显示的方向,可用的属性包括 ltrrtlauto 三种。需要注意的是,dir 这个参数,是针对 bdo 元素的,如果写在其他元素上,并不能起到同样的效果。比如,<span dir="rtl">Hi</span> 并不会让 Hi 显示为 iH

更多内容,可以参考 MDN


Clipboard Event🔗

JavaScript

JavaScript 提供了 copy 事件,可以针对一般的复制动作进行一些额外的操作。比如,一些网站出于版本的考虑,可能会禁止拷贝;或者,一些网站允许拷贝,但是会希望在拷贝的内容后面加上一些版权的声明。这些操作,都可以通过 copy 事件进行处理。

举例来说,如果希望禁止网站上内容的拷贝,可以写:

window.addEventListener('copy', (event) => {
  event.preventDefault();
});

在上面的代码中,通过 event.preventDefault API 的调用,可以组织浏览器默认的复制行为。这样,即使用户进行了复制,实际上剪贴板也不会被更新。

第二个例子,假设希望在复制的内容后面加上额外的数据,可以写:

window.addEventListener('copy', (event) => {
  const selection = document.getSelection();
  const text = selection.toString();
  event.clipboardData.setData('text/plain', text + '\nExtra Text at Bottom');
  event.preventDefault();
});

需要注意几点:

  1. setData 的第一个参数是数据的格式,支持的类型可以参考 MDN,主要就是 MIME 类型,一般纯文本可以使用 text/plain
  2. 在调用 setData 之后,需要调用 event.preventDefault 才能保证设置成功,否则最终复制出来的依然是原始的文案

最后,需要说明的一点是:出于测试或者其他目的,JavaScript 也支持创建一个 ClipboardEvent 并发送给监听的元素,如:

window.addEventListener('copy', (event) => {
  console.log('copy event triggered');
  event.clipboardData.setData('text/plain', 'hello world');
  event.preventDefault();
});
const event = new ClipboardEvent('copy', { clipboardData: new DataTransfer() });
window.dispatchEvent(event);

那么,回调函数可以正常执行(Console 可以看到输出),但是 event 内尝试设置 Clipboard 的数据并不会成功。

总体上来说,浏览器端提供的 Copy 事件,只能获取/修改浏览器内发生的剪贴板复制操作;对于用户本身剪贴板操作内有的数据是无法读取的,在非用户触发的情况下,剪贴板的数据也是无法直接被修改的。

延伸阅读


Cypress Upload File🔗

Cypress

Cypress 没有提供原生的上传文件支持,如果需要在 E2E 测试中进行文件上传的测试工作,最简单的方式就是自己写一个自定义的 Command。参考代码如下:

Cypress.Commands.add(
  'uploadFile',
  { prevSubject: true },
  (subject, fixtureFileName, mimeType = '') => {
    return cy.fixture(fixtureFileName, 'base64')
      .then(Cypress.Blob.base64StringToBlob)
      .then(blob => {
          const el = subject[0];
          const nameSegments = fixtureFileName.split('/');
          const name = nameSegments[nameSegments.length - 1];
          const testFile = new File([blob], name, { type: mimeType });
          const dataTransfer = new DataTransfer();
          dataTransfer.items.add(testFile);

          const setter =
            Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'files')
              .set;
          setter.call(el, dataTransfer.files);
        
          const event = new Event('change', { bubbles: true });
          el.dispatchEvent(event);
          return subject;
      });
  }
);

代码的解释如下:

cy.fixture(fixtureFileName, 'base64')

fixture 是 Cypress 提供的原生 API,可以读取 cypress/fixture 目录下的指定文件(文件名为 fixtureFileName)。上面这个 API 指定了文件需要以 Base64 的方式读取出来。

Cypress.Blob.base64StringToBlob 这一步顾名思义,就是将 Base64 字符串转化成对应的 Blob 类型。

剩下的代码,就是用 JavaScript 的方式模拟一个文件上传事件。其中,需要先将文件从 Blob 转换成 File(这里涉及到可能的 mime type 检查);然后,创建一个 DataTransfer 对象,把文件放进去,再赋值给 input(这里需要说明的是,React 组件会对 input 的属性做一层 proxy,因此直接使用 input.files = dataTransfer.files 这样的写法,调用的是 React 的方法而不是真正 DOM 的方法。按上面代码中的方法获取到真正的 setter,然后调用可以绕过去)。最后,在创建一个 Change 事件,传递给 input 组件,触发即可。

当然,简单起见,可以直接使用现成的库:cypress-file-upload。GitHub 地址见这里


cypress reporter🔗

Cypress

Cypress 默认提供了 spec reporter,在 CLI 运行的时候,会将结果输出到 stdout 中。同时,如果使用编程的方法直接调用 Cypress.run API,会以 Promise 的方式将运行的结果返回,程序可以从运行结果中,将主要的运行数据给读取出来。然而,不论是哪一种方案,都不能非常直观的将运行结果展示出来。以下介绍如何在 Cypress 中引入 Mochawesome reporter,用于生成直观的 HTML 报告。

需要事先说明的是,虽然 Cypress 是建立在 Mocha 的基础上,且 Mochawesome 是 Mocha 中非常流行的报告生成方案,但是直接使用 Mochawesome 在 Cypress 中生成报表还是有问题的。主要的原因在于,Cypress 调整了测试的行为,自 3.0 版本开始,每一个测试用例(spec)都是单独运行的。因此,原生的 Mochawesome 无法直接生成一个包含所有测试用例的完整报告。为此,需要借助一些额外的工具。

首先,在项目需要用到 mochamochawesome

yarn add mocha mochawesome

另外需要两个额外的包,分别是 mochawesome-mergemochawesome-report-generator。可以通过 yarn 或 npm 安装到工作目录中,也可以通过 npx 在需要的时候直接使用。这里,mochawesome-merge 将用于将所有的测试用例运行结果进行合并的,然后用 mochawesome-report-generator 包生成统一的完整报告。

接下来,修改 cypress.json 配置文件如下:

{
  "reporter": "mochawesome",
  "reporterOptions": {
    "reportDir": "cypress/results",
    "overwrite": false,
    "html": false,
    "json": true
  }
}

配置完成,再运行 Cypress,会在 cypress/results 目录下生成一批 JSON 文件(如 mochawesome.jsonmochawesome_001.json,……)。

有了这批生成的 JSON 报告,就可以使用 mochawesome-merge 命令,将这些 JSON 文件打包成一个完整的 JSON 报告。CLI 命令如下:

npx mochawesome-merge --reportDir cypress/results > mochawesome.json

生成了完整的 JSON 文件之后,可以通过 mochawesome-report-generator 生成需要的 HTML 报告:

npx mochawesome-report-generator mochawesome.json

当然,如果需要以编程的方式来执行上面的生成报告过程,可以参考下面的代码:

const cypress = require('cypress');
const { merge } = require('mochawesome-merge');
const generator = require('mochawesome-report-generator');

async function generate() {
  await cypress.run(config)
  const report = await merge({ reportDir: 'cypress/results' });
  const htmlReports = await generator.create(report, {
    reportFilename: 'report.html',
    // cdn 的命令可以在生成 HTML 报告的时候不额外生成 JavaScript/CSS 文件
    // 这些静态文件会走 CDN (unpkg)
    // 这样,只需要保存一个 HTML 文件就可以了,方便存储
    cdn: true
  });
  // report 就是 HTML 报告文件生成的路径
  const [report] = htmlReports;
}

generate();

更多的参数使用可以参考项目的源代码。

需要注意的一点是,生成 Report 之前需要确认 cypress/results 目录是否是干净的空目录。如果目录中仍然包含上一次运行的结果,那么最终合并报告的时候,两次运行的结果会叠加在一起,最终导致报告中包含多次运行的内容。一般在 Docker 中运行的话不会有这个问题,但是在本地跑的时候需要注意清理工作。


Copy out of Docker🔗

Docker

Docker 的运行环境因为一般只安装了运行程序需要的最小依赖集,因而存在各种限制。有时候需要查看日志,或是分析一些数据,直接在 Docker 中查看日志文件并不是非常方便。

这个时候,可以通过 docker cp 命令将文件从 Docker 中拷贝出来,在外部环境中通过合适的工具直接分析。

docker cp 的使用步骤如下:

  1. docker container ls 查看当前正在运行的 container,找到其中的 Container ID
  2. 运行 docker cp <containerId>:/path/in/container /host/path 将数据拷贝出来

URLSearchParams🔗

JavaScript

前端项目,总免不了写一些操作 URL 中 query string 的 API 代码,比如读取当前 URL 中的 query 数据,或是根据一个 Object 对象拼接出一个 query string,等等。

其实,现代浏览器中已经提供了 URLSearchParams 类,可以大大简化这部分的操作,也无需再自己维护一个 qs 或是类似的包了。

以下介绍如何通过 URLSearchParams 实现 qs.stringifyqs.parse API 的方法:

qs.parse 的方法比较简单,只需要将字符串传递给 URLSearchParams 并创建实例就可以了,实例本身自带了 iterator,也提供 getkeys 等 API 能很方便的获取需要的数据。

const searchParams = new URLSearchParams(location.search);
for (const param of searchParams) {
  const [key, value] = param;
  console.log(key, value)
}

需要注意的一点是,不论参数传入的字符串是否以 ? 字符开头,URLSearchParams 都默认可以正确处理,不需要像 qs 包一样显示的指明给定的字符串是否有 ? 开头(ignoreQueryPrefix)。

要实现 qs.stringify 的功能也不难,URLSearchParams 的构造器支持传入一个数组或一个对象,也提供了 append API 可以将 key value 一组一组的加入到对象中,最后只要使用 toString 拼接出一个完整的字符串就可以了:

// result in: `a=b`
(new URLSearchParams({ a: 'b' })).toString();
// result in: `c=d&e=f`
(new URLSearchParams([ ['c', 'd'], ['e', 'f'] ])).toString();

注意,toString 方法得到的字符串,最开头并没有带上 ? 字符,如果有需要的话,可以自行加上。

综上,下面的等式是成立的(假定 location.search 不是一个空字符串):

location.search === `?${(new URLSearchParams(location.search)).toString()}`

当然,URLSearchParams 在某些场景下还是不能替换 qs 之类的库,比如:

  1. 项目需要支持老浏览器,如 IE 时。URLSearchParams 的浏览器支持情况见:CanIUse,总体来说,现代的浏览器都已经支持了,但是 IE 完全没有。
  2. 需要使用一些比较冷门的解析功能时,如 qs 提供了很多复杂的解析方案(详情见文档

但总体来说,绝大部分的应用场景下,URLSearchParams 都可以轻松应对,不需要额外的库进行志愿了。

更多关于 URLSearchParams 的介绍,可以参考 MDNEasy URL Manipulation with URLSearchParamsWHATWG Spec


CSS Attribute Selector🔗

CSS

CSS 中有一些属性选择器,不常见,但是偶尔有一些小众的需求,实现起来会很方便。特别是在进行 Cypress 开发的时候,直接使用 JavaScript 查找元素比较困难,但是有了这些属性选择器,就可以很方便的通过 jQuery 的 API 进行元素的定位了。

属性选择器和一些可能的应用场景,列举如下:

attr

表示带有以 attr 命名的属性元素。这个选择器不关心属性具体的值,只要有,就会被选中。一些常见的应用场景包括:

  • 选择一些没有值的属性,比如 <input disabled /> 可以通过 input[disabled] 进行选择;
  • 选择一些带有属性的元素,属性具体的值并不关心。这种情况中,带有某种属性往往表示这类元素同属于一个类型组件,如一组列表中的每个元素,都会有一个子节点上带有 title 属性以显示 tooltip,此时就可以通过类似 ul li [title] 的方式,将这些文字都选出来,或是进行进一步的选择。

attr=value

表示带有以 attr 命名的属性,并且该属性的值是 value。这个的应用场景比较常见,一般的属性选择都会使用这个方案。值得注意的一点是,由于 CSS 选择器权重的关系,以下两个 CSS 定义是有不同优先级的:

#id {
  color: red;
}
[id=id] {
  color: blue;
}

最终的元素 <p id="id">Hello World</p> 显示颜色是红色,而不是蓝色。因为属性选择器的优先级比 id 选择器要低,即使两者表达的意思是一样的。

attr^=prefix

这个选择器可以将所有以 prefix 开头的 attr 属性所在的元素都选出来。^ 表示开头,这一点和正则表达式中的表述语义是类似的。可以设想这样一个应用场景:

在某个页面上,可能要根据一组数据显示对应的表单数据。因为每个表单中的输入项都需要一个 label + input 的组合,因而每个 input 可能需要给一个独一无二的 ID(方便 label 上加上 for 以绑定两者)。这时候,一个简单的做法,是给每一个表单中固定的输入项,取一个固定的前缀,再加上这个数据本身的 id 值,最终生成一个独一无二的 ID,防止重复。比如,数据 { id: 1, name: 'John' } 生成的名字 input 可能为:<input id="user-name-1" />

针对这种情况,如果希望一次性选出所有这些 input,就可以使用属性选择器:[id^=user-name]

attr|=prefix

上面的这个例子,也可以用这个属性选择器来进行改写:[id|=user-name]。两者都可以定义属性的前缀用于查找元素,但是区别在于,|= 的选择器规定的前缀之后一定跟着一个 - 字符。因此,[id|=user-name] 可以选出 <input id="user-name-1" /> 但是不能选出 <input id="user-name_1" />。这一点是和上面这个选择器最大的不同。当然,这个选择器最大的应用场景其实还是在选择 lang 上,比如将当前页面中所有英文的部分选择出来:[lang|=en],此时,无论是 <p lang="en-US">Color</p> 还是 <p lang="en-GB">Colour</p> 都可以被正确的选择出来。

attr$=suffix

这个选择器可以将所有以 suffix 结尾的 attr 属性所在的元素都选出来。$ 表示结尾,这一点和正则表达式中的表述语义是类似的。一个可能的例子是:在 Ant Design 中,Icon 组件会根据当前网页的语言,显示 aria-label="icon: right"aria-label="图标: right"。如果要根据当前选择的语言去分别创建选择器,会有一些麻烦,这时候可以考虑直接使用 [aria-label$=right] 来进行选择。

attr*=keyword

这个选择器可以将所有 attr 中带有 keyword 字段的元素都选出来。暂时没有遇到什么实际的应用场景,但是可以考虑用作属性的文案检查器。比如,原先的产品名字叫 AAA,但是后期业务调整,名字改成了 BBB,那么下面的 CSS 就可以将所有还没有改过来的元素都标注出来:

[class*=AAA], [aria-label*=AAA] {
  color: red;
}

参考文档

MDN


Eval Script via Nodejs🔗

Node.js

在编写 Bash 脚本的过程中,难免会遇到一些单纯用 Linux 命令很难实现的功能,比如,希望将一个文件的内容作为 JSON 的一个字段,并通过 curl 将这个 JSON 数据发送给服务器。如果单纯使用 Linux 的命令来拼接这个 JSON 字符串,在转意上会遇到很大的问题。但其实同样的需求,在 JavaScript 中可以通过一行命令完成:

JSON.stringify({ text: fs.readFileSync(filepath, 'utf8') })

对于这样的情况,如果正好环境中有 Node.js(比如基于 Node 的 Docker 环境),就可以很方便的通过直接调用 Node 来处理这部分的需求。

需要用到 node.js 内建的参数 -e-p

对于 -e 这个参数(或 --eval),Node 会执行参数后面的字符串。类似于 node file.js 可以让 Node.js 执行 file.js 这个文件,node -e "script" 可以让 Node.js 执行 script 这串语句。需要注意的几点:

  1. Windows 的 CMD 不能使用单引号,只能使用双引号;Powershell 的的话,两者都是支持的;
  2. -e 指令并不会将执行的结果输出到终端,因而如果需要 Bash 中能够用变量保存运行结果的话,需要额外使用 console.log 将结果输出到 stdout,然后再由 Bash 传递给需要赋值的变量。

综上所述,上面这个 JSON 序列化的需求,就可以写成下面的这种形式:

node -e "console.log(JSON.stringify({ text: fs.readFileSync(file, 'utf8') }))"

显然,对于需要赋值的情况,每次都加上 console.log 去输出结果比较的繁琐。Node.js 为此提供了另外一个可用的参数 -p。这个参数的表现形式和 -e 几乎是一样的,唯一的区别是,-p 会将结果默认输出到 stdout 中,不需要额外套一层 console.log 来完成这个操作。-p 也可以写作 --print

下面用一个例子来说明两者的区别:

echo $(node -e "true")

上面这个命令运行的结果将输出空字符串;而下面这个命令,则会在终端输出 true

echo $(node -p "true")

如此一来,上面的例子可以进一步转化为:

node -p "JSON.stringify({ text: fs.readFileSync(file, 'utf8') })"

配合 curl 最终的命令大体上如下:

curl -X POST \
  -H "Content-Type: application/json" \
  $URL \
  --data-binary \
    "$(node -p 'JSON.stringify({text:fs.readFileSync("filepath","utf8")})')"

ESlint for TypeScript🔗

Tool

根据 TSLint 官方给出的建议,TypeScript 项目的代码,现在也推荐使用 ESLint 来进行代码规范的检查。

在具体使用的时候,需要在 .eslintrc 中增加如下部分的设置:

{
  "parser": "@typescript-eslint/parser",
  "plugins": [
    "@typescript-eslint"
  ],
  "parserOptions": {
    "ecmaVersion": 6,
    "sourceType": "module",
    "project": "./tsconfig.json",
    "ecmaFeatures": {
      "jsx": true
    }
  },
}

其中,需要用到 typescript-eslint 这个库来帮助 ESLint 解析代码(GitHub)。

然而,在实际使用 ESLint 来检查 TypeScript 代码的时候,需要额外注意一个细节:

根据这里给出的解释,ESLint 默认情况下并不会检查非 .js.jsx 结尾的文件。也就是说,如果 src 目录下有以下几个文件:src/index.jssrc/app.tsxsrc/utils.ts,那么在运行命令 eslint src 的时候,只有 src/index.js 会被检查,剩下的两个会直接被忽略。

需要检查 TypeScript 的代码,需要手动在调用 eslint 命令行的时候加上 --ext 参数,如:--ext js,jsx,ts,tsx

当然,如果给 ESLint 手动指定需要检查的文件,或是通过 lint-staged 工具在 Git 签入的时候对 TypeScript 文件进行检查,这些情况下都是不需要额外指定 --ext 参数的,ESLint 可以正确处理 TypeScript 的代码。