Things I Learned (2019-10)

CSS Typed Object Model🔗

• CSS

在 Houdini 实现的过程中(什么是 Houdini?),Chrome 已经在 66 中已经实现了一部分 CSS 样式的 Typed Object Modal 支持(支持的列表可以参考这里)。

实现之后,在 JavaScript 中就可以通过 window.CSS 对象上的各类属性 API,生成指定类型的 CSS 属性值。看一个简单的例子:

在以前的实现中,往往需要这么写代码:

const fontSize = +(element.style.fontSize.replace('px', ''));
element.style.fontSize = `${fontSize * 2}px`;
element.style.opacity = 0.1;

这样写,会存在几个问题:

  1. 读取和设置带单位数值的时候,需要在字符串和数字之间进行转化;
  2. CSS 的属性名称是用 - 连接的,但是在 CSSStyleDeclaration 中却需要写成小驼峰的形式(font-size 变成 fontSize);
  3. 如果设置违法的值,代码会默默失败,没有任何错误提示;
element.style.opacity = 0.1;
// no error! not success!
element.style.opacity = '?';
// output: 0.1
console.log(element.style.opacity);
  1. 即使设置的属性值是数字,但是实际拿到的时候,值又变成了字符串

如,上例中的 element.style.opacity,虽然设置的值是 1,但如果运行 typeof element.style.opacity 结果却是 string

element.style.opacity = 0.1;
// output: string
console.log(typeof element.style.opacity);

如果试图直接进行运算,则可能得不到预料中的结果。比如,下面的输出依然是 0.1 而不是 0.6,因为 element.style.opacity += 0.5 的结果是 0.10.5(字符串拼接),作为一个非法值,直接被浏览器抛弃了(见第三点)

element.style.opacity = 0.1;
element.style.opacity += 0.5;
// output: 0.1
console.log(element.style.opacity);

有了 CSS Typed Object Model 之后,代码可以改写成这样:

const fontSize = element.attributeStyleMap.get('font-size').value;
element.attributeStyleMap.set('font-size', CSS.px(fontSize * 2));
element.attributeStyleMap.set('opacity', 1);

不难看出,这样的写法,基本解决了上面提到的几个问题:

  1. 读取和设置带单位数值的时候,不再需要手动进行字符串和数值的转化。CSS.px 这个函数可以将数值转化成一个带单位的对象,用于给 attributeStyleMap 赋值。另外,由于这个值 toString 之后就是类似 16px 的字符串,因此也可以直接给 element.style.fontSize 进行赋值。同时,从 attributeStyleMap 中拿到的数据,也是带单位的对象,对象中的 value 就是数值,unit 是字符串,表示单位,不再需要手动解析;
  2. attributeStyleMap 的属性名称和 CSS 的属性名称是一致的,不需要像以前一样在 JavaScript 中手动改成小驼峰的写法;
  3. 如果设置了违法的值,代码会报错:
try {
  element.attributeStyleMap.set('opacity', '?');
} catch (e) {
  console.log(e);
}

以上代码会输出报错:TypeError: Failed to execute 'set' on 'StylePropertyMap': Invalid type for property。

  1. 应该是数值的结果,拿到的时候也是数值,而不是字符串(因此数值计算也不会出错):
element.attributeStyleMap.set('opacity', 1);
// output: number
console.log(typeof element.attributeStyleMap.get('opacity').value);

当然,这里如果这么些,结果依然是数字:

element.attributeStyleMap.set('opacity', '1');
// output: number!
console.log(typeof element.attributeStyleMap.get('opacity').value);

另外,使用 CSS Typed OM 还有一些其他额外的好处,比如,浏览器不需要序列化和反序列化结果,因此性能更好(一个简单的性能检测可以查看这里,大概有 30% 左右的提升)。

更多更详细关于 CSS Typed OM 的介绍,可以参考 Google 的这篇 Blog。

P.S. 目前,其他的浏览器支持情况依然不理想,可以参考 Is Houdini ready yet? 网站上最新的支持情况了解详情。就实际情况来看,可以在 Electron 3 (基于 Chrome 66,见这里)或以上版本使用,但暂时不建议在 Web 项目中引入。


computed and getter in Mobx🔗

• JavaScript

Mobx 中,可以直接通过 observable 的方式来控制内部的 state,而不再使用 React 自带的 state 功能。一般的写法如下:

import React from 'react';
import { observer } from 'mobx-react';
import { observable } from 'mobx';

@observer
class Demo extends React.Component {
  @observable num: number = 1;
  onClick = () => {
    this.num += 1;
  };
  render() {
    return (
      <button onClick={this.onClick}>Clicked: {this.num}</button>
    )
  }
}

这样写的优势在于,可以将何时渲染的判断交给了 Mobx 去处理,不用手动去处理。

对于需要用到 observable 组合数据的情况,可以使用 computed 来生成一个新的 observable 值,也可以直接使用 getter 函数。以下的两个方案在效果上是等价的:

@observer
class Demo extends React.Component {
  @observable num: number = 1;
  onClick = () => {
    this.num += 1;
  };
  get isMany() {
    return this.num > 5;
  }
  render() {
    return (
      <button onClick={this.onClick}>
        Clicked {this.isMany ? 'many' : 'few'} times
      </button>
    );
  }
}
import { observable, computed } from 'mobx';

@observer
class Demo extends React.Component {
  @observable num: number = 1;
  onClick = () => {
    this.num += 1;
  };
  @computed
  get isMany() {
    return this.num > 5;
  }
  render() {
    return (
      <button onClick={this.onClick}>
        Clicked {this.isMany ? 'many' : 'few'} times
      </button>
    );
  }
}

之所以两者是等价的,理由很简单。在执行 render 函数的时候,Mobx 注意到 this.isMany 被使用了,而在调用这个 getter 函数的时候,实际使用到了 this.num 这个 observable。因此,当 this.num 发生了变化之后,Mobx 知道需要重新调用 render 函数进行绘制。而对于使用了 computed 的情况来说,情况会更简单一些,this.num 这个 observable 的变化触发了 this.isMany 的重新计算,最终在 this.isMany 值变化之后触发了 render 函数的重新计算。

然而需要注意的一点是,两者只是在效果上等价。在实际运算过程中,computed 的方案有两个优势:

  1. 代码看上去更清晰。render 是因为 computed 的数据触发的,这一点在代码上可以很容易的看出来;而第一种方案,是否触发 getter 函数,其实需要多思考一下才能确定;
  2. 实际执行效率更高。使用 getter 的方案,由于 render 函数实际上是和 this.num 这个 observable 进行关联的,因此哪怕 this.isMany 这个 getter 函数没有发生值的变化,只要 this.num 变了,render 函数都需要被执行;而对于使用 computed 的情况,因为 render 是和 this.isMany 进行关联的,实际 this.isMany 没有变化的时候,是不需要触发重绘的。换句话说,前者 getter 的方案,在 this.num 从 1 涨到 6 的过程中,一共触发了五次重新渲染;而后者 computed 的方案,只触发了一次重新渲染(当 this.num = 6 的时候)

针对第二点,Mobx 的 GitHub issue 中作者也有相关的说明,见这里。


Get Current IP Address🔗

• JavaScript

在 Node.js 中,可以通过 os 模块的 networkInterfaces API 来获取当前机器的 IP 数据。返回的结果类似于 ifconfig 或 ipconfig 命令。

以获取当前主机的 IPv4 地址为例,可以写类似如下的代码:

function getIPAddress() {
  const interfaces = require('os').networkInterfaces();
  const results = Object.values(interfaces)
    .flat()
    .filter(interface => interface.family === 'IPv4')
    .filter(interface => !interface.internal);
  if (results.length === 0) return null;
  return results[0].address;
}

简单的说明如下:

  • internal 用于表示当前的地址是否是本地回环地址或是其他外部无法访问的地址(例:127.0.0.1);
  • family 用于表示当前地址的类型,将会是 IPv4 或 IPv6 中的一种;
  • address 用于表示当前的 IP 地址;
  • os.networkInterfaces 的返回是一个对象,key 用于表示 network interface,比如常见的 lo 或者 eth0 等。

更多的返回数据及解释,可以参考官方文档。


noindex & nofollow🔗

• SEO

在一个网站中,并不是所有的页面都希望被搜索引擎的爬虫收录。为此,可以通过一些特殊的 meta 信息,来调节搜索引擎爬虫的行为。

nofollow

使用方法如下:

<meta name="robots" content="nofollow">

或者,针对页面上某一个具体的链接,也可以加上 nofollow 的标记:

<a href="some-link-to-backend-login-page" rel="nofollow">

有了 nofollow 的标记,搜索引擎的爬虫就不会做进一步的爬取操作了。对于第一种写法,当前页面内所有的链接地址爬虫都不会再去访问了;对于第二种写法,则是这个指定的链接在本次爬取中不会被访问(如果其他地方有引用,且没有加上 nofollow 的标记,搜索引擎依然可能会去访问这个页面)。

一些常见的使用场景:付费访问的页面、不被信任的页面(比如一些留言板快)等。

noindex

如果希望爬虫不要将当前页面的访问结果存储到数据库中用于未来搜索结果的展示,可以使用 noindex 标记。用法如下:

<meta name="robots" content="noindex">

一些常见的使用场景:后台登陆页面、感谢页面(SEO 的价值不大)或是一些内容动态的页面。

noindex nofollow

对于既不希望爬虫进一步访问,也不希望结果被收录的页面,可以将两者都加上:

<meta name="robots" content="noindex nofollow">

Webpack File Limit Error🔗

• Build

在 Webpack 的配置中,有一个 performance 选项。根据 performance 中的配置,Webpack 可以针对打包后的结果的实际大小,进行警告或报错。

具体的配置参数如下:

  • performance.hints

这个参数用于告诉 Webpack 最终产生的报告需要以什么样的方式呈现出来。可能的配置包括 false,'warning' 和 'error'。建议在 CI 中将这部分配置成 'error',保证过大体积的文件无法被发布到线上。

而具体多大的文件算“过大”,则需要用到下面提到的两个参数:

  • performance.maxEntrypointSize
  • performance.maxAssetSize

前者表示入口文件能接受的最大文件尺寸(单位是 byte),后者表示其他生成的文件所能接受的最大尺寸(默认情况下包括了所有的 CSS,非入口 JS 文件,以及字体、图片等文件)。

比如,入口 JavaScript 文件不能超过 250kb,而其余文件不能超过 100kb:

module.exports = {
  performance: {
    maxEntrypointSize: 250_000,
    maxAssetSize: 100_000,
  }
}

默认情况下,除了 .map 文件外,所有其他产生的文件都会被考虑在内。如果希望改变这个默认的行为,可以使用下面的这个参数:

  • performance.assetFilter

比如,只考虑 JavaScript 文件:

module.exports = {
  performance: {
    assetFilter(assetFilename) {
      return assetFilename.endsWith('.js');
    },
  },
};

官方文档在这里。


Document DesignMode🔗

• JavaScript

document.designMode 这个属性,可以用于控制当前的整个页面是否可以直接被编辑。可以设置的属性值包括 on 和 off 两种。如果设置为 on,那么相当于开启了全页面范围的 contenteditable=true。默认情况下,这个值是 off。

通过开关这个值,非程序员也可以轻松的对当前页面进行简单的修改(主要是文案的部分)。一些简单的需求,PM 和 UX 就可以直接进行尝试,而不需要再借助程序员的帮忙了。当然,对页面“造假”的门槛也变低了。

可以通过下面的按钮来实际体验一下这个功能。

更多的说明及浏览器支持情况(基本可以认为全支持),可以参考 MDN。


Get Npm Package Info🔗

• JavaScript

要获取一个 NPM 包所有的版本信息,可以使用 npm view 这个命令。比如,检查 React 这个包的所有版本,并输出成 JSON 格式:

npm view react versions --json

当然,以上只是 CLI 的操作方式,如果希望可以通过编程的方式去了解一个 NPM 包的相关信息,需要换一个方式。注意到 NPM 本身也是一个 NPM 包,对应的源码可以在 GitHub 上找到。其中,npm view 这个命令,对应的代码是 lib/view.js。

通过观察这个文件,不难发现,NPM 底层依赖的其实是 libnpm 这个库。其中,获取包信息的部分,使用的是 libnpm/packument 这个部分。而根据文档,这里 libnpm/packument 本质上就是将 pacote 中的 packument 接口开放了出来。

实际的使用方法如下:

const packument = require('libnpm/packument');

async function getVersions(package) {
  const { versions } = await packument(package, {
    // to use custom registry
    registry: 'https://registry.npm.taobao.org',
    // get all meta data
    fullMetadata: true,
    // prefer to get latest online data
    'prefer-online': true
  });
  return versions;
}

其中,packument 这个 API 的返回数据格式,可以参考 @types/pacote 中的相关定义。

需要额外注意的一点是:npm 和一些 registry 服务使用的数据格式可能略有区别。举例来说,npm 的返回数据里,每个版本的 dist 中可能包含 unpackedSize 数据(optional),表示该版本文件实际的大小;而 cnpm 返回的数据中,dist 内包含的是 size 数据(源代码),表示该版本的压缩文件 tar 的大小。


back to previous folder🔗

• Bash

在 Bash 中,可以通过以下的命令跳转回上一个访问的目录:

cd -

换句话说,cd - 可以在最后访问的两个目录间来回跳转。


webkit-app-region🔗

• Electron

-webkit-app-region 是一个 Electron 中的 CSS 属性,可以用于指明用户是否可以通过拖拽当前的 HTML 元素来完成对整个窗体的拖拽。这种情况主要是针对 frameless 窗口的。因为对于 frameless 窗口来说,由于没有了顶部 toolbar,所以默认是无法让用户直接拖拽的。-webkit-app-region 相当于提供了一个编程可指明的自定义拖拽区域,用于实现类似窗口顶部 toolbar 的效果。

具体的使用方法非常简单,只需要针对特定的 HTML 元素,应用如下的 CSS 就可以了:

.draggable {
  -webkit-app-region: drag;
}

当然,这里需要注意到的一点是,如果一个区域因为某个 HTML 元素的存在变成了 -webkit-app-region: drag,那么对于 Windows 系统来说(Mac 经测试不会有这个问题),这个区域上的其他元素(不论是否在 drag 元素的“上方”)都无法收到鼠标的事件(如 click / hover 等)。如果希望可以继续保留某些元素(比如按钮)的鼠标事件,需要在这些元素上通过如下的方式显示声明:

.button {
  -webkit-app-region: no-drag;
}

Electron 相关的文档说明。

关于使用 -webkit-app-region: drag 后其他区域内元素就无法收获鼠标事件的讨论,可以参考 Electron 的这个 issue。


Require Strategy in Electron🔗

• Electron

因为 Electron 项目天然的集成了 Node.js,可以直接使用 require 命令来加载其他的模块。因此,很多项目中就不再使用 Webpack 或 Rollup 来对项目进行打包操作。简单的使用 TypeScript 或 Babel 进行转化,保证 import 转化成了 require 命令,就能顺利的跑起来了。

然而,如果细究下去,直接使用 require 依次加载各个文件和通过打包将所有需要加载的部分一次性载入,两者之间还是存在这性能上的差距。具体的数据差异,可以通过这个测试项目来实际了解。

实测中,大约 1000 个文件,打包和不打包的版本,载入的时间差距在 200ms 以上。对于用户来说,这个已经是可感知的延迟了(参考数据)。

一次 Electron require 涉及的步骤包括:

  1. 根据请求的地址,寻找文件(Electron 的 _resolveFilename 方法 > Node.js 中的 _resolveFilename 方法 > _findPath 方法 > stat 方法)
  2. 根据实际地址读取文件(Node.js 的 Module.prototype.load 方法 > Module._extensions[‘.js’] 方法 > fs.readFileSync 方法)
  3. 编译加载文件内容(Node.js 的 Module.prototype._compile 方法)

其中,第一步和第二步的 IO 都是比较耗时的操作。特别是对于第一步来说,寻找文件是一个过程。对于非相对路径的文件来说,如果不能在当前的 node_modules 下找到,Node.js 就会逐级往上寻找,直到成功或最终失败。第三步编译和运行的过程,耗时将和内容具体的长短以及具体执行的内容相关。

由于不论是直接 require 的方法还是打包的操作,最终需要执行的程序都是基本相同的(对于 Webpack 来说,有一些 runtime 代码的消耗),也就是第三步的时间两个方案都是大体相同的。因而总体上来说,两种方案的差异主要体现在第一步和第二步的耗时上。由于单个打包文件加载的方案可以节省多次 IO 的查找和读取操作,因而最终会节省不少的时间。


MouseDown to Click🔗

• JavaScript

在日常的开发过程中,对于一个按钮或者链接,一般会附上一个 onClick 事件,以响应用户的点击操作。当用户实际按下按钮或链接之后,再通过 onClick 事件去触发之后要进行的流程(比如网络请求或是链接跳转等)。

如果对于用户操作后的反馈速度有一定的要求,这里的行为就需要进行优化。以链接为例,一个常见的操作方法是(比如 quicklink),用程序对可视范围内的链接地址进行预加载(使用 prefetch)。这样,当用户真正点击的时候,资源很可能已经得到了加载,打开速度就会显著提升。

当然,这样的行为是没有预判的,纯粹暴力的进行可能的预备操作。如果预备操作损耗较多,这样的操作就显得不方便了。

一个更加“智能”的操作是,仅当用户“点击”了之后才进行预加载。实际上,即使是一个点击的的操作,也会分成好几个不同的事件,包括 MouseDown,MouseUp 和 Click。在 MouseDown 和 Click 之间,差着大约 100ms 的时间。

换句话说,如果在 MouseDown 的时候就开始预处理,等到 Click 时才真正进行加载,那么整体的加载时间会减少 100ms 左右。在某些情况下,这也是个不小的提升了。

可以用下面的这段代码实际测试一下,MouseDown 事件和 Click 事件之间的时间差(具体时间差因人而异):

const button = document.createElement('button');
button.textContent = 'Click Me';
button.onmousedown = function () { console.log(`Mousedown: ${Date.now()}`); };
button.onclick = function () { console.log(`Click: ${Date.now()}`); };
document.body.appendChild(button);

也可以直接点击下面这个按钮尝试:

当然,比这个略激进的操作,可以将 MouseDown 事件换成 MouseEnter 事件,这样在 Hover 的时候就会开始预加载。大概能提前 300ms 左右开始操作,当然存在一定的误判风险(比如用户只是划过了鼠标,并没有想要点击的意愿)。

可以参考 InstantClick 了解更多实现的细节。


Parse GitConfig via Node🔗

• Tool

使用 Node 解析当前的 Git Config 文件,有两个可以辅助的 npm 库:

  1. git-config-path:可以用于判断当前的 Git Config 地址

例如,需要获取全局 Git Config 地址,可以运行:

const configPath = require('git-config-path')('global')

更多可以参考文档。

  1. ini:可以用于解析和处理 ini 类型的配置文件,parse-git-config 也使用了这个来解析 Git Config 文件。

简单的使用方法如下:

const configPath = require('git-config-path')('global');
const ini = require('ini');

const content = fs.readFileSync(configPath, 'utf8');
const parsed = ini.decode(content);
const stringify = ini.encode(parsed);
fs.writeFileSync(configPath, stringify, 'utf8');

更多内容,可以参考文档。


shape-rendering🔗

• CSS

在浏览器渲染 SVG 的时候,可以通过 shape-rendering 这一属性,来控制浏览器对 SVG 抗锯齿效果的展示。shape-rendering 支持从三个纬度来权衡 SVG 的渲染效果,这三个纬度分别是:速度、曲线精细度以及曲线的锐利程度。

  • auto,这个是默认值,表示由浏览器来决定改如何显示
  • optimizeSpeed,顾名思义,这个要求浏览器以渲染的速度优先,抗锯齿可能会被浏览器关闭
  • crispEdges,这个选项要求浏览器以曲线的锐利程度为第一优先级。这种情况下,速度和精细度的优先级会被降低。浏览器可能会关闭抗锯齿,或者只针对接近垂直和水平的线才开启抗锯齿的功能。同时,浏览器可能会微调线的位置和宽度,以适应显示器的物理像素点
  • geometricPrecision,这个选项要求浏览器以更好的精度来渲染图像,为此可能会牺牲渲染的性能(速度)和边界的清晰度

下图从左到右分别展示了 geometricPrecision,crispEdges 和 optimizeSpeed 三种情况下,同一个圆的显示效果。

不难看出,geometricPrecision 的效果是最平滑的,但是边缘清晰度不足;crispEdges 边缘很锐利,但是有一些毛边(越是低分辨率的屏幕,效果越明显);optimizeSpeed 的显示效果也明显有毛边,不过效果和 crispEdges 略微不同,可以看得出底层使用的算法是不太一样的。

上图的 HTML 代码如下:

<svg viewBox="0 0 640 200" xmlns="http://www.w3.org/2000/svg" width="740">
  <circle
    cx="100"
    cy="100"
    r="100"
    shape-rendering="geometricPrecision"
    fill="#ff8787"
  />
  <circle
    cx="100"
    cy="100"
    r="100"
    shape-rendering="crispEdges"
    fill="#da77f2"
  />
  <circle
    cx="100"
    cy="100"
    r="100"
    shape-rendering="optimizeSpeed"
    fill="#748ffc"
  />
</svg>

另外,除了在 SVG 中直接写属性之外,也可以通过 CSS 来给 SVG 加上相关的 shape-rendering 值:

svg {
  shape-rendering: geometricPrecision;
}

MDN 的相关介绍见这里。


iconfont to svg🔗

• CSS

图标的使用,之前的技术方案,一般都是使用特殊的字体文件进行的。而现在随着浏览器支持的变化,越来越多的技术方案开始迁移到直接使用 SVG 图标了。

当然,为了迁移的平滑进行,最好是可以尽可能的避免改动。在 CSS 层面上,一般针对图标有两个需要设置的部分,一个是颜色,一个是大小。

对于颜色,字体文件使用 color 属性进行着色。SVG 中可以用 fill 着色,用 stroke 描边。不过,由于 SVG 图标一般都是一个或多个 path 组成的,实际一般使用 fill 属性就可以了。这里,可以通过 CSS 中的 currentColor 来完成从 color 到 fill 的映射关系:

.icon {
  fill: currentColor;
}

其中,currentColor 的支持浏览器可以参考 caniuse。总体上来说,IE 9+ 都是支持的,是一个不需要有太多顾虑就可以使用的功能。

对于大小,字体文件使用 font-size 属性控制大小。SVG 中则使用 width 和 height 进行控制。这里可以取巧的对所有 SVG 图标统一设置一个如下的 CSS 样式,一步将大小的设置迁移过来:

.icon {
  width: 1em;
  height: 1em;
}

上面的方案可以解决大部分的大小问题,但是要警惕部分字体图标也设置了 width 和 height 的情况。这种时候,图标占的空间由 width 和 height 确定,但是实际图标的大小由 font-size 确定。相当于 SVG 图标外面加上了一圈 pending。实际在迁移的时候,也可以用这个方案,将 width 和 height 改成和原先 font-size 一样的值,其中变化的差值部分用 pending 补上。


HTTP Status 301 & 308🔗

• HTTP

在 HTTP 协议中,301 Moved Permanently 和 308 Permanent Redirect 在语意上是一致的,都表示一个资源已经被永久性地转移到了一个新的地址(这一点和 302 / 307 对应,后者只是资源的地址被临时修改了)。在这种情况下,浏览器会跳转到新的资源地址,SEO 也会更新资源对应的数据信息。

虽然 301 和 308 的语意是一致的,但是在实际的浏览器行为上,会有少许差别。根据 RFC7231 中的表述,因为一些历史原因,客户端有可能会将 301 重定向的请求方法从 POST 修改为 GET。而根据 RFC7238 中的定义,308 重定向是不允许客户端对请求方法进行修改的。

这里 302 和 307 的区别也是同理。整体的区别见下表:

是否允许改变请求方法 永久重定向 临时重定向
允许 301 302
不允许 308 307

Webpack Require Performance🔗

• Build

在 JavaScript 中,对模块的引用声明一般写在文件的顶部,而实际引用的 API,可能在运行时的非常晚才会被真正的使用到。看上去,这些 import 语句并没有什么问题。但实际上,由于引用模块自身的初始化工作以及可能的副作用,import 带来的性能损耗有时候也是不容忽视的。

首先来看下面这个 JavaScript 文件:

import { defaults } from 'lodash';

console.log(defaults({ 'a': 1 }, { 'a': 3, 'b': 2 }));
// → { 'a': 1, 'b': 2 }

看上去是一段非常简单的 JavaScript 代码,只是执行了一个很简单的操作。基本等价于下面这段代码(Lodash 的 API 可以参考文档):

console.log(Object.assign({ 'a': 3, 'b': 2 }, { 'a': 1 }));

然而两者有一个很重要的区别,就是前者引用了 Lodash 的 API。这个看上去是一个非常简单的操作,但实际上也有不小的消耗。在程序执行 import 语句的时候,会加载 Lodash 完整的初始化代码,并给 defaults 变量赋值 Lodash 的 defaults API。其中,Lodash 的初始化代码完整执行完成,需要大概 15ms 左右的时间。实际上,如果改成只引用 defaults 这一个 API,最终的效果就会好很多:

import defaults from 'lodash/defaults';

console.log(defaults({ 'a': 1 }, { 'a': 3, 'b': 2 }));

如果累计了很多这样小的初始化成本,最终就会导致在应用实际启动的过程中,产生几百毫秒的延迟。这一点在 Web 应用中相对还好,毕竟体积和初始化速度多少存在着一些关系,而 Web 应用对体积非常的敏感;但是同样的问题,到了 Electron 项目中,就有可能变得不容小觑起来。作为 PC 级别的应用,Electron 的打包往往对体积没有那么严苛的要求。很多时候多一个库,少一个库,都没有太大的差别。然而,各个库初始化的速度累计起来,却有可能拖累本就不快的 App 启动速度。

再举一个小例子。下面的这段代码看上去似乎没有什么问题:

const crypto = require('crypto');

function md5(input) {
  return crypto.createHash('md5').update(input).digest('hex');
}

然而,实际加载 crypto 模块可能需要 5ms 的时间。这个时间在初始化的时候就用掉了,但实际用到 crypto 模块的时间却可能还早(或者压根最终没触发)。考虑到 require 本身就有缓存的机制,将这一步骤放到第一次执行的时候再做,就可以省下这 5ms 的加载时间:

function md5(input) {
  return require('crypto').createHash('md5').update(input).digest('hex');
}

当然,上面只是一些例子。真正在实际的项目中需要解决这一问题,第一步,就是知道有哪些代码在初始阶段被加载了,分别花了多长的时间。这看上去是一个挺麻烦的工作,但如果应用是使用 Webpack 进行打包的,那么问题就变得不那么麻烦了。

Webpack 由于需要支持 HMR 以及 Dynamic Import,在编译的时候需要打包一个运行时进去,用于管理各个 Chunk 之间的引用(正因如此,Webpack 的打包体积往往会大于用 Rollup 打包的体积)。而正因为有了这个统一的运行时,使得模块间引用的耗时变得非常容易统计了。只需要在下面这行代码的前和后,分别用 Performance 进行一次打点计时,就可以很容易的知道每一个模块实际的加载耗时了。

modules[moduleId].call(
  module.exports,
  module,
  module.exports,
  __webpack_require__
);

修改后的代码大概如下:

if (typeof performance !== "undefined") performance.mark(moduleId);

modules[moduleId].call(
  module.exports,
  module,
  module.exports,
  __webpack_require__
);

if (typeof performance !== "undefined") {
  performance.measure(moduleId, moduleId);
  performance.clearMarks(moduleId);
  performance.clearMeasures(moduleId);
}

这里需要加上 typeof performance !== 'undefined' 的主要原因是,一些 loader(如 css-loader)可能会在 Node 环境执行运行时的代码,这种情况下不可以直接调用 Performance 相关的 API,会报错。

由于 Webpack 基于 Tapable 架构的关系,要编写一个插件来修改 Webpack 原本的运行时代码也非常的容易。观察 Webpack 的源码 不难发现,只需要针对 mainTemplate 的 require 进行一些改动就可以了。同时,从 Webpack 的代码历史来看,上面这句代码前后的 Comment 一直都没有变过。于是,只需要找到模块引用前后的注释,用字符串替换的方式,插入这些新的性能打点语句就可以了。

最终的代码可以参考 NPM 的库 webpack-require-performance-plugin,源码在这里。