Things I Learned (2019-07)

Follow Redirection🔗

• Node.js

在进行网络请求的时候,有可能会遇上服务器指定链接跳转的情况。此时,无论是 301(永久转移)还是 302(暂时转移),在请求页面的时候都需要根据服务器的指示,去访问下一个链接。当然,这里下一个链接依然有可能是跳转链接,需要继续执行跳转操作。

一段可用的 Node.js 代码如下:

const http = require('http');
const https = require('https');

function fetch(link) {
  return new Promise((resolve) => {
    const { get } = link.startsWith('https') ? https : http;
    get(link, response => {
      if (response.statusCode >= 300 && response.statusCode < 400) {
        const { location } = response.headers;
        return fetch(location).then(resolve);
      }
      resolve(response.headers['set-cookie']);
    });
  });
}

这里,Node.js 处理 HTTP 和 HTTPs 请求使用的模块是不相同的,因而需要根据链接地址的 protocol 进行按需索取。同时,如果是 3xx 的 HTTP 结果,则需要进行链接跳转。可以直接读取 headers 中的 location 数据,找到下一步需要跳转到的位置。


Bad owner or permissions🔗

• Bash

在 Docker 中使用 SSH 的功能时,发现 SSH 报错:

Bad owner or permissions on ~/.ssh/config

通过 ls -l 查看 ~/.ssh/config,得到如下结果:

-rw------- 1 1000  1000   557 Jul 29 20:32 config

注意到给出的 User 和 Group 的值不是一个名字(如 root),而是一个数字。这说明,文件所属的 User / Group 无法找到。

可以通过如下的命令查看当前 root 用户的 ID:

id -u root # output => 0

可以看到和 ls 列出的 ID 是不匹配的。这说明,导致 SSH 无法正常工作的主要原因,是 ~/.ssh/config 文件权限的设置有问题。可以通过如下的命令将权限分配给当前的 root 用户:

chown -R root:root /root/.ssh

再次运行 SSH 就可以正常工作了。


Jest with Ant Design🔗

• JavaScript

当 Ant Design 和 Jest 一起使用的时候,在某些情况下(开启 coverage 的时候)会导致单元测试运行失败。一个可能造成问题的 Ant Design 代码如下:

import { Input } from 'antd';

const { TextArea } = Input;

Jest 会报错:

ReferenceError: Input is not defined

  1 | import { Input } from 'ant-design';
  2 |
> 3 | const { TextArea } = Input;

报错的直接原因,是使用了 Ant Design 推荐的 babel-plugin-import 和 Jest 计算 coverage 使用的 babel-plugin-istanbul 造成的。在这里、这里等 GitHub Issue 中都有相应的讨论。

要修复这个问题,只需要在 Jest 或者单元测试环境中,不使用 babel-plugin-import 这个转换插件就可以了。参考代码如下,在 .babelrc 中:

{
  "env": {
    "development": {
      "plugins": [
        [
          "import",
          {
            "libraryName": "antd",
            "style": true
          }
        ]
      ]
    },
    "production": {
      // same as above
    }
  },
  "plugins": [
    // rest of plugins...
  ]
}

如此一来,只有在 NODE_ENV 为 production 或 development 的情况下,Babel 才会启用 babel-plugin-import 这个转换插件。对于 Jest 来说,因为默认设置了环境变量 NODE_ENV 为 test,所以 Plugin 不会起效。

这样造成的问题是 Jest 的运行速度会有所降低。


SSH Host Config🔗

• Bash

如果手上有多台设备在管理,SSH 的时候需要记住各个设备的 IP 地址、输入,总是很麻烦的。SSH 提供了配置文件的功能,可以为不同的 IP 设置别名,同时配置登陆需要用到的用户名和 RSA 私钥等。

配置方法

修改 ~/.ssh/config 文件,增加每个设备对应的配置数据。举例如下:

Host pi
    Hostname 192.168.xx.xx
    User pi
    IdentityFile ~/.ssh/id_pi_rsa

这样就配置好了一个 Raspberry Pi 的别名。接下来,可以直接使用如下的命令来访问设备:

ssh pi

除了 SSH 之外,SCP 也可以使用同样的配置。比如:

scp -r /local/path pi:/remote/path

Import Chunkname with Babel Plugin🔗

• JavaScript

默认情况下,Webapck 会用 Chunk ID 为 import() 产生的独立文件命名,最终的结果就是类似于 0.bundle.js 这样的文件。这样的文件并不方便理解和管理,所以一般会使用 webpackChunkName 这个注释来让 Webapck 使用更加有意义的命名。例子:

import(/* webpackChunkName: "module-name" */ 'path-to-bundle');

最终产生的文件为 module-name.bundle.js(这里假设在 Webpack 中配置了 output.filename 为 [name].bundle.js)。

然而,每次要手写这样的注释有些麻烦。如果动态加载的模块本身存放位置有规律可循(比如是在 pages 目录下,每个目录有一个入口文件),那么也可以考虑使用 Babel 插件的方式,自动为每个 import() 增加合适的 bundle name。

参考代码如下:

function addComments(arg, name) {
  // only add leading comment when not found
  if (arg.leadingComments) return;
  arg.leadingComments = [{
    type: 'CommentBlock',
    value: ` webpackChunkName: '${name}' `,
  }];
}

function getChunkNameFromImportPath(importPath) {
  // find a way to transform from import path to chunk name
  // example: from 'path/to/file' to 'path.to.file' as chunk name
  return importPath.replace(/\//g, '.');
}

module.exports = function(babel) {
  const { types: t } = babel;

  return {
    name: 'add-bundle-name',
    visitor: {
      CallExpression: function(path) {
        const { node } = path;
        if (!t.isImport(node.callee)) return;
        const [firstArg] = node.arguments;
        const importPath = firstArg.value;
        addComments(firstArg, getChunkNameFromImportPath(importPath));
      },
    },
  }
}

Execute Bash in Docker🔗

• Docker

在 Docker 开发的过程中,有时会有脚本出错,导致执行结果不及预期的情况。这种错误有时是环境导致的,在非 Docker 环境下无法重现。如果需要通过构建 Docker 添加诸如日志之类的信息来了解具体可能出错的原因,不免有些曲折。可以考虑直接在 Docker 环境下运行 Bash 命令,通过执行脚本中的语句,来查找可能出现问题的原因。

要在 Docker 环境下执行 Bash 脚本,可以遵循以下的步骤:

  1. 首先,需要知道当前运行 Docker 的容器 ID
docker container ls

上述命令会列出所有的容器,找到需要调试的那一个即可。

  1. 在该容器环境内执行 Bash 命令
docker exec -ti xxx /bin/bash

这里,xxx 就是第一步找到的 Container ID。上述命令用到了两个参数,-t 和 -i。-t 是 --tty 的缩写,用于让 Docker 将用户的终端和 stdin/stdout 关联起来;-i 是 --interactive 的缩写,用于让 Docker 在执行命令的时候允许用户进行交互式的输入输出。

如果只是希望执行一个语句并输出结果(比如 echo 一个字符串),那么 -t 就足够了,不需要 -i。但是对于需要在 Docker 环境下输入 Bash 命令并检查执行结果的情况来说,-i 就是必须的。

一个输出 Hello World 的简单例子:

docker exec -t echo "hello world"

另外,可以通过如下的命令知道,docker exec 运行的默认环境是在 / 下:

docker exec -t pwd # output: /

如需修改这一默认行为,可以通过 -w 参数(或 --workdir)来执行:

docker exec -w /root -t xxx pwd # output: /root

Alpine Mirror🔗

• Docker

Alpine 是 Docker 中非常流行的镜像,因为它体积小(5 MB 左右),且包管理机制友善。然而即使体积小,一旦网络条件受到限制,使用 Alpine 安装依赖依然十分费劲。这让 Docker 镜像的安装变得非常缓慢且容易失败。

假设原先的 Dockerfile 如下:

FROM alpine:edge

RUN apk update && \
  # ...

那么可以考虑改用国内的镜像源来加速网络下载过程:

FROM alpine:edge

RUN echo 'http://mirrors.aliyun.com/alpine/edge/community/' > \
    /etc/apk/repositories && \
  echo 'http://mirrors.aliyun.com/alpine/edge/main/' >> \
    /etc/apk/repositories && \
  apk update && \
  # ...

除了上面提到的阿里镜像之外,清华、南大、中科大等镜像也可以考虑。更多镜像及其对应的网络状态可以在这里找到。

注意使用的镜像版本必须与 Docker 需要使用的版本保持一致。如上例中,Docker 需要基于 alpine:edge,那么在设置镜像的使用,也应该使用 edge 的版本(在 URL 中可以找到 /alpine/edge/)。


Docker Installation🔗

• Docker

以下介绍一些系统上安装 Docker 的步骤。

Raspberry Pi

  1. 安装一些前置依赖
sudo apt-get install \
  apt-transport-https ca-certificates software-properties-common -y
  1. 安装 Docker
curl -fsSL get.docker.com -o get-docker.sh && sh get-docker.sh

这里直接使用了 get-docker.sh 提供的安装脚本。

  1. 让当前用户可以使用 Docker
sudo usermod -aG docker pi
  1. 导入 Docker CPG key
sudo curl https://download.docker.com/linux/raspbian/gpg
  1. 设置 Docker Repo 地址

在 /etc/apt/sources.list 中增加如下行:

deb https://download.docker.com/linux/raspbian/ stretch stable
  1. 更新系统
sudo apt-get update
sudo apt-get upgrade
  1. 启动 Docker 服务
systemctl start docker.service

MacOS

可以直接使用 Homebrew 进行安装:

brew cask install docker

安装完成后,在 Application 中找到 Docker 并启动,按提示信息一步步走就可以了。

运行

完成后,可以试试如下的 Docker 命令,如果可以正常输出内容,安装本身就没有问题了:

docker info

参考


Get Branch Name & Commit ID without Git🔗

• Git

可以使用 Git 命令行工具获取到当前使用的分支名称,最新的 Git Commit ID 等信息。然而,在不借助 Git 命令的情况下,依然可以通过 .git 文件,找到这些信息。

在 .git 文件夹中,HEAD 文件记录了当前分支的指向。文件内容 refs 后面跟着的就是分支名。这个分支名亦是一个路径,在 .git 目录下使用这个相对路径可以得到当前分支指向的头部 Commit ID。

举个例子:

发现 ./.git/HEAD 中的内容是:ref: refs/heads/master,通过查看 ./.git/refs/heads/master 文件中的内容,就可以知道当前的头部 Commit ID。同样,去除 refs/heads 之后,就可以得到当前的分支名称,即 master。

对应的 Node.js 代码如下:

const git = path.resolve(process.cwd(), '.git');
const head = path.resolve(git, 'HEAD');
const ref = fs.readFileSync(head, 'utf8').trim().substr('ref: '.length);
const commit = fs.readFileSync(path.resolve(git, ref), 'utf8').trim();
const branch = ref.substr('refs/heads/'.length);

console.log(branch, commit);

CPU Usage via Nodejs🔗

• Node.js

在 Node.js 中,可以通过 os.cpus() 这个函数,来了解当前状态下,计算机 CPU 的一些状态。以 MacBookPro 2019 款为例,以下是一份输出的结果:

[
  {
    model: 'Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz',
    speed: 2200,
    times: {
      user: 239572260,
      nice: 0,
      sys: 133555650,
      idle: 644681690,
      irq: 0,
    },
  },
  // 省略类似的其他十一个...
]

在这个返回的数组中,每一个元素代表一个 CPU 的线程。这台 MacBookPro 是六核(core)十二线程(processor),所以返回的数组长度是十二。具体来看每一个元素:

  • model,表示了当前 CPU 的型号
  • speed,表示 CPU 的运算速度,单位是 MHz
  • times 表示 CPU 的使用情况,分别记录了一些时间(单位是毫秒):

    • user 表示进程在用户模式(user mode)下使用的时间;
    • nice 表示被调整优先级(nice)的进程在用户模式下使用的时间。这里,nice 用于表示执行的优先级,从 -20(最高优先级)到 19(最低优先级)。默认的程序,优先级是 0;(注:这是一个 Unix 特有的功能,对于 Windows 用户来说,这个值永远是 0)
    • sys 表示进程在内核模式下使用的时间;
    • idle 表示空闲的时间;
    • irq 表示硬中断请求花费的时间。硬中断也叫外部中断,由外部硬件产生,如鼠标、键盘等。

有了以上的数据,就可以在 Node / Electron 程序中查看 CPU 的使用,从而对当前程序的执行情况有一个客观的了解。


navigator.platform🔗

• JavaScript

navigator.platform 可以获取到当前浏览器所在的操作系统信息。一般来说会得到一个字符串用以表示操作系统,但某些情况下也可能会拿到空字符串(浏览器拒绝或不能给出操作系统信息)。

需要注意的一点是,即使是 64 位的 Windows 操作系统,得到的结果很可能是 Win32 而不是 Win64。根据 MDN 的数据,Internet Explorer 和 v63 前的 Firefox 会使用 Win64,其他的一般返回 Win32。

综上,可以使用如下的方法检测当前是否是 Windows 系统:

const isWindows = navigator.platform.indexOf('Win') === 0;

navigator.platform 基本没有浏览器兼容性问题,可以放心使用。


console.count🔗

• JavaScript

console 中可以通过 console.count 来进行记数。

简单的使用方法如下:

function callMe() {
  console.count('callMe func');
}
console.countReset('callMe func');

callMe(); // output => callMe func: 1
callMe(); // output => callMe func: 2
callMe(); // output => callMe func: 3

几点说明:

  • console.countReset 函数可以用于清空记数
  • 传递的参数可以用于标记 count 的类别,如果不传就是默认的 default
  • 不同类别之间的 count 不会共享数据

Detect True Encoding of File and Convert🔗

• JavaScript

在 Windows 上,很多文本文件并不是以 UTF-8 的格式进行存储的。比如,中文可能的存储格式是 GB2312 或是 BIG5。这导致,在其他系统中,如果直接以 UTF-8 的格式打开对应的文本文件,就会得到一串乱码。

如果不知道之前是以什么格式存储的文件,这时就会有点束手无策了。

一个可行的简单方法是用 VSCode 的“猜测”功能。在 VSCode 中,如果选择 Reopen with Encoding,会得到 VSCode 猜测的当前文本编码格式。如果使用新的编码重新打开文本,看到的不再是乱码,那么很可能 VSCode 就猜测正确了。一般建议再以 UTF-8 的格式保存一下,以后再次打开就不会有乱码的困扰了。

然而,这个方法不适应大规模批量修改的需求。既然如此,不如直接从 VSCode 的源码入手,看看这个文本编码检测的功能是如何实现的。

VSCode 相关的代码,可以在 src/vs/base/node/encoding.ts 中找到,GitHub 的代码在这里。

精简后的代码如下:

const fs = require('fs');
const jschardet = require('jschardet');
const iconv = require('iconv-lite');

const JSCHARDET_TO_ICONV_ENCODINGS = {
  'ibm866': 'cp866',
  'big5': 'cp950'
};

const fromPath = '/path/to/read/file';
const toPath = '/path/to/save/file';

const buffer = fs.readFileSync(fromPath);
const { encoding } = jschardet.detect(buffer);
const iconvEncoding = JSCHARDET_TO_ICONV_ENCODINGS[encoding] || encoding;
const content = iconv.decode(buffer, iconvEncoding);
fs.writeFileSync(filename, content, 'utf8');

这里主要用到了两个库,jschardet 和 iconv-lite。

jschardet 是 Python chardet 的一个 JavaScript 移植版本,用于检测当前的二进制流(Buffer)是什么类型的编码。检测的大致原理,可以在 chardet 的网站上找到(这里)。

iconv-lite 用于将二进制流转化成指定编码格式的字符串,是一个纯 JavaScript 的 iconv 库。这里 iconv 的全称是 internationalization conversion,在类 Unix 系统中,这是一个用于转换不同编码字符串的命令行工具。

需要注意的是,iconv-lite 并不通过编码来区分 UTF-8 和 UTF-8 with BOM,而是通过第二个参数 { addBOM } 来完成的。因此,转化 UTF-8 with BOM 的时候,需要稍微手动处理一下。(处理的方法可以参考 VSCode 中的相关函数,比如 encode)

另外,jschardet 和 iconv-lite 对编码的命名有些不同,使用前需要转化。上面示例代码中的 JSCHARDET_TO_ICONV_ENCODINGS 就是做的这个事情。


Clone Current Tab in Chrome🔗

• Chrome

在 Chrome 中,如果当前的光标在地址栏上,可以通过快捷键 Cmd + Enter 来打开一个和当前页面一样的新标签页(焦点依然在当前标签页上)。如果光标不在地址栏上,可以先使用 Cmd + L 来将光标移动到地址栏,然后再 Cmd + Enter。

如果改用 Option + Enter,则可以新创建同一个 URL 地址的标签页,并将焦点定位到新打开的标签页上。

如果改用 Shift + Enter,则可以在一个新窗口打开当前的 URL 地址。


Abort Git Rebase Process🔗

• Git

在 Git 的一些操作中,可能会中途停下来,等待用户输入的操作。比如,git rebase -i 或 git ammend 的时候。在完成操作前,Git 会打开 Vim(或其他默认的编辑器)等待用户对 commit message 做最后的处理。只要用户保存并退出,rebase 的过程就完成了。

如果在这个等待确认的过程中,希望可以中断整个过程,使用 Ctrl+C 是不行的。如果使用 Ctrl+C,Vim 可能会提示用 qa! 来放弃所有修改并退出 Vim。但这个只是退出了 Vim,Git 依然会继续接下来的流程,并没有真正达到中断 Git 的目的。

事实上,Vim 允许以 error code 退出,使用如下的命令::cq。

更多关于这个命令的说明,可以使用 :help cq 来查看。以 error code 退出之后,Git 就不会再继续接下来的流程了。


Inspect React Node without DevTool🔗

• JavaScript

在 React 开发过程中,使用 Facebook 提供的官方 Chrome DevTool Extension 可以很方便的查看,修改页面上的 React 组件。然而,有时候也需要在没有 DevTool 的情况下,对 React 组件进行 Debug。比如:在测试电脑上查看一个即时出现的问题,或是在 Internet Explorer / Safari 上调试一个出现的问题等。

以下介绍如何在不借助 Chrome DevTool Extension 的情况下,完成对当前 React 组件的检查。

首先,需要获取到某个需要查看的 DOM 元素。可以用 querySelector 或是在 Chrome DevTool 中选中某个元素,然后在 Console 中使用 $0 获得该元素。

React 会在元素上添加额外的属性,用于记录当前这个 React 节点的相关数据。可以通过下面的代码来获取这个属性数据:

function getInstance(element) {
  const key = Object.keys(element)
    .filter(key => key.startsWith('__reactInternalInstance$'))[0];
  if (!key) return null;
  return element[key];
}

React 在添加属性的时候,属性名称会增加一个随机字符串作为后缀(各个 React Node 使用的随机字符串是一致的)。所以需要通过检查 startsWith 来判断当前属性是否是 React 使用的属性。

拿到的这个对象,有一些有用的数据,包括:

  • child - 当前元素子元素的头个元素
  • elementType/type - 该 React 节点对应的类型。如果是 HTML Element,那么就是一个字符串,比如 "div";如果是一个自定义的 React 元素,则是一个函数(class 或 function)。也就是 React.createElement 函数的第一个参数。
  • memoizedProps - 当前元素使用的 props。对于任何一个 React 生成的 HTML 元素,对会有对应的 Props。(写 JSX 的时候,每一个属性,包括 children,都是一个 Props 的属性值)
  • memoizedState - 当前元素使用的 state
  • return - 当前元素所在双向链表的上一个元素
  • sibling - 当前元素下一个兄弟元素
  • stateNode - 当前元素在组件内可以用的 this,包含了 props,refs,state,context 以及其他 React Component 的方法。在这个对象的原型链上,还有 React Component 组件的各个方法(比如生命周期函数如 componentDidMount)以及 setState 等可用方法。换句话说,这里的对象就是一个 React Component Class 生成的实例。如果组件是一个 Functional Component,那么这里就是 null 了;而如果是 React 的内置组件(比如 <div />,<span /> 这类),那么 stateNode 就是对应生成的 DOM 元素。

根据 React 16 中 Fiber 的设计,元素之间是一个双向链表的关系,每一个节点会连结其上一个元素(return),子元素的首个元素(child),下一个兄弟元素(sibling),因而从任意一个中间的 HTML 元素开始,都可以遍历整个 React 树。

注:从一个元素 A 的 sibling 抵达下一个元素 B 后,该元素 B 的 return 是他的上一个兄弟元素,也就是 A,而不是真正意义上 React / HTML 树的父元素。只有当 B 是 A 的第一个子元素的时候,B 的 return 才是它在树上的父元素。


Trigger onChange for React Input🔗

• JavaScript

在 React 16 中(包括 React 15.6 及之后的版本),如果想要用 JavaScript 在外部触发一个 input 组件的 onChange 事件,需要做如下的几个事情:

  1. 首先,让 React 记录下新的 value 值。React 通过 defineProperty 封装了 value 的 set 方法,因而直接调用 input.value = xxx 并不能达到预期的效果(实际触发的是 React 封装的 setter,而不是实际 DOM 的 setter)。为此,可以使用如下的方法绕过去:
var input = document.querySelector('.xxx');
var originalSetter =
  Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
originalSetter.call(input, 'your value here');
  1. 其次,需要触发一次 onChange 的事件,好让 React 的组件可以在原来既定的回调函数中处理新的数据:
var event = new Event('input', { bubbles: true });
input.dispatchEvent(event);

以下是一个完整可用的方法:

function change(input, value) {
  const setter =
    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
  setter.call(input, value);

  const event = new Event('input', { bubbles: true });
  input.dispatchEvent(event);
}

Object Spread and Proxy🔗

• JavaScript

ES2018 增加了 Object Spread 操作符的官方支持。Object Spread 操作符和 Object.assign 的一些区别,可以参考这篇文章。

在 JavaScript 执行 Object Spread 操作符的时候,需要进行如下的几步操作:

  1. 确定对象有哪些自己的属性。原型链上的部分是不会被 Object Spread 操作符接收的,举个例子:
const obj = Object.create(
  { parent: 1 },
  {
    current: {
      enumerable: true,
      value: 2,
    },
  },
);
console.log({ ...obj });
// output => { current: 2 }
  1. 确定第一步拿到的属性中,有那些是可枚举的(enumerable)。不可枚举的部分,不会被接收,举个例子:
const obj = Object.create(
  { parent: 1 },
  {
    current: {
      enumerable: false,
      value: 2,
    },
  },
);
console.log({ ...obj });
// output => { }
  1. 分别获取到这些属性的值

根据以上的规则,现在可以考虑这样一个场景:假设需要一个 Proxy 来修改访问对象属性的行为,比如对对象任意属性的取值,都从它的某一个子属性中去拿。例子:

const obj = { key: 'value', child: { key: 'inner-value' } };
const handler = { /* todo */ };
const proxy = new Proxy(obj, handler);
console.log(proxy.key); // => output: inner-value

这里的 handler 并不难写,只需要:

const handler = {
  get(target, key) {
    return target.child[key];
    // or:
    // return Reflect.get(target.child, key);
  },
};

现在,如果需要 Proxy 也同样可以支持 Object Spread 的功能,那么就需要对 handler 做更多的处理。从上面的分析来看,第一步获取自有属性,需要用到 ownKeys;第二步获取可枚举属性,需要用到 getOwnPropertyDescriptor;最后一步获得属性值,依然需要 get。

代码如下:

const obj = { key: 'value', child: { key: 'inner-value' } };
const handler = {
  get(target, key) {
    return Reflect.get(target.child, key);
  },
  ownKeys(target) {
    return Reflect.ownKeys(target.child);
  },
  getOwnPropertyDescriptor(target, key) {
    return Reflect.getOwnPropertyDescriptor(target.child, key);
  }
};
const proxy = new Proxy(obj, handler);
console.log({ ...proxy }); // => output: { key: 'inner-value' }

git rev-parse🔗

• Git

rev-parse 并不是 Git 中一个不常用的命令。Git 的一些命令底层会使用 rev-parse 来处理输入的参数。

通过 rev-parse 可以获得一些有用的 Git 数据,比如:

  • 获取当前的 commit id
git rev-parse HEAD
  • 获取当前的分支名
git rev-parse --symbolic-full-name --abbrev-ref HEAD

Derive Union Type from Tuple/Array🔗

• TypeScript

在 TypeScript 中,如果希望一个变量只能取某几个固定值中的一个,可以这么写:

type Type = 'a' | 'b';
const a: Type = 'a'; // ✔
const c: Type = 'c'; // ✖

然而,在实际的开发过程中,可能会遇到这样的需求:希望 TypeScript 可以限定某一个类型只能取某几个固定的值,同时这几个值又可以组成一个数组,方便 JavaScript 在运行时动态的执行匹配功能(如 Array.prototype.some)。

如果直接尝试在 TypeScript 中写数组,实际无法达到预想的效果:

const list = ['a', 'b'];
type Type = list[number]; // Type = string

这是因为,TypeScript 默认 list 的类型是 string[],而不是 ('a' | 'b')[]。因此,在转化成 Type 的时候,得到的结果是更宽泛的字符串类型,而不是限定死的两个固定值。这其中,一个很重要的原因是 JavaScript 语言的动态性。数组随时可以被加入/删除元素,因而默认只能假设这是一个字符串类型的数组,而不能过多约束。

为了达到目的,有以下几个变通的写法:

const list: ['a', 'b'] = ['a', 'b'];
type Type = list[number]; // Type = 'a' | 'b';

这种写法比较啰嗦,重新写了一遍完整的数组用于定死类型的选择范围。

也可以通过写一个辅助函数来达到类似的效果:

declare const tupleStr: <T extends string[]>(...args: T) => T;
const list = tupleStr('a', 'b');
type Type = list[number]; // Type = 'a' | 'b';

在 Ant Design 中可以找到类似的写法。这里也有一个类似的 gist。

注:上述这种写法需要 TypeScript 3.0 的支持。

当然,上述的方案或多或少都需要额外写一些东西,有些麻烦。在 TypeScript 3.4 中,可以通过 as const 这个语法来告知 TypeScript 数组是静态的、并不会增加或者减少内容。有了这样的前提假设,TypeScript 就可以更好的进行类型推导,把实际的类型结果限制到已知的几个有限的值范围内。例子如下:

const list1 = ['a', 'b'] as const;
const list2 = <const> ['a', 'b'];
type Type1 = list1[number];
type Type2 = list2[number];

上述两种写法是等价的(参考这里),都可以达到目的。另外,由于在 TypeScript 中限制了数组,之后想要在数组中做改动都是会导致编译器报错的。

参考


Command `tldr`🔗

• Bash

tldr 是一个社区维护的命令工具,可以用于输出某一个命令的说明文档。相比于 man 详尽的文档,tldr 更侧重于例子。通过具体的使用场景,介绍该命令一些常用的方法。

tldr 相关的一些链接地址:GitHub,官方网站。

安装 tldr 可以使用 brew:

brew install tldr

比如,需要了解 tar 的使用方法,可以运行下面的命令:

tldr tar

运行的结果如下:

tldr command result

注:tldr 的全称是 Too Long; Don’t Read。可以理解成“摘要”的意思。


Array.from🔗

• JavaScript

Array.from 是 JavaScript 中一个较新的 API,可以将一个类数组或可迭代对象转化成一个真正的数组。

类数组(array-like)常见于 DOM API 中取到的数据,比如 .querySelectorAll。得到的结果有 .length 属性,也可以通过下标获取到数据,但是本身却不是一个数组,没有 Array.prototype 上的 API 可以直接用。

可迭代对象则是指那些定义了 Symbol.iterator 属性的对象。

Array.from 可以将上述的两种对象直接转化成一个标准的数组:

const iterable = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
  },
};
console.log(Array.from(iterable)); // => [1, 2];

const arrayLike = document.querySelectorAll('span');
console.log(Array.from(arrayLike)); // => [span, span, ...]

除了上述常见的应用之外,Array.from 还有一些特殊的应用。

首先,只要指定了 length 属性,Array.from 就可以创建一个数组。这一行为可以用来创建一个指定长度的数组:

Array.from({ length: 5 }).map((_, i) => i);
// => [0, 1, 2, 3, 4]

其次,Array.from 函数其实接受不止一个参数。第二个参数是一个 map 函数,第三个参数是 thisArg,用于指定 map 函数的 this 对象。有了这个 map 函数的支持,上面这个例子就可以进一步改写成:

Array.from({ length: 5 }, (_, i) => i);

在转化 DOM 类数组的时候,直接通过指定 map 函数进行进一步的转化,是比较方便的。可以省略一个 .map 函数的嵌套,也节省一个中间数组对象的创建。

Array.prototype.map 函数可以指定 thisArg,在 Array.from 中也可以通过第三个参数指定 thisArg。以下是一个例子:

const mapper = {
  shift: 1,
  run(_, i) {
    return this.shift + i;
  },
};
Array.from({ length: 5 }, mapper.run, mapper);
// => [1, 2, 3, 4, 5];

上述写法等价于:

Array.from({ length: 5 }).map(mapper.run, mapper);

object-fit🔗

• CSS

object-fit 这个 CSS 样式,是针对可替换元素(replaced element)设计的。一般来说,常见的可替换元素包含图片(img)或是视频(video)。这些可替换元素的大小是事先不确定的,在实际展示的时候,需要一定的规则来决定元素实际如何被放置到元素框中去。

以下是几种 object-fit 的值及对应的显示效果(每种类型显示两个图片,第一张图片的原始尺寸大于元素显示的尺寸,第二张图片的原始尺寸小于元素显示的尺寸):

fill

big image

small image

  • 宽高比例:不保持
  • 显示范围:占满元素
  • 可能影响:显示结果宽高比失真;显示结果比原始尺寸大

contain

big image

small image

  • 宽高比例:保持
  • 显示范围:至少一轴占满,整体(另一轴)不超过元素
  • 可能影响:出现黑边(letterboxed);显示结果比原始尺寸大

cover

big image

small image

  • 宽高比例:保持
  • 显示范围:至少一轴占满,整体(另一轴)可以超过元素
  • 可能影响:超出显示范围;显示结果比原始尺寸大

none

big image

small image

  • 宽高比例:保持
  • 显示范围:原始尺寸
  • 可能影响:超出显示范围

scale-down

big image

small image

使用 none 或者 contain 的规则进行显示。具体选择哪个规则,要看两个规则生成的最终效果,哪一个更小。换句话说,如果元素的原始尺寸两轴都小于元素的显示范围,就使用 none 进行显示,显示结果是原始元素的原始尺寸;否则就是用 contain 的方式进行显示,用黑边的方式将元素压缩到显示范围内完整显示。