Things I Learned (2020-08)

Digit Display🔗

• CSS

在计时展示的时候,经常会遇到这样的问题:因为不同数字实际的“宽度”是不同的,因此在数字跳动的时候,整体的宽度会时常变化,无法对齐。

一个常见的方案,是将数字的字体设置成等宽字体(monospace):

.digits {
  font-family: monospace;
}

但是这样的方案会导致数字部分的字体和其他地方的字体出现出入,视觉上可能出现不统一的情况。

CSS 提供了一些属性来微调字体展示,从而让数字展示的时候不出现宽度变化:

  1. 第一个方案是使用 font-variant-numeric 属性,并设置值为 tabular-nums。根据 MDN 介绍,tabular-nums 会将字体设置成“等宽”的样子(占用的空间是等宽的,但是每个字本身并不一定是等宽的)。对应到 OpenType 中就是 tnum,相关的介绍可以参考这里;
  2. 第二个方案是使用 font-feature-settings 属性,并设置值为 tnum。根据 MDN 介绍,这是一个更底层的属性。效果其实和 font-variant-numeric 是一样的,且因为本身底层,并不建议直接使用。

需要注意的一点是,这一属性仅针对数字生效,对于小数点或是英文数字,并不生效。下面是一些实际的效果:

  • 3.14 <= 正常数字的展示效果
  • 3.14 <= tabular-nums 展示效果
  • 2.72 <= 同上,数字部分可以保持对齐
  • 1234 <= 对小数点无效,并没有对齐
  • wave <= 对英文字母无效,并没有对齐

Check Initial Commit🔗

• Git

在阅读开源项目代码的时候,一个常见的操作是,通过查看最开始的 commits 来了解整个项目/框架最初的设计理念。对一个发展了一段时间的大型项目来说,想要直接通过 git log 找到最初的 commits 还是比较花时间的。一个取巧的办法是通过 --reverse 参数来倒序排列所有的 commits,从而快速定位最开始的那一些。同样,可以通过 -n 参数来进一步过滤展示的条数,比如通过 -n 1 来仅展示一条 commit。

最终的命令如下:

git log --reverse -n 1

Google vs Trick Tool🔗

• Open Source Code

当用户试图比较两样东西的时候,经常会在 Google 中输入 xxx vs xxx 来进行搜索。而 Google 的搜索关键词自动填充功能可以很好得展示用户常见的输入内容。因此,当已知一个关键词 A,就很容易在 Google 中通过 A vs 这样的关键词去触发自动填充功能,从而找到和 A 近似的其他关键词 B,C,D。

比如,查找和 Webpack 相关的关键词:

desmos-graph

既然可以通过这个方法知道 A 相近的关键词 B,那么通过同样的方法,递归一下,就可以知道 B 的关键词 C。如此往复,就可以生成一张相近关键词的无向图(之所以是“无向图”是因为默认 vs 是满足交换律的)。

这里提供了一个可视化的工具,只要输入一个关键词,就可以递归的调用 Google 的接口,最终生成一个无向图。工具的源代码在这里。其中,具体查询 Google Suggestion API 的代码在这里可以找到。

关于这个 Google Suggestion API 的用法介绍(包括 Google vs 的技巧介绍),可以参考另外一篇文章。


Multiple Object Destructuring Assignment🔗

• JavaScript

在 JavaScript 中,可以通过 destructuring assignment 来对对象内的属性进行赋值。比如:

const a = { b: 1 };
const { b: c } = a;
console.log(c); // => 1

有意思的是,同一个属性可以被多次使用,比如:

const obj = { inner: { value: 1 } };
const { inner: wholeObj, inner: { value } } = obj;

console.log(wholeObj); // => { value: 1 }
console.log(value); // => value

甚至,将一个属性赋值给多个变量也是可行的:

const obj = { key: 'value' };
const { key: key1, key: key2 } = obj;

console.log(key1); // => 'value'
console.log(key2); // => 'value'

Git Stash Files🔗

• Git

一般情况下,使用 git stash 命令只会将当前工作区内的改动缓存起来。对于新生成的文件,默认是不会缓存的。然而在切分支的时候,难免会遇到这样的情况:当前分支的新文件在另一个分支下是已经提交的内容。此时如果切分支,可能会因为冲突而失败。如果希望将新文件也全部都缓存起来,可以两步走:

git add -A
git stash

以上两部可以合并成一步完成:git stash -u 或者 git stash --include-untracked。

如果希望连那些被 git ignore 的文件也一起缓存起来,可以使用 git stash -a 或者 git stash --all 命令。当然,一般不建议这样做。比如在 JavaScript 项目中,如果使用 git stash -a 命令,很有可能会将整个 node_modules 目录都缓存起来。


.bind multiple times🔗

• JavaScript

在 JavaScript 注册事件回调的时候,经常会用到 .bind 方法绑定 this 的指向。(关于 .bind 方法的使用,可以参考 MDN)。

这里有一个关于 .bind 使用的细节:根据 ECMAScript 规范的描述,.bind 只能修改 this 指向一次。

具体的规范如下:

NOTE 2: If Target is an arrow function or a bound function exotic object then the thisArg passed to this method will not be used by subsequent calls to F.

这里规范明确了两个事情:

  1. .bind 函数对已经 bind 过的函数是无效的;
  2. .bind 函数对箭头函数是无效的。

可以简单通过下面的代码验证这个行为:

function test(...args) {
  console.log(this.name, ...args);
}

const first = test.bind({ name: 'first' }, 1);
const second = first.bind({ name: 'second' }, 2);

first();  // output: "first 1"
second(); // output: "first 1 2"

可见,.bind 函数可以继续增加预置的函数参数,但是对 this 的指向无法继续改变了。

另外,在规范里还提到了一个细节:经过 .bind 之后的函数不再有 prototype 了。具体的规范如下:

NOTE 1: Function objects created using Function.prototype.bind are exotic objects. They also do not have a “prototype” property.

验证代码:

function Animal() { }
Animal.prototype.say = function () { console.log(this.name); }

const Cat = Animal.bind({ name: 'cat' });
console.log(Cat.prototype); // output: undefined

Mnemonic Device of Array.sort🔗

• JavaScript

在 JavaScript 中进行数组的排序,需要写一个比较函数,通过函数返回的结果来判断元素的顺序:如果返回的是负数,那么第一个参数在前;如果返回的是正数,那么第一个参数在后;如果返回 0,则表示两个值相等。(MDN 文档见这里)

如果觉得上面提到的这种行为不容易记忆的话,可以考虑下面的这种“直观”的写法:

const list = [8, 6, 7, 5, 3, 0, 9, 2, 4, 1];

// Ascending: 最终的数组排序结果类似从 a 排序到 z
list.sort((a, z) => a - z);

// Descending: 最终的数组排序结果类似从 z 排序到 a
list.sort((a, z) => z - a);

通过 a - z 这样的“形式”来表达从 a 排序到 z(即从小到大排序);z - a 来表达从 z 排序到 a(即从大到小排序)。最终的效果非常直观。Credit: David K. 🎹

其他类似的“直观”写法,比如说:

let i = 3;
while (i --> 0) {
  console.log(i);
}

上述表达式中,通过 --> 来创建一个类似“趋向于”的表达方式(当然,其实常见的写法应该是 i-- > 0)。

这样的写法可以在语法允许的范围内创造一些更“直观”的表达方式,至于好坏,可能见仁见智。


Latest Props in React Fiber🔗

• React

ReactDOM 在创建 DOM 元素的时候,会将 Fiber 信息也存储到 DOM 节点上(参考之前的文章)。但是需要注意的一点是,从 Fiber 上拿到的 Props 不一定是最新的数据。特别是对于使用 React Hooks 的情况来说,需要额外小心。举例来说:

const App = () => {
  const btn = React.useRef(null);
  const [value, setValue] = React.useState("");
  function onChange(event) {
    setValue(event.target.value);
  }
  function onClick() {
    if (!btn.current) return;
    const fiberKey = Object.keys(btn.current).filter((key) =>
      key.startsWith("__reactInternalInstance")
    )[0];
    if (!fiberKey) return;
    const fiber = btn.current[fiberKey];
    console.log(fiber.memoizedProps.onClick.valueInContext);
  }
  onClick.valueInContext = value;
  return (
    <form>
      <input onChange={onChange} value={value}>
      <button
        ref={btn}
        type="submit"
        onClick={onClick}
      >
        输入内容后点我
      </button>
    </form>
  );
};

在输入框输入一段文字之后点击按钮,可以看到最终在 console 输出的内容并不是完整的输入内容。比如,输入 12345 但是在 console 只输出了 1234。通过阅读 React 中对于事件处理的代码可以发现,在获取当前监听事件的时候(这里的 getListener 函数),使用了 getFiberCurrentPropsFromNode 这个函数,而这个函数的定义方法在这里。可以看到,使用的是另一个 DOM 节点上的字段:'__reactEventHandlers$' + randomKey。

因此,上面的代码只需要稍作调整,就可以正常工作了:

const App = () => {
  const btn = React.useRef(null);
  const [value, setValue] = React.useState("");
  function onChange(event) {
    setValue(event.target.value);
  }
  function onClick() {
    if (!btn.current) return;
    const propsKey = Object.keys(btn.current).filter((key) =>
      // ** 主要改动点 **
      key.startsWith("__reactEventHandlers")
    )[0];
    if (!propsKey) return;
    const props = btn.current[propsKey];
    console.log(props.onClick.valueInContext);
  }
  onClick.valueInContext = value;
  return (
    <form>
      <input onChange={onChange} value={value}>
      <button
        ref={btn}
        type="submit"
        onClick={onClick}
      >
        输入内容后点我
      </button>
    </form>
  );
};

对于 __reactEventHandlers 字段的更新,可以参考 ReactDOM 给 Reconciler 配置的两个 API:createInstance 和 commitUpdate(代码在这里)。

当然,对于 Class Component 来说,因为方法一般都是固定的成员方法,因此 __reactEventHandlers 和 __reactInternalInstance 记录下的内容没有什么差别。

另外需要注意的一点是,在 React 最新版本中,__reactInternalInstance$xxx 改名叫 __reactFiber$xxx;而 __reactEventHandlers$xxx 改名叫 __reactEvents$xxx。


isFrozen🔗

• JavaScript

在 JavaScript 中,可以通过 Object.freeze API 将一个对象“冻结”起来。操作之后,对象就无法被修改,添加或删除属性。

如果需要判断一个对象是否被执行过 Object.freeze 操作,最简单的方法,就是使用 Object.isFrozen API:

const obj = { key: 'value' };
const frozen = Object.freeze(obj);

console.log(Object.isFrozen(obj));    // => false;
console.log(Object.isFrozen(frozen)); // => true;

具体来说,Object.isFrozen API 主要是针对对象做了以下几点判断:

  1. 对象是否是不可扩展的;
  2. 对象的所有属性是否都是不可配置的(configurable = false);
  3. 对象的数据属性(没有 getter/setter 访问器属性)都是不可写的(writable = false)。

除了 Object.freeze 后的对象 isFrozen 一定为 true 外,根据上面的规则还可以构造出一些特殊的情况也返回 true:

const empty = { };
Object.preventExtensions(empty);
// 满足第一条;且因为没有其他属性,因此满足第二,第三条
console.log(Object.isFrozen(empty)); // => true

const obj = { key: 'value' };
// 满足第一条
Object.preventExtensions(obj);
// 满足第二条 & 第三条
Object.defineProperty(obj, 'key', { configurable: false, writable: false });
console.log(Object.isFrozen(obj)); // => true

const accessor = { get key() { return 'value' } };
// 满足第一条
Object.preventExtensions(accessor);
// 满足第二条
Object.defineProperty(accessor, 'key', { configurable: false });
// 因为没有数据属性,因此满足第三条
console.log(Object.isFrozen(accessor)); // => true

与 Object.freeze 类似,当 Object.isFrozen API 传入值类型/null/undefined 的时候,在 ES5 和 ES2015 环境下的行为略有不同:在 ES5 下,API 会直接报错(同 Object.freeze);在 ES2015 中则会直接返回 true。


Object.freeze🔗

• JavaScript

在 JavaScript 中,如果使用 const 定义变量,那么变量就无法再被修改。但是这种“不可变”仅限于值类型,对于引用类型来说,不可变的只有引用指针本身,其指向的内容还是可变的。

比如:

const obj = { a: 1 };

// works
obj.a = 2;

如果希望定义的对象里面的值也不可以被修改,可以使用 Object.freeze API。如:

const obj = Object.freeze({ a: 1 });

// throw error in strict mode
obj.a = 2;

事实上,Object.freeze 处理过的对象,不仅不能修改已有的数据(包括值、可枚举性、可配置性、可写性),也不能添加和删除字段。

注:如果只是单纯的希望不能增加/删除字段,但是依然可以改变对象的值,可以考虑使用 Object.seal API。

几点 Object.freeze 值得注意的细节:

  1. Object.freeze 执行后如果试图改变对象,在 strict mode 下会报错,在非 strict mode 下不会报错,但改动行为同样不会执行:
const obj = Object.freeze({ a: 1 });

// 在非 strict mode 下,下面的语句不会报错
obj.a = 2;

console.log(obj.a); // => 1
  1. Object.freeze 返回的其实就是原来的对象。因此,即使不重新赋值,原来的对象也已经变成不可变的了:
const obj = { a: 1 };
Object.freeze(obj);

// not work
obj.a = 2;
  1. 如果在 Object.freeze 之前定义了 setter,那么在 Object.freeze 之后依然是可以其效果的。但是 Object.freeze 之后就不能再定义新的 setter 了。举例来说:
(function () {
  'use strict';
  const obj = { a: 0 };
  let val = 'value';
  Object.defineProperty(obj, 'key', {
    get() {
      return val;
    },
    set(value) {
      console.log('setter triggered with value: ', value);
      val = value;
    },
  });
  Object.freeze(obj);
  // throw error:
  // obj.a = 1;

  // not throw error:
  obj.key = 'value';

  // throw error:
  Object.defineProperty(obj, 'b', {
    get() { return 1; },
    set() { },
  })
}());
  1. Object.freeze API 不会递归对 Object 内的属性进行“冻结”操作,即:
const obj = Object.freeze({ deeper: { a: 1 } });
obj.deeper.a = 2;

console.log(obj.deeper.a); // => 2

如果希望将整个对象都进行冻结,就需要递归对所有的属性进行 Object.freeze 操作:

function deepFreeze(obj) {
  for (const name of Object.getOwnPropertyNames(obj)) {
    const value = obj[name];
    if (value && typeof value === 'object') {
      deepFreeze(obj);
    }
  }
  return Object.freeze(obj);
}
  1. 在 ES2015 中,如果 Object.freeze 传入的参数是值类型/null/undefined,会直接返回;在 ES5 中传入则会报错。