前端杂记

1 并发、异步与事件循环

1.1 嵌套的异步函数调用栈显示问题

1
2
3
4
5
6
7
8
9
10
11
/**
* 判断数据是否存在
* @param key
*/
public async hasData(key: string): Promise<boolean> {
return await super.query(key)
}

public async hasData(key: string): Promise<boolean> {
return super.query(key)
}

这里的super.query(key)也是一个异步函数,返回值为Promise<boolean>

在这两个函数中,第一个函数使用了await关键字来等待super.query(key)函数的返回值,而第二个函数则直接调用了super.query(key)上述两个函数的执行逻辑表现一致,且返回值一致。但是在抛出异常时会有一些区别,主要是在调用栈的显示上。

如果super.query(key)函数抛出异常:

  • 对于第一种写法,异常会被hasData捕获。在控制台显示的调用栈中,hasData被包含在内。

  • 对于第二种写法,异常将直接抛出到hasData上层。在控制台显示的调用栈中,hasData不会被包含在内。

换言之,对于第二种写法,会导致调试信息中的调用栈显示不完整,不利于排查错误。建议在项目中采用第一种写法。

1.2 异步函数锁

JavaScript是单线程的,看似不需要引入锁机制来避免资源竞争,但是其异步特性也会导致资源被竞争操作。在具体的JavaScript项目中,如果一个比较复杂且耗时的异步函数,在同一时间被多次调用,它们之间可能会出现数据或者逻辑上的冲突。所以,我们还是有必要利用js的提供的特性来创建锁。

由于原生js并未提供核心库级别的锁(或者操作系统级别的锁),为此,我专门实现了一个Lock类,来保护那些容易受到资源竞争影响的函数。采用 Promise+队列 的加锁方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Lock {
// 等待队列
private lockedQueue = null

public async lock() {
if (this.lockedQueue === null) {
this.lockedQueue = []
} else {
await new Promise(resolve => this.lockedQueue.push(resolve))
}
}

public unlock() {
if (this.lockedQueue && this.lockedQueue.length) {
this.lockedQueue.shift()()
} else {
this.lockedQueue = null
}
}
}

具体使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const opLock = new Lock()

/**
* 需要加锁保护的异步函数(示例)
* @param key 根据已有键去set数据
* @param buf
*/
async function setData(...) {
await opLock.lock() // 等待锁的释放

// 被保护的异步函数体

opLock.unlock() // 执行完毕就释放锁
}

此外,如果对锁的操作不当,可能导致死锁出现(即await opLock.lock()永远处于pending态,函数执行被永久阻塞),以下操作可以避免死锁的出现:

  1. 如果被保护的函数存在多个return语句,那么请务必确保在每个return之前调用unlock(),避免遗漏。
  2. 若函数A和B均由同一个Lock实例进行保护,且函数A调用了函数B,那么此时会引起死锁,函数B的执行会被永远阻塞。建议不同层级的函数采用不同的锁实例来保护。
  3. 尽量避免在被保护的函数里抛出异常,因为这么做会终止当前函数执行,导致控制流跳过函数末尾的unlock(),无法释放锁。

2 工具库

2.1 Buffer的无效字符串编码问题

If encoding is 'utf8' and a byte sequence in the input is not valid UTF-8, then each invalid byte is replaced with the replacement character U+FFFD.

在Buffer库提供的buf.toString([encoding[, start[, end]]])方法中,如果 encoding'utf8' 并且输入中的字节序列不是有效的 UTF-8,则每个无效字节都会被替换为替换字符 U+FFFD

这个替换过程是静默的,不会有任何的提示。如果不想自己的二进制数据被篡改,请注意这个可能存在的静默替换过程。

3 语法特性

3.1 interface的条件可选属性

需求:在一个typescript项目中,我定义了下面这个接口:

1
2
3
4
5
6
7
interface MyInterface {
a ?: any
b ?: any
c ?: any
d ?: any
e ?: any
}

其中,abcde都是可选的属性,并且它们的可选性互不影响。但是我觉得这个interface所携带的类型约束信息有点少,因为现在有这么一个需求:如果用户实现了a属性之后,bc也必须被实现,但是de仍然保持原来的、互不影响的可选状态。要怎样修改上述代码来达到需求?

解答:一种解决方案是采用联合类型和交叉类型来增加这样的条件依赖。这个方法比较简单易懂,但是需要定义额外的interface来辅助,下面是实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 单独的 interface,其中 a、b 和 c 是必须的
interface ABC {
a: any;
b: any;
c: any;
}

// 不包括 a、b、c 的剩余属性,都是可选的
interface OptionalDE {
d?: any;
e?: any;
}

// 将原本的 MyInterface 拆分成两种情形:
// 1. 不包含 a(也意味着不包含 b 和 c)
// 2. 包含 a 以及必须的 b 和 c
interface MyInterface extends OptionalDE {
a?: never; // 如果 a 被设置,则类型将不兼容
b?: never; // b 和 c 也同样设置为 never,因为如果 a 不在,b 和 c 也不应当存在
c?: never;
}

// 创建一个联合类型,要么 MyInterface 没有 a、b、c,要么 ABC 必须有 a、b、c,且都组合了 OptionalDE 的 d 和 e
type NewMyInterface = MyInterface | (ABC & OptionalDE);

// 使用例子
let valid1: NewMyInterface = { d: 123 }; // 正确:没有 a、b、c
let valid2: NewMyInterface = { a: 'foo', b: 'bar', c: 'baz', e: 456 }; // 正确:有 a、b、c 和可选的 e
let invalid1: NewMyInterface = { a: 'foo' }; // 报错:有 a,但是没有 b 和 c
let invalid2: NewMyInterface = { b: 'bar' }; // 报错:不能只有 b 和 c,abc要一起出现

上述代码创建了三个interface和一个type,ABC 必须同时具有 a、b 和 c, OptionalDE 包含可选的 d 和 e,MyInterface 定义为没有 a、b、c 属性,但包含可选的 d 和 e。最终的 NewMyInterface 是一个联合类型,也是我们最终要得到的结果。

当创建一个类型为NewMyInterface的对象时,如果仅提供了a,没有提供b和c,编译器就会报错。同样,如果只提供了b而没有a和c,也会让编译器报错。

常见的bug诱因

下面介绍了一些虽然众所周知,却常常被不小心遗忘的bug诱因。这些也是我在实际项目中遇到过的:

1 git文件名不区分大小写

在git中,文件和文件夹的名称不区分大小写,git会自动忽略大小写的差异。比如在git库中有一个文件名为example.txt,那么无论是example.txt、EXAMPLE.TXT还是eXaMpLe.TxT都会被视为同一个文件。

2 调用无返回值的异步函数时遗漏await

调用无返回值的异步函数时,如果不小心遗漏了await,那么即使typescript环境处于严格模式,这种遗漏也不会报错,需要我们额外注意。

3 try-catch只能捕获同步代码块中的异常

1
2
3
4
5
6
try {
requestIdleCallback(async () => {
// code1
})
}
catch { }

try-catch语句只能捕获同步代码块中的异常,而requestIdleCallback是一个异步函数。需要在回调函数内部使用try-catch语句来捕获异常。