Things I Learned (2019-12)

Function toString🔗

• JavaScript

众所周知,Sentry 在运行的时候,会改写原生的 console API,用于记录上下文相关的一些信息。然而,在一个有 Sentry 的页面上输入 console.log 并回车,会看到输出的内容是:

ƒ log() { [native code] }

但是如果真的输入 console.log('hello world') 执行一下,又会看到输出的文件来自于 breadcrumbs.ts 而不是常见的 VMxxx。

这里,Sentry 确实重写了 console 的 API,而之所以会输出 ƒ log() { [native code] } 是因为 Sentry 通过改写 Function.prototype.toString 函数,再一次改写了输出结果,从而达到了迷惑的作用(有些代码会通过判断 toString 是否包含 native code 来判断当前的 API 是否被改写了)。

具体的代码可以在这里找到,大体如下:

originalFunctionToString = Function.prototype.toString;

Function.prototype.toString =
  function(this: WrappedFunction, ...args: any[]): string {
    const context = this.__sentry_original__ || this;
    // tslint:disable-next-line:no-unsafe-any
    return originalFunctionToString.apply(context, args);
  };

如果需要判断当前的 console.log 是否被改写了,针对 Sentry 的话只需要判断 console.log__sentry_original__ 是否存在就可以了。或者,看一下 console.log.toString.toString() 的值也是可以的,因为 Sentry 并没有对 Function.prototype.toString 也做一样的 toString 改写。

如果希望可以做更好的隐藏,那么可以考虑把 Function.prototype.toString 也改写掉:

function wrap(obj, api, f) {
  const original = obj[api];
  obj[api] = f;
  obj[api].__wrapped__ = true;
  obj[api].__wrapped_original__ = original;
}

wrap(Function.prototype, 'toString', function (...args) {
  const context = this.__wrapped__ ? this.__wrapped_original__ : this;
  return Function.prototype.toString.__wrapped_original__.apply(context, args);
});
wrap(console, 'log', function (...args) {
  return console.log.__wrapped_original__.apply(this, ['extra: '].concat(args));
});

Performance Memory🔗

• JavaScript

Chrome 浏览器在 performance 对象上加上了 memory 属性,通过获取 performance.memory 可以得到一组当前页面使用内存数据的信息。具体如下:

  • jsHeapSizeLimit:表示当前页面最多可以获得的 JavaScript 堆大小;
  • totalJSHeapSize:表示当前页面已经分配的 JavaScript 堆大小;
  • usedJsHeapSize:表示当前页面 JavaScript 已经使用的堆大小。

这里,三个值的单位是字节(byte),且有恒定的不等式:jsHeapSizeLimit >= totalJSHeapSize >= usedJsHeapSize。

Chrome 在分配内存的时候,会一次性向系统申请一块内存,然后在 JavaScript 需要的时候直接提供使用,因而 usedJSHeapSize 总是大于 usedJsHeapSize 的。如果 JavaScript 需要的内存多于已经申请的量,就会继续申请一块,直到达到 jsHeapSizeLimit 的上限,触发页面崩溃。注:根据之前 Gmail 团队的分享,Chrome 的进程模型,在浏览器打开非常多 Tab 的时候,会出现多个 Tab 共享一个进程的情况。因此,如果共享的几个页面中有一个内存大户,可能会导致一批 Tab 全部崩溃。

通过观察 jsHeapSizeLimit 和 totalJSHeapSize 这两个字段,可以用于监控当前的页面是否有耗尽内存的危险;同时,如果内存一直在涨,不见回落,很可能需要排查是否有潜在的内存泄漏危险。

需要注意的几点:

  1. 出于安全方面的考虑,API 并不会给出非常准确的数据,并且给出的数据会额外加上一些干扰(参考这个 Proposal,以及这个改动);
  2. 这不是一个标准的 API,目前只有 Chrome / Opera 可以使用(参考 caniuse);
  3. 安全问题是这个 API 没有被广泛实施的原因,详情可以参考这里的讨论;Proposal 也因为安全问题不好解决(参考这里给出的解释)而暂停了;
  4. 如果需要 Chrome 给出精确的内存数据,可以在启动的时候加上 --enable-precise-memory-info;

MacOS 可以通过如下的命令启动 Chrome:

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --enable-precise-memory-info
  1. performance.memory 无法通过 JSON.stringify 获取到数据(结果是 {}),一些分析和解决办法可以参考这篇文章。

require.main🔗

• Node.js

在 Python 中,可以通过如下的代码来判断,当前的文件是否是入口文件:

if __name__ == "__main__":
  print("entry file")

Node.js 中也可以写类似的判断,上面的 Python 代码等价于:

if (require.main === module) {
  console.log('entry file');
}

这样,当程序是作为入口文件被运行的时候,可以在 if 语句内直接运行业务代码;而如果这个文件是作为 API 被别的文件加载的,那么就只会暴露 API 接口,运行的部分交给使用者自行完成。

几点说明:

  1. 这里,require.main 是一个 Module(也就是 module 的类型,等价于 module.constructor)。而 module 则是 Node.js 在加载 JavaScript 文件的时候提供的,参考 Module.prototype._compile 函数调用的 wrapSafe 函数(见这里);
  2. 对于 Node.js 来说,这里的 require.main 就是命令行加载的文件,比如运行 node xxx.js 命令,那么 xxx.js 生成的 Module 就是这里的 require.main;而对于 Electron 来说,每一个 render 进程加载的 HTML 文件就是对应的 require.main 模块;
  3. 从 Node.js 代码可知,这里的 require.main 等价于 process.mainModule(见这里)。

Get Package Size🔗

• Build

在 npm 中,可以通过 npm publish --dry-run 来“试运行”一次发布命令。不会真的将当前的内容发布到 npm 上,但是会执行完所有的步骤,并完整的列出会发布的文件以及文件对应的大小。通过这个 CLI 命令,可以直观的看到当前的 npm 包占用的体积。

如果希望可以通过 JavaScript API 的方式直接获取,可以考虑如下的方法:

const packlist = require('npm-packlist');
const tar = require('tar');
const cacache = require('cacache');
const rimraf = require('rimraf');

const tmpFolder = '.tmp';

async function getPackedSize(packagePath) {
  const files = await packlist({ path: packagePath });
  const folder = await cacache.tmp.mkdir(tmpFolder);
  const tmpTar = path.join(folder, 'package.tgz');
  await tar.create({
    prefix: 'package/',
    cwd: packagePath,
    file: tmpTar,
    gzip: true
  }, files);
  const { size } = fs.statSync(tmpTar);
  rimraf.sync(tmpFolder);
  return size;
}

上面的代码展示了如何获取发布包压缩后的代码,几点说明:

  • npm-packlist 这个包是专门用来分析需要发布的文件列表的,文档在这里;
  • cacache 这个包可以创建临时的目录,用于存放临时生成的 tar 文件,文档在这里;
  • tar 这个包可以用于将所有指定的文件都打包到 tar 中,文档在这里;
  • rimraf 这个包可以用于将指定的目录删除,这里用于清理不需要的临时目录,文档在这里。

以上的代码参考了 npm 的流程操作,见 npm cli 中的 packDirectory 代码。

如果需要计算非压缩的体积,原理也是类似的。不同点在于,通过 npm-packlist 取得文件列表之后,直接依次将每个文件的大小通过 fs.statSync 计算出来,然后加起来就可以了。不需要额外生成辅助的临时文件。


npmignore .ts but keep .d.ts🔗

• TypeScript

在某些情况下,可能希望将 .ts 文件从 npm 打包中去除(因为不会使用到未编译的代码),但是却希望保留 .d.ts 文件用于帮助使用者获得更好的类型判断。

因为 .npmignore 支持 glob 的语法,因而可以写类似如下的代码来满足需求:

# ignore the .ts files
*.ts

# include the .d.ts files
!*.d.ts

这里 ! 表示“不包含”,同时因为 .npmignore 文件的含义是定义不打包的文件,因此“负负得正”,这些文件最终会被保留到 npm 的包产物中。


__PURE__🔗

• Build

在代码压缩的时候,由于压缩工具很难判断 JavaScript 代码的副作用,因此可能会将某些不需要使用的代码保留下来。针对 uglifyjs 或者 terserjs,可以通过 /*@__PURE__*/ 或者 /*#__PURE__*/ 这样的标签来显式声明定义是不包含副作用的。压缩工具在获取到这个信息之后,就可以放心的将未被使用的定义代码直接删除了。

相关的文档,可以参考 uglifyjs 或 terserjs。

或者,可以在 terser online 中尝试如下代码,观察编译结果的区别:

(function () {
  const unused = window.unknown();
  const used = 'used';

  console.log(used);
}());

上面的代码中,因为 window.unknown() 这个函数的调用细节对 terser 是不透明的,压缩工具无法判明使用是否会存在副作用。虽然 unused 这个变量没有被使用到,但是为了避免副作用丢失,terser 只能将 window.unknown() 调用保留下来。最终生成的压缩代码为:

!function(){window.unknown();console.log("used")}();

而如果将代码加上显式声明:

(function () {
  const unused = /*#__PURE__*/ window.unknown();
  const used = 'used';

  console.log(used);
}());

那么,terser 就可以放心的将整个调用删除。最终的压缩结果为:

console.log("used");

Require CSS in Electron🔗

• Node.js

在实际开发过程中,可能会遇到在 Electron 项目中需要引用组件库的情况。因为组件库往往除了 JavaScript 文件之外,还连带有 CSS 文件,因此一般即便有 lib 文件的输出(也就是 JavaScript 经过了预编译,在 Node.js 环境下可以直接运行),也没法直接使用。这里 CSS 文件一般通过 import 'styles.css'; 这样的语法引入,编译后会变成 require('styles.css')。由于 Node.js 不支持直接 require CSS 文件,因此 lib 在 Electron 下是没法直接运行的。

针对这种情况,常规的做法是通过 Webpack 将组件库打包到最终的产物中,通过 css-loader 和 mini-css-extract-plugin 消化 CSS 产物,最终在 Electron / Web 环境下运行。

然而,这样的做法可能面临几个问题:

  1. 如果 Electron 的不同 Webview 需要组件库的不同部分(不完全重叠),那么实践上最方便的做法只能是分别打包。相当于同一份代码被复制了多份,存在于各自的打包产物中,造成了包体积的浪费;
  2. 上面的做法其实是 Web 的,Electron 环境的优势(包含 Node.js)并没有体现出来。

因为 Node.js 在进行文件载入(require)的时候,提供了扩展能力。因此,只需要做如下的代码增强,在 Electron 项目中也可以直接 require CSS 文件了:

const fs = require('fs');
// 当 Node.js 需要 require 任意后缀是 .css 的文件时,就会执行这个自定义的回调
require.extensions['.css'] = function (module, filename) {
  const css = fs.readFileSync(filename, 'utf8');
  /**
   * 这里让 Electron 支持 require CSS 的思路非常简单,类似于 Webpack 中的 style-loader:
   * 首先创建一个 <style> 标签;
   * 然后将 CSS 文件的内容读取出来;
   * 将 CSS 的内容插入到 <style> 中并最终插入到 <head> 里面;
   * 剩下的渲染工作就交给浏览器了。
   */
  const js = [
    `const css = ${JSON.stringify(css)};`,
    /**
     * 将引用文件的路径作为 id
     * 用于确保同一个 CSS 文件不会因为多次 require 而被重复插入
     */
    `const id = ${JSON.stringify(filename)};`,
    'if (document.head.getElementById(id)) return;',
    'const style = document.createElement("style")',
    'style.id = id;',
    'style.textContent = css;',
    'document.head.appendChild(style);',
  ].join('');
  return module._compile(js, filename);
}

在上述代码中,参考了 style-loader 的思路,实现了一个简单的从 CSS 转化到 JavaScript 代码的操作。剩下的编译工作交给原来 Node.js 的流程去做就可以了(module._compile 部分的代码)。


exports-loader🔗

• Build

exports loader 是 Webpack 官方提供的一个 loader 工具,主要目的是转化已有的 JavaScript 库文件,方便以 import / require 的形式引入到代码中。

在实际的开发过程中,有时会遇到这样的情况:

  • 需要使用的库没有提供 npm 的包,只提供了一个可执行的 JavaScript 文件(比如 FoamTree);
  • 或者为了避免重复的编译工作,希望可以直接用 npm 包中现成的打包后产物。比如 @sentry/browser 使用中除了常规的 dist/index.js 及一众文件外,build/bundle.min.js 提供了打包后的结果。因为打包不涉及对库文件的修改,直接使用打包后的产物显然会更快一些(类似 Webpack DLL 的操作)。

然而,上面的情况都有一个共性的问题:这些打包的产物本意是给 Web 直接引用的。因此,“导出”的方式是在 window 上直接绑定对象,而不是通过 module.exports 或其他类似方式导出。比如,FoamTree 就会在 window 上绑定一个 CarrotSearchFoamTree 对象。

exports loader 就是为了这种情况设计的。

官方的文档可以查看这里。简单来说,通过下面的方式,就可以将 FoamTree 引入了:

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /carrotsearch\.foamtree/u,
        loader: 'exports-loader?CarrotSearchFoamTree'
      },
      // ...
    ],
  },
  resolve: {
    modules: [
      // foamtree 的文件存放在 vendor 目录下,使用时效果等价于 node_modules
      // 但因为 foamtree 本身不提供 npm 包,因此这里只能手动存放,再配置查找路径
      'path-to-vendor-folder',
      'node_modules'
    ],
    // ...
  },
}

配置完成后,项目中,可以直接这么使用:

import FoamTree from 'carrotsearch.foamtree';

exports loader 的操作,就是在原先 FoamTree 的最后,加上一句:

module.exports = CarrotSearchFoamTree;

这里 CarrotSearchFoamTree 是 FoamTree 文件在 window 注入的对象。如果能导出多个变量,也可以进行配置:

require('exports-loader?file,parse=helpers.parse!./file.js');
// 添加如下代码:
//  exports['file'] = file;
//  exports['parse'] = helpers.parse;

Webpack External with Path Transform🔗

• Build

在实际开发的过程中,可能会遇到这样的 Webpack 打包场景:某一个目录内的文件不需要被打包,而是通过 require 的形式在运行时单独加载。对于一般的包来说,只需要简单的配置 Webpack 的 externals 字段就可以了;但是如果引用的位置是一个相对路径,那么在配置 externals 的时候就会相对复杂一些。至少有以下的潜在问题:

  • 不需要被打包的目录是固定的,但是引用的位置可能不是固定的。这导致在相对路径引用的时候,引用的地址不是一个固定的字符串,需要根据引用位置以及引用路径才能确定一个引用是否指向了不需要被打包的文件目录。换句话说,是否 externals 需要动态分析。

举个例子来说,假设目录结构如下:

- A
  - should-exclude.js
- B
  - A
    - should-include.js
  - index.js

其中,只有顶层的 A 目录不需要被打包,B 目录下的 A 子目录依然需要被打包。假设 index.js 中的内容如下:

import shouldExclude from '../../A/should-exclude';
import shouldInclude from '../A/should-include';

那么符合预期的编译结果应该大致如下:

const shouldExclude = require('./A/should-exclude').default;
const shouldInclude = __webpack__require('../A/should-include');

在这种场景下,单纯判断引用路径是否包含 A/ 就不够了,需要具体问题具体分析。

  • 另一个问题是打包后的引用路径很可能需要发生变化。还是用上面这个例子来说明。B 目录被打包后会生成一个文件,此时 B 目录内的文件层级关系就不复存在了。这时候生成文件和 A 的目录关系也就固定下来了。原先在 B 目录内各种情况的相对引用,到了打包之后需要根据最终确认的层级关系固定成一个具体的表示。因此,引用的路径也需要转义。

针对上面提到的这两点,Webpack 的 externals 也提供了函数回调的配置方案,可以用于灵活的配置。一个参考代码如下;

const path = require('path');
function external(context, request, callback) {
  // 当 request 是相对引用的时候,根据使用文件的绝对路径(context),计算出具体被引用的文件地址
  const requestedFilePath = path.resolve(context, request);
  // 判断被引用的地址是否需要被打包
  if (shouldExternal(requestedFilePath)) {
    // 重新计算引用的层级关系,比如将 ../../A/xx.js 转化成 ../A/xx.js
    const transformedRequest = transformRequest(request);
    return callback(null, `commonjs2 ${transformedRequest}`);
  }
  return null;
}

module.exports = {
  // ...
  externals: [
    external,
  ],
  // ...
};

注:关于 commonjs2 和 commonjs 的区别,可以参考这个 issue。


hreflang🔗

• HTML

hreflang 是 HTML 中的一个属性值,可以在“链接”相关的 Tag 中被使用,一般在 <link> 或者 <a> 中常见(理论上也可以在 <area> 上使用)。从属性的名字中不难知道,hreflang 标注的是:当前链接所指向资源的使用语言。根据 hreflang 具体应用的标签不同,实际的使用场景也存在一定的差异。

a

在 <a> 标签中,hreflang 表示的是当前这个链接指向的网站所使用的语言。在明确链接指向资源使用语言非当前网站使用语言的情况下可以使用。一些常见的使用场景:

  • 在一篇中文博客中引用了一个英文的文献,链接地址可以加上 hreflang=en-US;
  • 一个网站包含多语言版本,不同语言切换的链接,可以加上 hreflang 标明。

这里需要注意的是,hreflang 和 lang 属性之间的表意差异。lang 属性表示的是当前这个标签内,使用的语言是什么;而 hreflang 表示的则是当前链接标签指向的资源使用的语言是什么。对于一个多语言网站的跳转链接来说,往往需要同时声明 lang 和 hreflang 的值,因为链接一般会用对应语言而不是当前页面使用的语言来写。举个例子来说,假设这个页面有一个对应的德语版本,那么链接可以这么写:

<a lang="de-DE" hreflang="de-DE" href="xxx">Deutsch</a>

link

<link> 标签也是用来表示链接,但是和 <a> 有所不同,前者更多的表示的是当前页面对应的外部资源文件,如样式文件(CSS),预加载文件(Preload)等。就 hreflang 的使用场景来说,<link> 标签加上 hreflang 属性,可以用来表示当前页面的不同语言版本。

举例来说,假设有一个多语言的网站,一个页面同时有中文和英文两种语言。那么,在显示中文语言的网站内,可以加上下面的 HTML 代码,用于表示不同语言对应的版本网址分别是多少。

<link rel=alternate href=https://laysent.com hreflang=zh-cmn-Hans>
<link rel=alternate href=https://laysent.com/blog/en hreflang=en-US>

这样的设置有助于帮助程序更好的了解网站的结构。举个例子来说,Google 的搜索引擎的爬虫就可以根据上述的信息了解多语言的具体实施情况(参考这里),并在用户搜索的时候,根据用户的使用习惯推荐对应的语言版本。(因而也能帮助提升网站排名,参考这里关于 ccTLDs 的介绍)

注意事项

关于 hreflang 的使用,大体有几点需要注意的:

非权威性

根据 W3C 的说明,hreflang 是一个非强制的属性:

It is purely advisory. […] User agents must not consider this attribute authoritative — upon fetching the resource, user agents must use only language information associated with the resource to determine its language, not metadata included in the link to the resource.

也就是说,hreflang 给出的内容只是作为一种参考建议(advisory)。浏览器并不会根据给出的 hreflang 信息来判断网页使用的语言,所有的判断都只会依赖于该页面内给出的具体信息。

当然,依然有足够的理由可以支撑 hreflang 被正确的使用。足够的信息可以为程序(如搜索引擎爬虫,屏幕阅读器)提供更多额外的帮助,即时当下没有被使用,也依然为未来提供了更多的可能性。

链接的完整性

当使用 <link> 进行多语言说明的时候,需要注意链接的添加是否是完整的。具体来说,如果 A 页面上有 B 页面的 <link>,那么在 B 页面上也必须有指向 A 页面的 <link> 才行。如果链接的链路缺失或者有错误,可能会导致这些数据被忽略或者错误解析。(具体可以参考 Google 给出的说明)

正确的语言标签

hreflang 可以配置的属性值需要是 BCP47,这一点和 lang 属性是一致的。关于 BCP47,可以在这里找到完整的说明。

参考


Find Input Code🔗

• MacOS

在 Mac 系统中,选择中文输入法,通过快捷键 Option + Shift + L 可以打开“查找输入码”功能(另外的打开方式可以查看官方文档)。在该功能中输入中文,就可以得到输入值的拼音(Pinyin)、五笔字型(Wubi Xing)、笔画(Stroke)以及结构性拼音(Structural)。

多音字会将可能的情况都列出来。遇到生僻字可以通过这个小功能进行查找。

其中,列出的结构性拼音就是拆字。在输入拼音的时候,输入包含两个或多个音节的拆字输入码,然后按下 Shift + Space 就可以得到相对应的汉字输入选项。比如,输入 niu niu niu 然后按 Shift + Space 就可以看到“犇”(bēn)的选项。


React Capture Event🔗

• React

React 为了消除不同浏览器上的 Event 差异,设计了一套合成事件(SyntheticEvent)。一般常用的有 onClick,onKeyDown 等等。

类似于原生的浏览器事件,React 的合成事件也有捕获和冒泡两个不同的阶段。一般常用的 onClick 是在冒泡阶段的回调函数,对应的捕获阶段的回调函数是 onClickCapture。

需要注意的是,React 合成事件的设计,是在顶层元素上捕获事件,然后通过 React 内部的机制生成对应的合成事件,并转发给 React 元素。其中的捕获和冒泡是由 React 自身来维护的。通过下面的例子,可以直观的看到 React 合成事件和原生浏览器的事件之间的执行顺序。

假设有下面一段 HTML 代码:

<div id="not-react-dom-outer">
  <div id ="not-react-dom-inner">
    <div id="app"></div>
  </div>
</div>

以及下面这段配套的 JavaScript 代码:

const outer = document.querySelector('#not-react-dom-outer');
outer.addEventListener('click', function(e) {
  console.log('not-react-dom-outer (bubble)');
}, false);
outer.addEventListener('click', function(e) {
  console.log('not-react-dom-outer (capture)');
}, true);
 
const inner = document.querySelector('#not-react-dom-inner');
inner.addEventListener('click', function(e) {
  console.log('not react div inner (bubble)');
}, false);
inner.addEventListener('click', function(e) {
  console.log('not react div inner (capture)');
}, true);

const Button = () => (
  <div
    onClick={() => console.log('react div (bubble)')}
    onClickCapture={() => console.log('react div (capture)')}
  >
    <button
      onClick={() => console.log('react button (bubble)')}
      onClickCapture={() => console.log('react button (capture)')}
    >
      Click Me
    </button>
  </div>
);

ReactDOM.render(<Button />, document.getElementById('app'));

那么,在点击了 <button> 按钮之后,控制台的输出顺序为:

not-react-dom-outer (capture)
not react div inner (capture)
not react div inner (bubble)
not-react-dom-outer (bubble)
react div (capture)
react button (capture)
react button (bubble)
react div (bubble)

执行顺序上是先完成了从捕获到冒泡的所有原生事件,然后再执行从捕获到冒泡的所有 React 合成事件。

关于合成事件,可以参考官方给出的文档。


Git Diff Filenames🔗

• Git

在 ~/.gitconfig 中进行如下配置(或者使用命令:git config --global diff.noprefix true):

[diff]
    noprefix = true

之后,Git 输出的 diff 内容,比较的文件名前将不再包含 a/ 和 b/ 这样的前缀。

举例来说,在配置前,使用 git diff 命令,看到的输出可能如下:

diff --git a/package.json b/package.json
index ac6f0b2..f937b7b 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
   "bugs": {
     "url": "https://github.com/laysent/some-codemod/issues"
   },
-  "version": "0.1.2",
+  "version": "0.2.0",
   "license": "MIT",
   "scripts": {
     "test": "jest"

而进行了配置之后,输出如下:

diff --git package.json package.jsonindex ac6f0b2..f937b7b 100644
--- package.json
+++ package.json
@@ -13,7 +13,7 @@
   "bugs": {
     "url": "https://github.com/laysent/some-codemod/issues"
   },
-  "version": "0.1.2",
+  "version": "0.2.0",
   "license": "MIT",
   "scripts": {
     "test": "jest"

此时,无论是直接在终端复制这个文件名,还是直接点击文件名跳转打开,都比较容易。

(来源:tweet from @brandur)

需要注意的一点是,如果配置了 noprefix,那么在进行 git diff 创建 Patch 文件并通过 git apply 提交修改的时候,可能会遇到 Git 的报错:

error: git diff header lacks filename information when removing 1 leading pathname component (line 5)

原因就是生成的 Patch 文件,目录名称没有了前缀。针对这种情况,可以改用下面的方案进行 git apply:

git apply -p0 change.patch

这里,-p0 表示 Git 在进行补丁操作的时候,需要先删除零层前缀字符,然后再读取真实的目录地址。这里,Git 会根据 / 字符将目录地址拆分开来,然后删除必要的层数,将剩下的部分作为文件地址。默认值是 1,也就是会将 a/package.json 当作 package.json 目录进行处理。如果改成 -p2,那么 a/dir/file 会被当成 file 目录进行处理。

相关的说明可以参考 Git 文档。


Apply Git Patch🔗

• Git

在实际开发过程中,可能会遇到这样的问题:因为重构,一些文件从 A 目录移动到了 B 目录,而后又对文件内容做了修改。这时,如果希望将其中的某些修改(比如和安全相关的布丁)应用回重构前的代码,就显得比较困难了。直接通过 Git 进行 cherry-pick 并不顺利,因为具体修改的 commit 中并不包含文件目录移动的信息。

可以简单使用下面的命令来构建一个场景:

git init;
echo "console.log('hello world');" > origin.js;
git add -A;
git commit -m "first commit";

git checkout -b "new_branch";
mv origin.js modified.js;
git add -A;
git commit -m "rename commit";

echo "console.log('hi')" >> origin.js;
git add -A;
git commit -m "modify commit";

git checkout master;

这时候,希望直接将 new_branch 中最后一个 commit cherry-pick 到 master 是比较困难的。

针对这种情况,可以考虑使用 Git Patch 功能。首先将修改的部分生成 Patch 文件,然后手动将 Patch 中的目录映射关系处理正确,最终将修改后的 Patch 应用到重构前的某个旧版本中。

创建 Patch

git diff 命令输出的结果就是一个 Patch,可以简单的将输出的内容存储到文件中,就生成了一个当前未签入内容的 Patch 文件:

git diff > change.patch

如果希望只是将部分修改的文件生成 Patch,可以先将需要的部分放入缓冲区中(git add),然后通过 git diff --cached 命令,仅针对缓冲区中的修改生成 Patch 文件。

以上这些生成的方案,比较适合为没有写权限的 Git 仓库提交修改的场景。直接将 Patch 文件通过 email 的形式发送,就可以进行修改的讨论了。

注:如果改动包含了二进制文件的修改,可以通过增加 --binary 命令来获取到这部分文件的修改 Patch。

针对已经签入的提交,也可以通过 git format-patch 或 git show 命令来生成 commit 对应的 Patch 文件。

git show commit-id > change.patch

可以生成单个 commit 的 Patch 文件;如果希望生成一组 commit 的 Patch,可以使用:

git format-patch A..B

上面的命令会生成为从 A 到 B 之间的所有 commit 生成对应的 Patch 文件(包含 B commit,但是不包含 A commit;如果需要包含 A,可以使用 A^..B 命令)。或者,如果希望将所有的改动合成到一个 Patch 文件中,可以使用:

git format-patch A..B --stdout > changes.patch

上面的 A 和 B 除了可以是 commit id 之外,也可以是 Branch 或者 Tag。

应用 Patch

将生成的 Patch 文件应用到当前的代码中,只需要使用:

git apply change.patch

Git 会将 Patch 中提到的修改应用到当前的项目中,但改动不会被自动提交;如果希望直接将 Patch 以 commit 的形式进行提交,可以直接使用:

git am change.patch

关于 Patch

Git 生成的 Patch 文件,除了提交作者、commit message 这些信息外,核心的部分是通过 diff 命令生成的修改内容。如果只是需要修改一下文件的位置,应该可以通过观察文件直接找到。更多关于 diff 命令生成的补丁文件的格式,可以参考 Wikipedia 中的相关描述。


Cherry-pick Range of Git Commits🔗

• Git

在 Git 中,可以通过 cherry-pick 命令将某一个 commit 选到当前的分支上。在 Git 1.7.2+ 中,可以支持将一组连续的 commit 全部都选到当前的分支上。

使用的语法是:

git cherry-pick A..B

或者

git cherry-pick A^..B

这里,A..B 要求 A 是在 B 的前面(更老)。在实际挑选的过程中,A 并不会被选入,实际选入的是 A 之后的下一个 commit,直到 B 为止。如果希望选择也包括 A 这个提交,可以使用 A^..B 的语法。


Checkout Previous Branch🔗

• Git

在 Git 中,可以通过 git checkout - 切换会上一个分支。重复使用该命令,就会在最近的两个切换的分支上往复。

需要注意的一点是,虽然 git worktree 之间是共用同一个 .git 数据的,但是切换的分支也是当前目录下最新使用的两个分支。其他 worktree 上的分支切换记录不会影响到当前目录的切换行为。


Export was Not Found🔗

• TypeScript

在使用 TypeScript + Webpack 的项目中,可能会遇到如下类似的报错:

WARNING in ./src/xxx.tsx 346:0-62
"export 'xxx' was not found in './xxxx'

这类报错出现的情况是,在 ./scr/xxx.tsx 文件中,先 import 了一个类型定义,然后又将这个类型定义重新 export 出去了。产生报错的原因在于,TypeScript 的文件需要通过 loader(无论是 babel-loader 还是 ts-loader)转化成 Webpack 可识别的 JavaScript 文件。在转化之后,TypeScript 中定义的纯类型(如 interface)都丢失了。正因为这些类型丢失了,在试图重新 export 的时候,Webpack 就无法找到对应的定义,只能报错(Warning)了。

可以考虑通过以下的方案避免警告:

  1. 将所有的类型定义放到单独的文件(比如 types.ts 中),然后通过 export * from 'types.ts' 一次性将所有内容 export 出去(这样可以避免具体声明需要 export 的内容);
  2. 重新在当前文件中定义一个类型,然后将这个类型 export 出去:
import { Type as _Type } from './type';
export type Type = _Type;

在 TypeScript 3.7 之前,上面的代码可以简写为:

import { Type } from './type';
export type Type = Type;

在 3.7 及之后的版本中,必须保证新定义的类型名称和原来的类型名称不同。这是因为在 TypeScript 3.7 中对类型定义做了调整,在提供更强大的递归引用类型功能的同时,不再允许定义同名的类型。相关的介绍,可以查看官方的发布文档。


Rendered Fonts🔗

• Chrome

要知道一个网页中的文本,具体是使用什么字体渲染出来的,并不是非常简单的事情,可以有以下一些方案进行尝试。

getComputedStyle

使用 window.getComputedStyle 属性获取对应元素的 font-family 字段。因为字体的设置一般是通过顶层配置,子元素继承的方式完成的,因此在大多数的元素上,并没有 font-family 设置。即使有,大概率也是通过 CSS 完成的,因此从 .style 或者 .attributeStyleMap 无法拿到需要的数据。getComputedStyle 可以获取当前元素上样式的最终计算值,因此即使字体实际来自于继承或者系统默认字体,都可以通过该 API 获取到。比如,在一个没有 CSS 设置的页面上,可以通过下面的代码知道具体使用的系统默认字体是什么:

window.getComputedStyle(document.body).fontFamily;
// MacOS (Chrome): Times
// Windows (Chrome): Microsoft YaHei

非编程的方案,可以在 Chrome DevTools 中直接找到 Elements 下的 Computed 部分,查看实际使用的 CSS 属性值。注:如果 font-family 并没有被定义过,可以勾选 All 来查看系统默认的属性值。

Screenshot of Chrome DevTools > Elements > Computed

Rendered Fonts

使用 getComputedStyle 只能得到实际使用的 CSS 属性值,有时候并不能准确表达实际真实使用的具体字体类型。

举例来说,一个 font-family 定义可能是:

body {
  font-family:
    'Rubik',
    -apple-system, 'system-ui', 'BlinkMacSystemFont', 'PingFang SC',
    'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
    'Helvetica Neue', 'Helvetica', 'Arial', 'Hiragino Sans GB',
    'Microsoft Yahei', 'WenQuanYi Micro Hei', sans-serif;
}

在如此众多的定义中,具体浏览器使用了哪一款字体,并不容易知道。这其中涉及到了大量的变量。用户使用的系统环境、字体下载情况、正在阅读的文字是中文还是英文等,这些都有可能影响到最终浏览器所选用的字体。甚至在不复杂的 font-family 设置下,最终的选用字体依然可能并不直观。举例来说,如果设置 CSS 为:font-family: system-ui,具体使用的字体并没有从设置的字段中直观的反馈出来。

Chrome DevTools 提供了一个 Rendered Fonts 功能,可以帮助开发者了解当前真实使用的字体。示意图如下:

Screenshot of Chrome DevTools > Elements > Computed > Rendered Fonts

Chrome 会将当前选中元素真实使用的所有字体都列出来。需要注意两点:

  1. 需要选中一个有文字内容的元素,不然这里并不会显示;
  2. 选中的文字内容可能需要多种字体类型共同配合渲染,Chrome 会讲所有用到的元素都列举出来。

    比如,笑,😊,smile 这样一段文字,在 MacOS Chrome 下,默认就需要以下三种字体来进行渲染:

    • Times(渲染英文)
    • Songti SC(渲染中文)
    • Apple Color Emoji(渲染 emoji)

    以上三种字体在 Rendered Fonts 中都会被列举出来。

Chrome 的 Blog 介绍可以查看这里。

这一方案暂时没有 JavaScript API 可以直接调用,无法在程序运行时进行自动的判断。


Cost of parsing JSON🔗

• JavaScript

在 JavaScript 中,直接定义一个对象(Object),性能上远不如定义一个 JSON.parse() 的表达式。具体来说,下面的两行,JSON.parse 的表达式会有更好的性能表现:

const slow = { foo: 42, bar: 1337 };
const fast = JSON.parse('{"foo":42,"bar":1337}');

同样的效果,但是在 JavaScript 引擎中的表现却差别很大。根据这里给出的测试数据,JSON.parse 的速度是直接写对象速度的 1.7 倍。而且这不仅仅只是 V8 表现上的不同,在各类 JavaScript 引擎上都有类似的表现,性能差异均非常明显(JavaScriptCore 的性能差异可以到两倍)。

这里差异的主要原因在于,引擎在解析时候算法复杂度有着巨大的差异。简单来说,JSON 的数据结构是非常简单且固定的,因而在解析的时候可以有更好的表现。这种简单体现在以下几个方面:

  1. JSON 的数据支持类型不多,只有字符串,数组,数字,NULL,对象这几种;相比之下,JavaScript 中一个对象的支持类型非常的复杂,情况更多;
  2. 从抽象语法树(AST)的角度看,JSON.parse 的情况比单纯写一个 JavaScript 对象要简单的多。对于前者来说,就是一个 CallExpression 和一个 StringLiteral;而对于一个 JavaScript 对象来说,涉及到大量的 ObjectExpression,当中可能还包含 StringLiteral,NumericLiteral,Identifier 等等;
  3. JSON 的解析是上下文无关的;而 JavaScript 对象的解析却需要结合当前的上下文(context)来确定;

举一个例子来说明:假设有这样一个 JavaScript 代码片段:

const x = 1;
const y = ({ x }

这里的 x 代表什么含义,其实有非常多的可能性,比如:

  • const y = ({ x }),此时 x 的值和上下文中的 x 变量是相关的,定义是一个 JavaScript 对象;
  • const y = ({ x } = { x: 2 }),此时 x 和上下文是相关的,但定义的是一个赋值语句,而不是对象(根据语法,对 const 二次赋值导致语法错误);
  • const y = ({ x }) => x;,此时 x 的值和上面的 x 无关,是一个函数的参数;

换句话说,当 JavaScript 引擎在解析一个 JavaScript 对象的时候,需要考虑很多的可能性,在解析的过程中很可能无法确定当前的类型,甚至连语法是否正确也不能确定。但反观 JSON,定义就简单的多,在解析的当下,引擎就可以很清楚的知道当前的内容是一个数组,还是一个对象,亦或是有语法错误。

除了上述提到的性能比较数据之外,这里还有一份针对 Redux 应用的优化分析。数据显示,使用 JSON.parse 调用之后 TTI (Time To Interactive) 时间缩短了 0.74s (18%)。考虑到整个改动是非常“简单”的,这一性能提升显得非常客观。

这里之所以说改动是非常“简单”的,是因为整个优化思路非常的明确,完全可以通过对应的工具在编译时完成。目前开源社区已经提供了各类相关的工具,可以直接使用,列举一些如下:

  • Webpack(v4.35.3 或以上)默认会将 JSON 打包成 JSON.parse();使用 json-loader 可以去掉这一优化(具体见这个 Pull Request);
  • 一些 Babel Plugin 支持将满足要求的 JavaScript 对象转化成 JSON.parse 语法,比如 babel-plugin-object-to-json-parse 或 babel-plugin-transform-optimize-object-literal。

7zip-bin in Alpine Docker🔗

• Docker

Node.js 的 Docker 有基于 Alpine 的版本。在这个 Docker 中使用 7zip-bin 库的时候遇到了错误,无法正常启动。

一个简单的重现 Dockerfile 可以这么写:

FROM node:10-alpine

RUN mkdir -p example && \
  cd example && \
  yarn init -y && \
  yarn add 7zip-bin && \
  mkdir /lib64 && \
  ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2

ADD run.sh /run.sh

RUN chmod +x /run.sh

CMD ["/run.sh"]

其中,run.sh 可以写:

cd example
$(node -e "console.log(require('7zip-bin').path7za)")

报错的内容是:

/run.sh: line 2: /example/node_modules/7zip-bin/linux/x64/7za: not found

通过进入 Docker 内部观察不难发现,/example/node_modules/7zip-bin/linux/x64/7za 这个文件实际是真实存在的,但是在使用的时候系统却报错 not found。造成这一问题的原因,可能是动态库缺失。

通过 ldd 命令可以列出动态库依赖关系(文档):

ldd /example/node_modules/7zip-bin/linux/x64/7za

输出结果是:

/lib64/ld-linux-x86-64.so.2 (0x7febe540e000)
libpthread.so.0 => /lib64/ld-linux-x86-64.so.2 (0x7febe540e000)
libstdc++.so.6 => /usr/lib/libstdc++.so.6 (0x7febe52b9000)
libm.so.6 => /lib64/ld-linux-x86-64.so.2 (0x7febe540e000)
libgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x7febe52a5000)
libc.so.6 => /lib64/ld-linux-x86-64.so.2 (0x7febe540e000)

注意到缺少了 /lib64/ld-linux-x86-64.so.2 这个动态库,因此导致了 7zip-bin 这个库无法正常使用。造成这个的原因是,Alpine 使用的是 musl,而 7zip-bin 使用的二进制文件是基于 glibc 编译出来的。要解决这个问题,有两种思路:

  1. 在 Alpine 中安装 libc 的兼容库:RUN apk add --no-cache libc6-compat;
  2. 或者,ln -s /lib/libc.musl-x86_64.so.1 /lib/ld-linux-x86-64.so.2 将 musl 的版本软连过去,直接让 7zip-bin 的二进制使用

当然,最佳的方案是不使用 7zip-bin 中的 pre-build 版本,而改用 Alpine 的 p7zip 版本。用 Alpine 的包管理器安装好 pz7ip 之后(apk add p7zip),使用类似下面的代码直接替换脚本就好了:

cp $(type -p 7za) $(node -p "require('7zip-bin').path7za")

参考链接

  • 在 7zip-bin issue 中的相关讨论
  • 重现的配置代码 gist
  • node-gyp 在 Alpine 中也可能会遇到类似的问题,在这里可以找到相关的讨论

arguments.callee🔗

• JavaScript

arguments.callee 是一个不应该被使用的 API,在严格模式下使用会直接报错。这里仅仅是作为了解,记录一下该 API 的作用。

在早期的 JavaScript 版本中,不允许写带名字的函数表达式,在这种情况下,如果需要做递归调用,就无法显式得指明需要调用的函数名称。arguments.callee 这个值,指向了当前被调用的函数本身,因此可以在匿名函数递归调用中被使用。举例来说,在早期的 JavaScript 中,Array.prototype.map 函数给定的回调函数只能是匿名的,如果要实现一个阶乘函数,只能这么写:

[1, 2, 3].map(function (num) {
  if (n > 1) return arguments.callee(num - 1) * num;
  return 1;
});

然而,arguments.callee 的调用会导致 this 的指向出现问题(具体见 MDN),使用起来比较危险。

在 ECMAScript 3 中已经支持了带函数名的表达式,因此上面的代码可以简单的改写为一下这种正常的写法:

[1, 2, 3].map(function factorial(num) {
  if (n > 1) return factorial(num - 1) * num;
  return 1;
});

换句话说,只需要给函数指定名称,就可以规避绝大多数的 arguments.callee 使用了(注:匿名函数/箭头函数无法指定名称,但同时规范也明确了匿名函数中没有 arguments)。

MDN 给出了一个 arguments.callee 无法替换的场景:

function createPerson(sIdentity) {
  var oPerson = new Function('alert(arguments.callee.identity);');
  oPerson.identity = sIdentity;
  return oPerson;
}

var john = createPerson('John Smith');

john();

这里的函数 oPerson 是通过 new Function 创建的。在字符串内无法“得知”函数会被赋值的名称,因此只能通过 arguments.callee 去获取。在某些非常特殊的业务场景中,可能会有需求将某些表达式通过字符串进行存储,并通过 new Function 构建执行。这种时候,使用 arguments.callee 获取数据类似于传参。当然,如果只是传参的需求,其实可以写成:

const script = 'alert(arg.identity)';
function createPerson(identity) {
  const closure = new Function([
    'const arg = arguments[0];',
    `return function () { ${script} }`,
  ].join('\n'));
  return closure({ identity });
}

var john = createPerson('John Smith');

john();