JavaScript 异步时序问题

场景

死后我们必升天堂,因为活时我们已在地狱。

不知你是否遇到过,向后台发送了多次异步请求,结果最后显示的数据却并不正确 – 是旧的数据。

具体情况:

  1. 用户触发事件,发送了第 1 次请求
  2. 用户触发事件,发送了第 2 次请求
  3. 第 2 次请求成功,更新页面上的数据
  4. 第 1 次请求成功,更新页面上的数据

嗯?是不是感觉到异常了?这便是多次异步请求时会遇到的异步回调顺序与调用顺序不同的问题。

思考

  • 为什么会出现这种问题?
  • 出现这种问题怎么解决?

为什么会出现这种问题?

JavaScript 随处可见异步,但实际上并不是那么好控制。用户与 UI 交互,触发事件及其对应的处理函数,函数执行异步操作(网络请求),异步操作得到结果的时间(顺序)是不确定的,所以响应到 UI 上的时间就不确定,如果触发事件的频率较高/异步操作的时间过长,就会造成前面的异步操作结果覆盖后面的异步操作结果。

关键点

  • 异步操作得到结果的时间(顺序)是不确定的
  • 如果触发事件的频率较高/异步操作的时间过长

出现这种问题怎么解决?

既然关键点由两个要素组成,那么,只要破坏了任意一个即可。

  • 手动控制异步返回结果的顺序
  • 降低触发频率并限制异步超时时间

手动控制返回结果的顺序

根据对异步操作结果处理情况的不同也有三种不同的思路

  1. 后面异步操作得到结果后等待前面的异步操作返回结果
  2. 后面异步操作得到结果后放弃前面的异步操作返回结果
  3. 依次处理每一个异步操作,等待上一个异步操作完成之后再执行下一个

这里先引入一个公共的 wait 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* 注: 此实现在 nodejs 10- 会存在宏任务与微任务的问题,切记 async-await 本质上还是 Promise 的语法糖,实际上并非真正的同步函数!!!即便在浏览器,也不要依赖于这种特性。
* @param param 等待时间/等待条件
* @returns Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
const timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}

1. 后面异步操作得到结果后等待前面的异步操作返回结果

  1. 为每一次的异步调用都声称一个唯一 id
  2. 使用列表记录所有的异步 id
  3. 在真正调用异步操作后,添加一个唯一 id
  4. 判断上一个正在执行的异步操作是否完成
  5. 如果未完成等待上一个异步操作完成,否则直接跳过
  6. 从列表中删除掉当前的 id
  7. 最后等待异步操作然后返回结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 将一个异步函数包装为具有时序的异步函数
* 注: 该函数会按照调用顺序依次返回结果,后面的调用的结果需要等待前面的,所以如果不关心过时的结果,请使用 {@link switchMap} 函数
* @param fn 一个普通的异步函数
* @returns 包装后的函数
*/
function mergeMap(fn) {
// 当前执行的异步操作 id
let id = 0
// 所执行的异步操作 id 列表
const ids = new Set()
return new Proxy(fn, {
async apply(_, _this, args) {
const prom = Reflect.apply(_, _this, args)
const temp = id
ids.add(temp)
id++
await wait(() => !ids.has(temp - 1))
ids.delete(temp)
return await prom
},
})
}

测试一下

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
;(async () => {
// 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
async function get(ms) {
await wait(ms)
return ms
}
const fn = mergeMap(get)
let last = 0
let sum = 0
await Promise.all([
fn(30).then(res => {
last = res
sum += res
}),
fn(20).then(res => {
last = res
sum += res
}),
fn(10).then(res => {
last = res
sum += res
}),
])
console.log(last)
// 实际上确实执行了 3 次,结果也确实为 3 次调用参数之和
console.log(sum)
})()

2. 后面异步操作得到结果后放弃前面的异步操作返回结果

  1. 为每一次的异步调用都声称一个唯一 id
  2. 记录最新得到异步操作结果的 id
  3. 记录最新得到的异步操作结果
  4. 执行并等待返回结果
  5. 判断本次异步调用后面是否已经有调用出现结果了
    1. 是的话就直接返回后面的异步调用结果
    2. 否则将本地异步调用 id 及其结果最为[最后的]
    3. 返回这次的异步调用结果
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
/**
* 将一个异步函数包装为具有时序的异步函数
* 注: 该函数会丢弃过期的异步操作结果,这样的话性能会稍稍提高(主要是响应比较快的结果会立刻生效而不必等待前面的响应结果)
* @param fn 一个普通的异步函数
* @returns 包装后的函数
*/
function switchMap(fn) {
// 当前执行的异步操作 id
let id = 0
// 最后一次异步操作的 id,小于这个的操作结果会被丢弃
let last = 0
// 缓存最后一次异步操作的结果
let cache
return new Proxy(fn, {
async apply(_, _this, args) {
const temp = id
id++
const res = await Reflect.apply(_, _this, args)
if (temp < last) {
return cache
}
cache = res
last = temp
return res
},
})
}

测试一下

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
;(async () => {
// 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
async function get(ms) {
await wait(ms)
return ms
}
const fn = switchMap(get)
let last = 0
let sum = 0
await Promise.all([
fn(30).then(res => {
last = res
sum += res
}),
fn(20).then(res => {
last = res
sum += res
}),
fn(10).then(res => {
last = res
sum += res
}),
])
console.log(last)
// 实际上确实执行了 3 次,然而结果并不是 3 次调用参数之和,因为前两次的结果均被抛弃,实际上返回了最后一次发送请求的结果
console.log(sum)
})()

3. 依次处理每一个异步操作,等待上一个异步操作完成之后再执行下一个

  1. 为每一次的异步调用都声称一个唯一 id
  2. 使用列表记录所有的异步 id
  3. 向列表中添加一个唯一 id
  4. 判断上一个正在执行的异步操作是否完成
  5. 如果未完成等待上一个异步操作完成,否则直接跳过
  6. 真正调用异步操作
  7. 从列表中删除掉当前的 id
  8. 最后等待异步操作然后返回结果
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
/**
* 将一个异步函数包装为具有时序的异步函数
* 注: 该函数会按照调用顺序依次返回结果,后面的执行的调用(不是调用结果)需要等待前面的,此函数适用于异步函数的内里执行也必须保证顺序时使用,否则请使用 {@link mergeMap} 函数
* 注: 该函数其实相当于调用 {@code asyncLimiting(fn, {limit: 1})} 函数
* 例如即时保存文档到服务器,当然要等待上一次的请求结束才能请求下一次,不然数据库保存的数据就存在谬误了
* @param fn 一个普通的异步函数
* @returns 包装后的函数
*/
function concatMap(fn) {
// 当前执行的异步操作 id
let id = 0
// 所执行的异步操作 id 列表
const ids = new Set()
return new Proxy(fn, {
async apply(_, _this, args) {
const temp = id
ids.add(temp)
id++
await wait(() => !ids.has(temp - 1))
const prom = Reflect.apply(_, _this, args)
ids.delete(temp)
return await prom
},
})
}

测试一下

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
;(async () => {
// 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
async function get(ms) {
await wait(ms)
return ms
}
const fn = concatMap(get)
let last = 0
let sum = 0
await Promise.all([
fn(30).then(res => {
last = res
sum += res
}),
fn(20).then(res => {
last = res
sum += res
}),
fn(10).then(res => {
last = res
sum += res
}),
])
console.log(last)
// 实际上确实执行了 3 次,然而结果并不是 3 次调用参数之和,因为前两次的结果均被抛弃,实际上返回了最后一次发送请求的结果
console.log(sum)
})()

小结

虽然三个函数看似效果都差不多,但还是有所不同的。

  1. 是否允许异步操作并发?否: concatMap, 是: 到下一步
  2. 是否需要处理旧的的结果?否: switchMap, 是: mergeMap

降低触发频率并限制异步超时时间

思考一下第二种解决方式,本质上其实是 限流 + 自动超时,首先实现这两个函数。

  • 限流: 限制函数调用的频率,如果调用的频率过快则不会真正执行调用而是返回旧值
  • 自动超时: 如果到了超时时间,即便函数还未得到结果,也会自动超时并抛出错误

下面来分别实现它们

限流实现

具体实现思路可见: JavaScript 防抖和节流

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
/**
* 函数节流
* 节流 (throttle) 让一个函数不要执行的太频繁,减少执行过快的调用,叫节流
* 类似于上面而又不同于上面的函数去抖, 包装后函数在上一次操作执行过去了最小间隔时间后会直接执行, 否则会忽略该次操作
* 与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作, 而非仅执行最后一次操作
* 注: 该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值
* 注: 返回函数结果的高阶函数需要使用 {@link Proxy} 实现,以避免原函数原型链上的信息丢失
*
* @param {Number} delay 最小间隔时间,单位为 ms
* @param {Function} action 真正需要执行的操作
* @return {Function} 包装后有节流功能的函数。该函数是异步的,与需要包装的函数 {@link action} 是否异步没有太大关联
*/
const throttle = (delay, action) => {
let last = 0
let result
return new Proxy(action, {
apply(target, thisArg, args) {
return new Promise(resolve => {
const curr = Date.now()
if (curr - last > delay) {
result = Reflect.apply(target, thisArg, args)
last = curr
resolve(result)
return
}
resolve(result)
})
},
})
}

自动超时

注: asyncTimeout 函数实际上只是为了避免一种情况,异步请求时间超过节流函数最小间隔时间导致结果返回顺序错乱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 为异步函数添加自动超时功能
* @param timeout 超时时间
* @param action 异步函数
* @returns 包装后的异步函数
*/
function asyncTimeout(timeout, action) {
return new Proxy(action, {
apply(_, _this, args) {
return Promise.race([
Reflect.apply(_, _this, args),
wait(timeout).then(Promise.reject),
])
},
})
}

结合使用

测试一下

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
;(async () => {
// 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
async function get(ms) {
await wait(ms)
return ms
}
const time = 100
const fn = asyncTimeout(time, throttle(time, get))
let last = 0
let sum = 0
await Promise.all([
fn(30).then(res => {
last = res
sum += res
}),
fn(20).then(res => {
last = res
sum += res
}),
fn(10).then(res => {
last = res
sum += res
}),
])
// last 结果为 10,和 switchMap 的不同点在于会保留最小间隔期间的第一次,而抛弃掉后面的异步结果,和 switchMap 正好相反!
console.log(last)
// 实际上确实执行了 3 次,结果也确实为第一次次调用参数的 3 倍
console.log(sum)
})()

起初吾辈因为好奇实现了这种方式,但原以为会和 concatMap 类似的函数却变成了现在这样 – 更像倒置的 switchMap 了。不过由此看来这种方式的可行性并不大,毕竟,没人需要旧的数据。

总结

其实第一种实现方式属于 rxjs 早就已经走过的道路,目前被 Angular 大量采用(类比于 React 中的 Redux)。但 rxjs 实在太强大也太复杂了,对于吾辈而言,仅仅需要一只香蕉,而不需要拿着香蕉的大猩猩,以及其所处的整个森林(此处原本是被人吐槽面向对象编程的隐含环境,这里吾辈稍微藉此吐槽一下动不动就上库的开发者)。

可以看到吾辈在这里大量使用了 Proxy,那么,原因是什么呢?这个疑问就留到下次再说吧!

JavaScript 规范整理

场景

圣人走过的道路,荆棘遍布,火焰片片焚烧……

日常 review 代码时看到一些奇怪的代码,这里记录一下重构方案以及原因。

命名规范

不要使用拼音命名

如果不熟悉英语,可以使用 Codelf 或者 Google 翻译,避免使用拼音命名。

错误示例

1
2
// 这里是用户状态
const yongHuZhuangTai = 1

正确示例

1
const userStatus = 1

函数中的变量

js 中普通变量使用 小写开头驼峰命名法,而非不区分大小写,或使用下划线命名等等。

错误示例

1
2
// 用户操作日志备注
const useroperatinglogremark = '新增用户'

正确示例

1
const userOperatingLogRemark = '新增用户'

内部变量

如果需要不想让使用者使用的属性(能够看到),需要使用下划线开头。例如 _value,代表内部的值,外部不应该直接访问(实际上可以做到)。

1
2
3
4
5
6
7
8
9
10
11
12
class DateFormat {
constructor(fmt) {
// 不想让外部使用
this._fmt = fmt
}
format(date) {
// 具体格式化代码
}
parse(str) {
// 具体解析代码
}
}

不要使用无意义的前缀命名

如果一个对象的变量名已经很好的标识了该对象,那么内部的属性就不能使用对象名作为前缀!

错误示例

1
2
3
4
5
// 活跃的日志信息
const activeLog = {
activeUserId: 'rx',
activeTime: new Date(),
}

正确示例

1
2
3
4
const activeLog = {
userId: 'rx',
time: new Date(),
}

ES6

优先使用 const/let

一般情况下,使用 const/let 声明变量,而不是使用 var。因为使用 var 声明的变量会存在变量提升。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
;(function() {
// 使用 var 声明的变量(初始值为 undefined)
console.log(i)
i = 1
console.log(i)
// 此时使用 var 声明的变量 i 相当于在 function 顶部声明,然后在此处进行了赋值操作
var i = 0

// 使用 const 声明的变量(抛出异常 k is not defined)
// console.log(k)
k = 1
const k = 0
})()

关于可以参考 let 与 var 在 for 循环中的区别

使用新的函数声明方式

ES6 推出了一种更简洁的函数声明方式,不需要在写 function,只要 名字 + () 即可在 classObject 中声明函数。

错误示例

1
2
3
4
5
6
const user = {
name: 'rx',
hello: function() {
console.log('hello' + this.name)
},
}

正确示例

1
2
3
4
5
6
const user = {
name: 'rx',
hello() {
console.log('hello' + this.name)
},
}

优先使用箭头函数而非 function

优先使用 箭头函数 而不是使用传统的函数,尤其是使用 匿名函数 时,更应如此。

错误示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const sum = [1, 2, 3, 4]
// 过滤出偶数
.filter(function(i) {
return i % 2 === 0
})
// 将偶数翻倍
.map(function(i) {
return i * 2
})
// 计算总和
.reduce(function(res, i) {
return (res += i)
})

console.log(sum)

正确示例

1
2
3
4
5
6
7
8
9
const sum = [1, 2, 3, 4]
// 过滤出偶数
.filter(i => i % 2 === 0)
// 将偶数翻倍
.map(i => i * 2)
// 计算总和
.reduce((res, i) => (res += i))

console.log(sum)

不要使用 if 判断再赋予默认值

如果函数需要对参数做默认值处理,请不要使用 if 判空之后再修改参数,而是使用 ES6 的 默认参数解构赋值

主要优点

  • 减少代码,JavaScript 是动态语言,维护起来较为麻烦,代码越少,错误越少
  • 清晰明了,可以让人一眼就能看出这个参数的默认值,而不需要关心函数内部的逻辑
  • IDE 大多对此进行了支持,代码提示时便会告诉我们参数是可选的并且有默认值

错误示例

1
2
3
4
5
6
7
8
9
10
11
/**
* 格式化日期
* @param {Date} [date] 日期对象。默认为当前时间
* @return {String} 格式化日期字符串
*/
function formatDate(date) {
if (date === undefined) {
date = new Date()
}
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}

正确示例

1
2
3
function formatDate(date = new Date()) {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}

这里如果展开来讲实在太多,请参考 JavaScript 善用解构赋值

优先使用 Map 做键值对映射而非传统的对象

如果需要 键值映射,不要使用一般的对象,而是用 ES6 的 Map。它不仅可以使用 任意类型的键,另外 Map 本身也是 有序 的哦

错误示例

1
2
3
4
5
6
7
8
9
const obj = {
2: '琉璃',
1: 'rx',
'1': 'liuli',
}
// 结果为 true,因为属性 1 实际上会被转换为 '1'
console.log(obj[1] === obj['1'])
// 结果为 [ '1', '2'],因为是按照属性字符串排序的
console.log(Object.keys(obj))

正确示例

1
2
3
4
5
6
7
8
const map = new Map()
.set(2, '琉璃')
.set(1, 'rx')
.set('1', 'liuli')
// 结果为 false
console.log(map.get(1) === map.get('1'))
// 结果为 [ 2, 1, '1' ],因为是按照插入顺序排序的
console.log(Array.from(map.keys()))

优先使用模板字符串拼接多个字符串变量

如果需要拼接多个对象以及字符串时,不要使用 + 进行拼接,使用 es6 的 模板字符串 会更好一点。一般而言,如果需要拼接的变量超过 3 个,那么就应该使用模板字符串了。

错误示例

1
2
3
function hello(name, age, sex) {
return 'name: ' + name + ', age: ' + age + ', sex: ' + sex
}

正确示例

1
2
3
function hello(name, age, sex) {
return `name: ${name}, age: ${age}, sex: ${sex}`
}

当独立参数超过 3 个时使用对象参数并解构

错误示例

1
2
3
function hello(name, age, sex) {
return `name: ${name}, age: ${age}, sex: ${sex}`
}

正确示例

1
2
3
function hello({ name, age, sex }) {
return `name: ${name}, age: ${age}, sex: ${sex}`
}

不要写多余的 await

如果 await 是不必要的(在返回语句时,那么就不要用 async 标识函数),这是没有必要的 – 除非,你需要在这个函数内异步操作完成后有其他操作。

错误示例

1
2
3
4
5
6
7
8
9
10
11
12
const login = async ({ username, password }) => {
if (!useranme) {
console.log('用户名不能为空')
return
}
if (!password) {
console.log('密码不能为空')
return
}
// 真正发起登录请求
return await userApi.login(user)
}

正确示例

1
2
3
4
5
6
7
8
9
10
11
12
const login = ({ username, password }) => {
if (!useranme) {
console.log('用户名不能为空')
return
}
if (!password) {
console.log('密码不能为空')
return
}
// 真正发起登录请求
return userApi.login(user)
}

不要使用 == 进行比较

在 js 中使用 == 比较相当危险,你永远不知道 js 到底是按照什么类型比较的,因为 js 会做各种隐式转换。而如果使用 === 比较,则会同时比较 类型 是否都相同,避免了各种不确定的问题。

错误示例

1
2
3
4
5
console.log(1 == true) // true
console.log(1 == '1') // true
console.log('1' == true) // true
console.log('0' == true) // false
console.log([] == []) // false

扪心自问,你真的知道上面为什么会出现这种结果么?即便知道,对于其他人而言仍然是难以预测的,所以抛弃掉 == 吧,学会使用更好的 === 最好

1
2
3
4
5
console.log(1 == true) // false
console.log(1 == '1') // false
console.log('1' == true) // false
console.log('0' == true) // false
console.log([] == []) // false

使用计算属性名替代使用方括号表示法赋值

目前而言已经有了 计算属性名 用以在初始化时计算属性名,所以不需要再先声明对象再使用 方括号表示法 进行赋值了。

ES5 写法

1
2
3
4
5
6
7
const state = {
'user.username': function() {},
}

state[Date.now()] = new Date()

console.log(state)

ES6 写法

1
2
3
4
5
6
const state = {
'user.username'() {},
[Date.now()]: new Date(),
}

console.log(state)

简单的选项列表优先使用 Map 而非数组

对于复选框,想必很多人相当熟悉。下面使用 js 模拟一个复选框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const item = {
id: 1,
role: ['1', '2'],
name: '',
}
const options = [
{
roleid: '1',
label: '黄金糕',
},
{
roleid: '2',
label: '双皮奶',
},
{
roleid: '3',
label: '蚵仔煎',
},
]

现在的需求是根据 role 计算显示值 name 的值

1
2
3
4
item.name = item.role
.map(role => options.find(op => op.roleid === role))
.filter(s => s)
.join(',')

但实际上这里应该使用 Map 替代数组,因为数组的 find 其实非常低效,也需要进行遍历,使用 Map 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const item = {
id: 1,
role: [1, 2],
name: '',
}
const options = new Map()
.set(1, '黄金糕')
.set(2, '双皮奶')
.set(3, '蚵仔煎')

function calcName(role) {
return role
.map(k => options.get(k))
.filter(s => s)
.join(',')
}

item.name = calcName(item.role)

可以看到,获取时使用了 Map#get,在效率上应该是极好的。

附: 该问题来自 https://segmentfault.com/q/1010000019426996

存放 id 标识列表使用 Set 而非数组

还是上面的例子,当你需要存取当前选中复选框的值 role 使用数组时,有可能遇到 id 重复的问题,实际上导致每次添加前需要使用 Array#includes 判断是否已存在。这里可以使用 Set 从数据结构层面避免掉可能重复的问题。

修改后的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const item = {
id: 1,
role: new Set([1, 2]),
name: '',
}
const options = new Map()
.set(1, '黄金糕')
.set(2, '双皮奶')
.set(3, '蚵仔煎')

function calcName(role) {
return Array.from(role)
.map(k => options.get(k))
.filter(s => s)
.join(',')
}

item.name = calcName(item.role)

先判断是否存在执行某些操作也非常方便,可以使用 Set#has 进行判断,当然时间复杂度时 O1。

逻辑代码

不要判断一个 Boolean 值并以此返回 Boolean 值

不要在得到一个 Boolean 的值后使用 if-else 进行判断,然后根据结果返回 truefalse,这真的显得非常非常蠢!

错误示例

1
2
3
4
5
6
7
8
9
// 模拟登录异步请求
const login = async ({ username, password }) => {
const res = username === 'rx' && password === 'rx'
if (res) {
return true
} else {
return false
}
}

正确示例

1
2
3
// 模拟登录异步请求
const login = async ({ username, password }) =>
username === 'rx' && password === 'rx'

不要使用多余的变量

如果一个表达式立刻被使用并且只会被使用一次,那就不要使用变量声明,直接在需要的地方使用好了。

错误示例

1
2
3
4
5
// 模拟登录异步请求
const login = async ({ username, password }) => {
const res = username === 'rx' && password === 'rx'
return res
}

正确示例

1
2
3
4
// 模拟登录异步请求
const login = async ({ username, password }) => {
return username === 'rx' && password === 'rx'
}

不要使用嵌套 if

不要使用多级的 if 嵌套,这会让代码变得丑陋且难以调试,应当优先使用 提前 return 的策略。

错误示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 模拟登录异步请求
const login = async ({ username, password }) =>
username === 'rx' && password === 'rx'

async function submit(user) {
const { username, password } = user
if (username) {
if (password) {
const res = await login(user)
if (res) {
console.log('登录成功,即将跳转到首页')
} else {
console.log('登录失败,请检查用户名和密码')
}
} else {
console.log('用户密码不能为空')
}
} else {
console.log('用户名不能为空')
}
}

正确示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 模拟登录异步请求
const login = async ({ username, password }) =>
username === 'rx' && password === 'rx'

async function submit(user) {
const { username, password } = user
if (!username) {
console.log('用户名不能为空')
return
}
if (!password) {
console.log('用户密码不能为空')
return
}
const res = await login(user)
if (!res) {
console.log('登录失败,请检查用户名和密码')
return
}
console.log('登录成功,即将跳转到首页')
}

不要先声明空对象然后一个个追加属性

有时候会碰到这种情况,先声明一个空对象,然后在下面一个个追加属性,为什么创建对象与初始化不放到一起做呢?

错误示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 模拟登录异步请求
const login = async ({ username, password }) =>
username === 'rx' && password === 'rx'

async function submit(username, password) {
// 数据格式校验处理。。。

const user = {}
user.username = username.trim()
user.password = password.trim()

const res = await login(user)

// 后续的错误处理。。。
}

正确示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 模拟登录异步请求
const login = async ({ username, password }) =>
username === 'rx' && password === 'rx'

async function submit(username, password) {
// 数据格式校验处理。。。

const user = {
username: username.trim(),
password: password.trim(),
}

const res = await login(user)

// 后续的错误处理。。。
}

不要使用无意义的函数包裹

使用函数时,如果你想包裹的函数和原来函数的参数/返回值相同,那就直接应该使用函数作为参数,而非在包裹一层。给人的感觉就像是大夏天穿着棉袄吃雪糕 – 多此一举!

错误示例

1
2
3
4
// 判断是否是偶数的函数
const isEven = i => i % 2 === 0
//过滤出所有偶数
const res = [1, 2, 3, 4].filter(i => isEven(i))

正确示例

1
2
3
4
// 判断是否是偶数的函数
const isEven = i => i % 2 === 0
//过滤出所有偶数
const res = [1, 2, 3, 4].filter(isEven)

不要使用三元运算符进行复杂的计算

三元运算符适合于替代简单的 if-else 的情况,如果碰到较为复杂的情况,请使用 if + return 或者解构/默认参数的方式解决。

错误示例

1
2
3
4
5
6
7
8
function formatUser(user) {
return user === undefined
? 'username: noname, password: blank'
: 'username: ' +
(user.username === undefined ? 'noname' : user.username) +
', password: ' +
(user.password === undefined ? 'blank' : user.password)
}

看到上面的代码就感觉到一股烂代码的味道扑面而来,这实在是太糟糕了!实际上只需要两行代码就好了!

正确示例

1
2
3
function formatUser({ username = 'noname', password = 'blank' } = {}) {
return `username: ${username}, password: ${password}`
}

如果变量有所关联则使用对象而非多个单独的变量

如果变量有所关联,例如一个表单,存储的时候不要使用单独的变量,将之存储到一个表单变量中更好。

错误示例

1
2
3
4
5
6
7
8
9
10
11
12
13
function login(){
const username = document.querySelector('#username')
const password = document.querySelector('#password')
const remeberMe = document.querySelector('#remeberMe')

// 一些校验。。。
if (!validate(username, password, remeberMe)) {
return
}
// 请求后台
const res = await userLogin.login(username, password, remeberMe)
// 后处理。。。
}

正确示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function login(){
const user = {
username : document.querySelector('#username'),
password : document.querySelector('#password'),
remeberMe : document.querySelector('#remeberMe'),
}
// 一些校验。。。
if (!validate(user)) {
return
}
// 请求后台
const res = await userLogin.login(user)
// 后处理。。。
}

应该尽量解决编辑器警告

如果编辑器对我们的代码发出警告,那么一般都是我们代码出现了问题(一般开发人员的能力并不足以比肩编辑器 #MS 那些 dalao 的能力)。所以,如果出现了警告,应该先去解决它 – 如果你确认发生了错误,则通过注释/配置禁用它!

使用类型定义参数对象

如果一个函数需要一个对象参数,最好专门定义一个类型,并在注释上说明,便于在使用时 IDE 进行提示,而不需要去查找文档手册。

错误示例

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 格式化用户
* @param {Object} user 格式化的用户对象
*/
function formatUser(user) {
const { username, password } = user || {}
return `user, username: ${username}, password: ${password}`
}

// 此处别人并不知道 User 里面到底有什么属性,只能去查看文档
const str = formatUser({ username: 'rx', password: '123456' })
console.log(str)

正确示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class User {
constructor(username, password) {
this.username = username
this.password = password
}
}

/**
* 格式化用户
* @param {User} user 格式化的用户对象
*/
function formatUser(user) {
const { username, password } = user || {}
return `user, username: ${username}, password: ${password}`
}

const str = formatUser(new User('rx', '123456'))
console.log(str)

尽量扁平化代码

尽量将 a 调用 b, b 调用 c,然后 b 调用 d,优化为依次调用 a, b, c, d

注意: 这里使用的是尽量而非不要,深层嵌套不可避免,但在局部上,应该采取扁平化的策略,提前 return 避免嵌套 if-else 是个很好的例子。

自执行函数前面必须加分号

如果我们需要使用自执行函数,则开头必须加上 ; 以避免可能出现的歧义。

错误示例

1
2
3
4
5
6
7
function returnItself(o) {
return o
}
returnItself(() => console.log(1))
(() => {
console.log(2)
})()

上面这段代码是有问题的,因为后面的自执行函数会被认为是上一句的 returnItself 返回函数的参数,最后的括号会被认为又是一次调用,将会抛出错误

1
returnItself(...)(...) is not a function

正确示例

1
2
3
4
5
6
7
function returnItself(o) {
return o
}
returnItself(() => console.log(1))
;(() => {
console.log(2)
})()

使用分号可以明确告诉 JavaScript 这是一行新的代码,上面的代码已经到此为止了。

优化 Google Chrome 的使用体验

前言

假若我没有看见光明,我本可以忍受黑暗。

下面是吾辈在使用 Chrome 遇到的一些不舒服的地方,以及对应的解决方法。一切皆是为了一个目标:提高浏览器的使用体验!

字体

在之前吾辈也未曾对字体有过什么注意,直到后来听闻 MacOS 的字体显示比 Windows 上好很多,去看了一下确实如此。想要有一个好看的字体,字体本身极为重要,这里吾辈目前在使用,也很推荐的字体是 Sarasa Gothic。支持 简中/繁中/英/日 四种语言,虽然体积稍微庞大,但效果却是相当不错。

Windows 字体预览

更纱黑体

这里也推荐作为编程字体,毕竟程序中同时存在中英文,而一个同时支持中英文的等宽字体实在难得。

设置网页默认字体

安装完了字体,然而 Chrome 默认并不会使用它,我们还需要 Stylus 进行指定。

添加 UserCSS

添加一个新的样式,内容只需要设置所有元素使用的字体为 Sarasa Mono CL

1
2
3
4
5
6
7
8
/* 全局字体设置 */
* {
font-family: 'Sarasa Mono CL';
}
/* 强制指定 input 框中的字体 */
input {
font-family: 'Sarasa Mono CL' !important;
}

这时候查看一下字体效果

效果

夜间模式

如果你像吾辈一样,喜欢暗色的主题,可以使用插件 Dark Reader,它能够让网页默认使用暗色模式,看起来和编辑器保持一致:并且,看起来很 Geek

暗色模式

看起来标题栏很违和?安装一个 Dark 主题 试试看。
现在,是不是变得很和谐了呢?

暗色标题栏

广告过滤

目前而言,浏览网站时,没有一个广告过滤插件的话,广告的数量将是难以置信的庞大,而且讨人厌!
吾辈目前在 Chrome 上仅推荐 uBlock Origin,开源免费,不推荐 Adblock PlusUblock。前者将广告拦截做成了生意(参见 向来以屏蔽互联网广告为己任的 AdBlock Plus,为什么卖起广告了?),后者则是接手开发者的自私接受捐款导致原作者 Fork 并开发了新版本 Ublock Origin(参见 Wiki uBlock Origin 历史)。

虽然吾辈基本上日常 Google,不过为了比较这里来看一下未进行广告过滤前的百度搜索结果

搜索购物,天啊,第一页全都是广告,百度真的丧心病狂。。。

注意每个搜索结果下面的小字 广告

百度的广告

使用插件后的效果

过滤后的百度搜索

嗯,清爽了许多呢 ~ o(* ̄ ▽  ̄*)o

这里对比一下 Google 的搜索结果,可以明显看出来百度的广告的数量之多。。。

对比的 Google 搜索结果

自动翻页

如果你也觉得搜索结果需要翻页好麻烦,那么 uAutoPagerize 可以一样可以帮到你!

相比于 AutoPagerize 万年不更新,uAutoPagerize 仍在积极维护中!

下面是使用了 uAutoPagerize 后的 Google 搜索结果,会在滚动到接近底部时,自动获取下一页的内容并拼接到最后!

使用 uAutoPagerize

当然,它也支持百度哦

屏蔽 Google 搜索结果

当你搜索中文技术相关的内容时,一定会遇到一个令人厌恶的社区 – CSDN 博客。里面的内容基本上都是复制粘贴,甚至作者都并未真实尝试过就发出来了,实在是太烂了!
所以,使用 Tampermonkey 油猴插件 + 油猴脚本便可以轻松屏蔽掉它们。

吾辈并不否认 CSDN 有很多有趣的作者,但是啊,相比于这个平台的大多数人,他们实在太少了,简直如同大海捞针一般。
油猴脚本: 是一段可以在某个网页自动运行的 JavaScript 脚本,事实上,抛开 Tampermonkey 这个运行容器不说,油猴脚本就是彻头彻尾的 JavaScript 代码,任何了解过 Web 开发的人应该都能写一个简单的油猴脚本。如果你打算尝试玩玩油猴脚本,可以参考吾辈踩过的坑 Greasemonkey 踩坑之路

你需要先安装 Tampermonkey 插件,然后在 Greasy Fork 安装脚本 Google Hit Hider by Domain

然后在 Google 搜索 js 数组去重,可以看到包含了几个的 CSDN 博客的结果

CSND 博客搜索结果

现在,让我们从 Google 搜索中屏蔽 blog.csdn.net 这个域名

Block 操作

屏蔽后的搜索结果,CSDN Blog 那些垃圾博客不见了,心情大好!

屏蔽后的结果

冻结后台标签页

你是否也曾因为 Chrome 开了太多标签页而占用了庞大的内存?那么 The Great Suspender 就是最好的帮手,它能自动冻结后台一段时间没有访问的标签页,以便于释放系统内存。并且,当你再次访问时,标签页将会自动重新加载。如果你也经常开了很多个标签页忘记关闭了,那么它可以帮助你自动管理他们。

下面演示多个标签页被冻结的效果

冻结的标签页

下载增强

你使用 Chrome 下载过资料么?是否也对 Chrome 单线程下载并且在下载完成后强制检查资源安全性感到不满?那么 FDM 应该是 Windows 上比较好的选择下载工具了,你可以下载并安装到 PC 上,然后安装 Chrome 插件 Free Download Manager 即可将所有 Chrome 中的下载请求交给 FDM,并且,它携带着 Cookie,所以即使是有权限校验的下载也能够胜任。

FDM 的优势

  • 多线程下载
  • 断点续传
  • 支持 BT
  • 自动分类
  • 下载限速
  • 国际化
  • 自由免费

所以,如果经常下载资料的话推荐入坑 FDM,这里放一张首页

FDM 首页

快捷键

使用浏览器,一些高频操作的快捷键也是必不可少的。

  • CS-T: 重新打开上一个关闭的标签页
  • 中键/C-左键: 强制在新标签页打开链接
  • 中键(浏览器标签上): 关闭这个标签页
  • A-左键: 选择链接中的文字(不会触发拖动链接)
  • S-滚轮: 水平移动滚动条
  • 空格: 翻到下一页
  • F12: 开启/关闭开发者工具
  • C-R: 重新加载当前页面
  • CS-R: 硬性重新加载
  • CS-N: 打开隐私标签页
  • C-T: 打开新的标签页
  • C-W: 关闭当前标签页

GitHub 优化

众所周知,GitHub 作为最大的开源平台,平时访问的频率是相当高的,这里吾辈推荐一些插件/UserCSS 以增强使用体验。

树结构浏览代码

GitHub 浏览代码侧边栏没有一个文件栏实在难受,所以这里推荐 Gitako   这个插件。它能够为 GitHub 添加一个侧边栏,极大的方便了在线代码浏览。

相比于 Octotree,Gitako 的性能更好,而且是完全免费的。

Gitako 侧边文件夹

立体化 GitHub 用户活动

当你查看一个 GitHub 用户的活动概览时,总是平面方块未免有些无聊,而且以颜色深浅区分也尚不明了。

GitHub 用户活动平面图

所以,你可以使用 Isometric Contributions 插件,让活动变得更有趣一点,变成更直观的 3D 柱状图。

GitHub 用户活动 3D 图

统计仓库大小

或许你也有 clone 之前先知道仓库大小的习惯,这在网络稍差的环境中尤为重要,例如 TypeScript 的仓库大小超过 1G,如果没有准备的话直接下载很容易炸!

GitHub 下载仓库时并不会给出下载的百分比,所以什么时候下载完成是个玄学。。。

统计仓库大小

总结

Chrome 有很多可以优化体验的地方,这里也只是吾辈所接触到的一部分罢了,欢迎在下面留言补充!

JavaScript => TypeScript 迁移体验

前言

如果你使用 JavaScript 没出现什么问题,那吾辈就不推荐你迁移到 TypeScript!

  • JavaScript 不能无缝迁移到 TypeScript
  • JavaScript 不能无缝迁移到 TypeScript
  • JavaScript 不能无缝迁移到 TypeScript

重要的话说三遍,TypeScript 是 JavaScript 的超集,所以有很多人认为(并宣称)JavaScript 可以很容易迁移到 TypeScript,甚至是无缝迁移的!
导致了 JavaScript 开发者满心欢喜的入坑了 TypeScript(包括吾辈),然后掉进了坑里,甚至差点爬不出来。。。

原因

  • 问: 为什么吾辈用 JavaScript 用的好好的,偏偏自找麻烦去入坑了 TypeScript 了呢?
  • 答: JavaScript 因为一些固有问题和主流编辑器 VSCode 支持不力,导致代码写起来会感觉很不方便
  • 问: 具体谈谈
  • 答: 有很多令人不满意的地方,这里只谈几点:
    • JavaScript 没有类型,所以写 JSDoc 感觉很麻烦,但不写又不太好。然而,JavaScript 代码写的太顺利的话就可能忘记加上 JSDoc,之后代码就很难维护。
    • VSCode 支持不好,这点或许才是最重要的: VSCode 使用 TypeScript 编写,并基于 TypeScript 实现的语法提示功能,虽然也支持根据 JSDoc 的注释进行提示,然而当你去做一个开源项目,并将之发布到 npm 之后,情况发生了变化。。。当一个用户使用 npm/yarn 安装了你的项目之后,发现并没有任何代码提示,如此你会怎么做?
    • 复杂的类型很难使用 JSDoc 表达出来并清晰地告诉调用者,例如高阶函数。
    • 等等。。。。

是的,TypeScript 确实解决了以上的一些问题,却同时带入了另外一些问题。

  • TypeScript 有类型了,然而即便有类型推导,还是要加很多类型,而且有时候 TypeScript 和我们的想法不同的时候还要用 !/(t as unkonwn) as R 这种 hack 技巧
  • VSCode 天生支持 TypeScript,但 TypeScript 的 API Doc 生成工具实在谈不上多好,例如 typedoc 相比于 ESDoc 不过是个半吊子。。。
  • 事实上,即便使用 TypeScript 写的项目,只要使用者没有在 jsconfig.json 中进行配置的话,提示仍然默认不存在
  • TypeScript 的类型系统是把双刃剑,实在太复杂了,当然有理由认为是为了兼容 JavaScript。然而在 TypeScript 想要正确的表达类型也是一件相当困难的事情。

类型系统踩坑

如何声明参数与返回值类型相同?

例如一个函数接受一个参数,并返回一个完全相同类型的返回值。

1
2
3
function returnItself(obj: any): any {
return obj
}

假使这样写的话,类型系统就不会发挥作用了,调用函数的结果将是 any,意味着类型系统将没有效果。

例如下面的代码会被 ts 认为是错误

1
2
// 这段代码并不会有提示
console.log(returnItself('abc').length)

需要写成

1
2
3
function returnItself<T = any>(obj: T): T {
return obj
}

这里主要声明了参数和返回值是同一类型,默认为 any,但具体取决于参数的不同而使得返回值也不同,返回值不会丢失类型信息。

如何声明参数与返回值类型有关联?

例如一个计算函数执行时间的函数 timing,接受一个函数参数,有可能是同步/异步的,所以要根据函数的返回值确定 timing 的返回值为 number/Promise<number>

1
2
3
4
5
6
7
8
9
10
export function timing(
fn: (...args: any[]) => any | Promise<any>,
): number | Promise<number> {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

然而在使用时你会发现返回值类型不太对,因为 timing 的返回值是 number | Promise<number> 这种复合类型

1
2
3
// 这里会提示类型错误
const res: number = timing(() => sleep(100))
expect(res).toBeGreaterThan(99)

解决方案有二

  1. 使用函数声明重载
  2. 使用类型判断

使用函数声明重载

1
2
3
4
5
6
7
8
9
10
11
12
export function timing(fn: (...args: any[]) => Promise<any>): Promise<number>
export function timing(fn: (...args: any[]) => any): number
export function timing(
fn: (...args: any[]) => any | Promise<any>,
): number | Promise<number> {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

感觉函数声明顺序有点奇怪是因为 Promise<any> 属于 any 的子类,而函数声明重载必须由具体到宽泛。当然,我们有方法可以在 any 中排除掉 Promise<any>,这样顺序就对了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function timing(
fn: (...args: any[]) => Exclude<any, Promise<any>>,
): number
export function timing(fn: (...args: any[]) => Promise<any>): Promise<number>
export function timing(
fn: (...args: any[]) => any | Promise<any>,
): number | Promise<number> {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

使用类型判断

1
2
3
4
5
6
7
8
9
10
11
export function timing(
fn: (...args: any[]) => any | Promise<any>,
// 函数返回类型是 Promise 的话,则返回 Promise<number>,否则返回 number
): R extends Promise<any> ? Promise<number> : number {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return (performance.now() - begin) as any
}
return result.then(() => performance.now() - begin) as any
}

思考

可以看出来,第一种方式的优点在于可以很精细的控制每个不同参数对应的返回值,并且,可以处理特别复杂的情况,缺点则是如果写 doc 文档的话需要为每个声明都写上,即便,它们有大部分注释是相同的。
而第二种方式,则在代码量上有所减少,而且不必使用函数声明重载。缺点则是无法应对特别复杂的情况,另外一点就是使用了 any,可能会造成重构火葬场

TypeScript 类型系统就是认为吾辈错了怎么办?

有时候,明明自己知道是正确的,但 TypeScript 偏偏认为你写错了。思考以下功能如何实现?

将 Array 转换为 Map,接受三个参数

  1. 需要转换的数组
  2. 将数组元素转换为 Map key 的函数
  3. 将数组元素转换为 Map value 的函数,可选,默认为数组元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function returnItself<T = any>(obj: T): T {
return obj
}

export type ArrayCallback<T, R> = (item: T, index: number, arr: T[]) => R

export function arrayToMap<T, K, V>(
arr: T[],
kFn: ArrayCallback<T, K>,
vFn: ArrayCallback<T, V> = returnItself,
): Map<K, V> {
return arr.reduce(
(res, item, index, arr) =>
res.set(kFn(item, index, arr), vFn(item, index, arr)),
new Map<K, V>(),
)
}

可能有以上代码,然而实际上 returnItself 无法直接赋值给 ArrayCallback<T, V>。当然,我们知道,这一定是可以赋值的,但 TypeScript 却无法编译通过!

1
2
3
4
5
6
7
8
9
10
11
12
export function arrayToMap<T, K, V>(
arr: T[],
kFn: ArrayCallback<T, K>,
// 是的,这里添加 as any 就好了
vFn: ArrayCallback<T, V> = returnItself as any,
): Map<K, V> {
return arr.reduce(
(res, item, index, arr) =>
res.set(kFn(item, index, arr), vFn(item, index, arr)),
new Map<K, V>(),
)
}

或者,如果 returnItself 用的比较多的话(例如吾辈),可以使用另一种方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 修改 returnItself 的返回值
function returnItself<T, R = T>(obj: T): R {
return obj as any
}

export type ArrayCallback<T, R> = (item: T, index: number, arr: T[]) => R

export function arrayToMap<T, K, V>(
arr: T[],
kFn: ArrayCallback<T, K>,
vFn: ArrayCallback<T, V> = returnItself,
): Map<K, V> {
return arr.reduce(
(res, item, index, arr) =>
res.set(kFn(item, index, arr), vFn(item, index, arr)),
new Map<K, V>(),
)
}

如何强制调用非空时对象上的函数?

当有时候你得到一个对象可能为空时,无法直接调用其上的函数,会提示函数不存在。
例如下面从数组中查询字符串,然后获取长度,在 TypeScript 中便会报错,因为 str 的类型为 string/undefined。

1
2
3
4
const arr = ['a', 'b', 'c']
const str = arr.find(s => s === 'b')
//
console.log(str.length)

之前使用 JavaScript 从未遇到过这种事情,事实上确实有可能为空,但 JavaScript 太过于动态,并不会提示错误,而 TypeScript 就会提示这种低级错误,因为类型系统。
但是啊,凡事都有例外,当吾辈确实想调用 string 上的函数时报错真的是有点讨厌,那么有什么办法呢?

  1. 使用 ! 强制调用

    1
    2
    3
    const arr = ['a', 'b', 'c']
    const str = arr.find(s => s === 'b')
    console.log(str!.length)
  2. 使用 (str as any) 转换为 any 类型之后再随意调用任何函数

    1
    2
    3
    const arr = ['a', 'b', 'c']
    const str = arr.find(s => s === 'b')
    console.log((str as any).length)
  3. 使用注释 // @ts-ignore 忽略错误(非常强力,少用)

    1
    2
    3
    4
    const arr = ['a', 'b', 'c']
    const str = arr.find(s => s === 'b')
    // @ts-ignore
    console.log(str.length)

注意: 三种方式推荐程度逐渐降低,因为后两种实际上都会忽略类型系统,导致编写代码没有提示!

总结

截至目前为止,吾辈已经着手使用 TypeScript 重构工具函数库 rx-util 两周了,基本上打包配置,文档生成,类型定义基本上算是大致完成,感觉之后的公共项目大概都会用 TypeScript 实现了,毕竟前端主流开发工具 VSCode 对其的支持真的很好,而且 TypeScript 的接口这种概念真的太有用了!

一些吐槽

使用了有一段时间了,这里不得不再次声明一下,TypeScript 的类型系统复杂度超乎想象,如果你没有准备好在生产系统中使用,那就最好不要使用。缺少关于类型系统(尤其是原生类型,例如 PromiseLike 居然没有人讲过)的说明,使得 TypeScript 的类型系统很多时候看起来都只是为了好玩而已。而且稍微复杂一点的情况思考如何设计类型的时间将会超过具体的代码实现,使用它请务必再三慎重考虑!

TypeScript 的类型系统为了兼容 JavaScript 缺陷实在太大了。

参见某个知乎用户的话:

  1. ts 写不出一个合并对象的方法

    下面是一个 js 合并对象的方法

    1
    2
    3
    function extend(dest, ...sources) {
    return Object.assign(dest, ...sources)
    }

    这么一个简单的方法,ts 写不出不丢失类型信息的实现。

    下面贴的是 typescript 源码中对 Object.assign 的声明,我相信都能看出有多傻:

    1
    2
    3
    4
    assign<T, U>(target: T, source: U): T & U;
    assign<T, U, V>(target: T, source1: U, source2: V): T & U & V;
    assign<T, U, V, W>(target: T, source1: U, source2: V, source3: W): T & U & & W;
    assign(target: object, ...sources: any[]): any;

    按这个实现,多于 4 个参数就直接丢掉类型信息了,建议 ts 至少把 A-Z 都作为泛型量用上…

  2. 一些很明显的类型推断却推断不出来

    用 assert 方法做参数检查是很常用的做法,一个简单的 assert 方法:

    1
    2
    3
    function assert(condition, msg) {
    if (condition) throw new Error(msg)
    }

    然后看这样一段代码:

    1
    2
    3
    4
    function foo(p: number | string) {
    assert(typeof p === 'number', 'p is a number')
    p.length // 这里报错,ts 竟然不知道到这一步 p 必定是 string 类型
    }

JavaScript 处理树结构数据

场景

即便在前端,也有很多时候需要操作 树结构 的情况,最典型的场景莫过于 _无限级分类_。之前吾辈曾经遇到过这种场景,但当时没有多想直接手撕 JavaScript 列表转树了,并没有想到进行封装。后来遇到的场景多了,想到如何封装树结构操作,但考虑到不同场景的树节点结构的不同,就没有继续进行下去了。

直到吾辈开始经常运用了 ES6 Proxy 之后,吾辈想到了新的解决方案!

思考

  • 问: 之前为什么停止封装树结构操作了?
  • 答: 因为不同的树结构节点可能有不同的结构,例如某个项目的树节点父节点 id 字段是 parent,而另一个项目则是 parentId
  • 问: Proxy 如何解决这个问题呢?
  • 答: Proxy 可以拦截对象的操作,当访问对象不存在的字段时,Proxy 能将之代理到已经存在的字段上
  • 问: 这点意味着什么?
  • 答: 它意味着 Proxy 能够抹平不同的树节点结构之间的差异!
  • 问: 我还是不太明白 Proxy 怎么用,能举个具体的例子么?
  • 答: 当然可以,我现在就让你看看 Proxy 的能力

下面思考一下如何在同一个函数中处理这两种树节点结构

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
31
32
/**
* 系统菜单
*/
class SysMenu {
/**
* 构造函数
* @param {Number} id 菜单 id
* @param {String} name 显示的名称
* @param {Number} parent 父级菜单 id
*/
constructor(id, name, parent) {
this.id = id
this.name = name
this.parent = parent
}
}
/**
* 系统权限
*/
class SysPermission {
/**
* 构造函数
* @param {String} uid 系统唯一 uuid
* @param {String} label 显示的菜单名
* @param {String} parentId 父级权限 uid
*/
constructor(uid, label, parentId) {
this.uid = uid
this.label = label
this.parentId = parentId
}
}

下面让我们使用 Proxy 来抹平访问它们之间的差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const sysMenuMap = new Map().set('parentId', 'parent')
const sysMenu = new Proxy(new SysMenu(1, 'rx', 0), {
get(_, k) {
if (sysMenuMap.has(k)) {
return Reflect.get(_, sysMenuMap.get(k))
}
return Reflect.get(_, k)
},
})
console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0

const sysPermissionMap = new Map().set('id', 'uid').set('name', 'label')
const sysPermission = new Proxy(new SysPermission(1, 'rx', 0), {
get(_, k) {
if (sysPermissionMap.has(k)) {
return Reflect.get(_, sysPermissionMap.get(k))
}
return Reflect.get(_, k)
},
})
console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 0

定义桥接函数

现在,差异确实抹平了,我们可以通过访问相同的属性来获取到不同结构对象的值!然而,每个对象都写一次代理终究有点麻烦,所以我们实现一个通用函数用以包装。

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
/**
* 桥接对象不存在的字段
* @param {Object} map 代理的字段映射 Map
* @returns {Function} 转换一个对象为代理对象
*/
export function bridge(map) {
/**
* 为对象添加代理的函数
* @param {Object} obj 任何对象
* @returns {Proxy} 代理后的对象
*/
return function(obj) {
return new Proxy(obj, {
get(target, k) {
if (Reflect.has(map, k)) {
return Reflect.get(target, Reflect.get(map, k))
}
return Reflect.get(target, k)
},
set(target, k, v) {
if (Reflect.has(map, k)) {
Reflect.set(target, Reflect.get(map, k), v)
return true
}
Reflect.set(target, k, v)
return true
},
})
}
}

现在,我们可以用更简单的方式来做代理了。

1
2
3
4
5
6
7
8
9
10
const sysMenu = bridge({
parentId: 'parent',
})(new SysMenu(1, 'rx', 0))
console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0

const sysPermission = bridge({
id: 'uid',
name: 'label',
})(new SysPermission(1, 'rx', 0))
console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 0

定义标准树结构

想要抹平差异,我们至少还需要一个标准的树结构,告诉别人我们需要什么样的树节点数据结构,以便于在之后处理树节点的函数中统一使用。

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
31
32
33
34
/**
* 基本的 Node 节点结构定义接口
* @interface
*/
export class INode {
/**
* 构造函数
* @param {Object} [options] 可选项参数
* @param {String} [options.id] 树结点的 id 属性名
* @param {String} [options.parentId] 树结点的父节点 id 属性名
* @param {String} [options.child] 树结点的子节点数组属性名
* @param {String} [options.path] 树结点的全路径属性名
* @param {Array.<Object>} [options.args] 其他参数
*/
constructor({ id, parentId, child, path, ...args } = {}) {
/**
* @field 树结点的 id 属性名
*/
this.id = id
/**
* @field 树结点的父节点 id 属性名
*/
this.parentId = parentId
/**
* @field 树结点的子节点数组属性名
*/
this.child = child
/**
* @field 树结点的全路径属性名
*/
this.path = path
Object.assign(this, args)
}
}

实现列表转树

列表转树,除了递归之外,也可以使用循环实现,这里便以循环为示例。

思路

  1. 在外层遍历子节点
  2. 如果是根节点,就添加到根节点中并不在找其父节点。
  3. 否则在内层循环中找该节点的父节点,找到之后将子节点追加到父节点的子节点列表中,然后结束本次内层循环。
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
31
32
33
34
35
/**
* 将列表转换为树节点
* 注:该函数默认树的根节点只有一个,如果有多个,则返回一个数组
* @param {Array.<Object>} list 树节点列表
* @param {Object} [options] 其他选项
* @param {Function} [options.isRoot] 判断节点是否为根节点。默认根节点的父节点为空
* @param {Function} [options.bridge=returnItself] 桥接函数,默认返回自身
* @returns {Object|Array.<String>} 树节点,或是树节点列表
*/
export function listToTree(
list,
{ isRoot = node => !node.parentId, bridge = returnItself } = {},
) {
const res = list.reduce((root, _sub) => {
if (isRoot(sub)) {
root.push(sub)
return root
}
const sub = bridge(_sub)
for (let _parent of list) {
const parent = bridge(_parent)
if (sub.parentId === parent.id) {
parent.child = parent.child || []
parent.child.push(sub)
return root
}
}
return root
}, [])
// 根据顶级节点的数量决定如何返回
const len = res.length
if (len === 0) return {}
if (len === 1) return res[0]
return res
}

抽取通用的树结构遍历逻辑

首先,明确一点,树结构的完全遍历是通用的,大致实现基本如下

  1. 遍历顶级树节点
  2. 遍历树节点的子节点列表
  3. 递归调用函数并传入子节点
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 返回第一个参数的函数
* 注:一般可以当作返回参数自身的函数,如果你只关注第一个参数的话
* @param {Object} obj 任何对象
* @returns {Object} 传入的第一个参数
*/
export function returnItself(obj) {
return obj
}
/**
* 遍历并映射一棵树的每个节点
* @param {Object} root 树节点
* @param {Object} [options] 其他选项
* @param {Function} [options.before=returnItself] 遍历子节点之前的操作。默认返回自身
* @param {Function} [options.after=returnItself] 遍历子节点之后的操作。默认返回自身
* @param {Function} [options.paramFn=(node, args) => []] 递归的参数生成函数。默认返回一个空数组
* @returns {INode} 递归遍历后的树节点
*/
export function treeMapping(
root,
{
before = returnItself,
after = returnItself,
paramFn = (node, ...args) => [],
} = {},
) {
/**
* 遍历一颗完整的树
* @param {INode} node 要遍历的树节点
* @param {...Object} [args] 每次递归遍历时的参数
*/
function _treeMapping(node, ...args) {
// 之前的操作
let _node = before(node, ...args)
const childs = _node.child
if (arrayValidator.isEmpty(childs)) {
return _node
}
// 产生一个参数
const len = childs.length
for (let i = 0; i < len; i++) {
childs[i] = _treeMapping(childs[i], ...paramFn(_node, ...args))
}
// 之后的操作
return after(_node, ...args)
}
return _treeMapping(root)
}

使用 treeMapping 遍历树并打印

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
const tree = {
uid: 1,
childrens: [
{
uid: 2,
parent: 1,
childrens: [{ uid: 3, parent: 2 }, { uid: 4, parent: 2 }],
},
{
uid: 5,
parent: 1,
childrens: [{ uid: 6, parent: 5 }, { uid: 7, parent: 5 }],
},
],
}
// 桥接函数
const bridge = bridge({
id: 'uid',
parentId: 'parent',
child: 'childrens',
})
treeMapping(tree, {
// 进行桥接抹平差异
before: bridge,
// 之后打印每一个
after(node) {
console.log(node)
},
})

实现树转列表

当然,我们亦可使用 treeMapping 简单的实现 treeToList,当然,这里考虑了是否计算全路径,毕竟还是要考虑性能的!

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
/**
* 将树节点转为树节点列表
* @param {Object} root 树节点
* @param {Object} [options] 其他选项
* @param {Boolean} [options.calcPath=false] 是否计算节点全路径,默认为 false
* @param {Function} [options.bridge=returnItself] 桥接函数,默认返回自身
* @returns {Array.<Object>} 树节点列表
*/
export function treeToList(
root,
{ calcPath = false, bridge = returnItself } = {},
) {
const res = []
treeMapping(root, {
before(_node, parentPath) {
const node = bridge(_node)
// 是否计算全路径
if (calcPath) {
node.path = (parentPath ? parentPath + ',' : '') + node.id
}
// 此时追加到数组中
res.push(node)
return node
},
paramFn: node => (calcPath ? [node.path] : []),
})
return res
}

现在,我们可以转换任意树结构为列表了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const tree = {
uid: 1,
childrens: [
{
uid: 2,
parent: 1,
childrens: [{ uid: 3, parent: 2 }, { uid: 4, parent: 2 }],
},
{
uid: 5,
parent: 1,
childrens: [{ uid: 6, parent: 5 }, { uid: 7, parent: 5 }],
},
],
}
const fn = bridge({
id: 'uid',
parentId: 'parent',
child: 'childrens',
})
const list = treeToList(tree, {
bridge: fn,
})
console.log(list)

总结

那么,JavaScript 中处理树结构数据就到这里了。当然,树结构数据还有其他的更多操作尚未实现,例如常见的查询子节点列表,节点过滤,最短路径查找等等。但目前列表与树的转换才是最常用的,而且其他操作基本上也是基于它们做的,所以这里也便点到为止了。

JavaScript 微任务/宏任务踩坑

场景

SegmentFault

在使用 async-await 时,吾辈总是习惯把它们当作同步,终于,现在踩到坑里去了。
使用 setTimeoutsetInterval 实现的基于 Promisewait 函数,然而测试边界情况的时候却发现了一些问题!

实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* @param {Number|Function} [param] 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
export const wait = param => {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
const timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}

测试代码

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
;(async () => {
// 标识当前是否有异步函数 add 在运行了
let taskIsRun = false
const add = async (_v, i) => {
// 如果已经有运行的 add 函数,则等待
if (taskIsRun) {
console.log(i + ' 判断前: ')
await wait(() => {
return !taskIsRun
})
console.log(i + ' 判断后: ' + taskIsRun)
}
try {
taskIsRun = true
console.log(i + ' 执行前: ' + taskIsRun)
await wait(100)
} finally {
console.log(i + ' 执行后: ')
taskIsRun = false
}
}

const start = Date.now()
await Promise.all(
Array(10)
.fill(0)
.map(add),
)
console.log(Date.now() - start)
})()

那么,先不要往下看,猜一下最后打印的大概会是多少呢?

实际执行结果

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
0 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

1 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

2 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

3 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

4 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

5 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

6 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

7 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

8 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

9 判断前: ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

0 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

1 判断后: false ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

1 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

// 这儿的 1 执行前,结果 2 就已经判断通过并准备执行了???发生了什么?

2 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

2 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

3 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

3 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

4 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

4 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

5 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

5 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

6 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

6 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

7 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

7 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

8 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

8 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

9 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

9 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

1 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

2 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

3 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

4 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

5 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

6 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

7 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

8 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

9 执行后: ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

307 ​​​​​at ​​​Date.now() - start​​​ ​src/module/function/wait.js:52:2​

可以看到,很神奇的是 _判断后 => 执行前 => 判断后…=> 执行后…_,并不是预想中的 判断后 => 执行前 => 执行后… 的循环,所以,到底发生了什么呢?

思考

这个问题卡了吾辈两天之久,直到吾辈在 StackOverflow 提出的另一个相关的问题被外国友人回答了,瞬间吾辈就想起了 – async-await 本质上还是异步

是的,为什么会出现 wait 一直在执行而后面的 taskIsRun = true 却并没有执行?因为 JavaScript 中的 async-await 虽然可以写出来很像同步代码的异步代码,但实际上还是异步的,原理还是基于 Promise

我们改造一下代码,将之使用原生 Promise 实现一下

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* @param {Number|Function} [param] 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
export const wait = param => {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
const timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}
;(() => {
// 标识当前是否有异步函数 add 在运行了
let taskIsRun = false
const add = (_v, i) => {
// 如果已经有运行的 add 函数,则等待
return Promise.resolve()
.then(() => {
if (taskIsRun) {
console.log(i + ' 判断前: ')
// 关键在于这里,实际上执行完成之后并不会到下一个 then,而是继续另一个 wait 的判断
return wait(() => !taskIsRun).then(() => {
console.log(i + ' 判断后: ' + taskIsRun)
})
}
})
.then(() => {
taskIsRun = true
console.log(i + ' 执行前: ' + taskIsRun)
return wait(100)
})
.catch(() => {})
.then(() => {
console.log(i + ' 执行后: ')
taskIsRun = false
})
}

const start = Date.now()
Promise.all(
Array(10)
.fill(0)
.map(add),
).then(() => console.log(Date.now() - start))
})()

这个时候就可以看出来了,判断逻辑是处在一个 then 后继里面的。那么,执行完 console.log(i + ' 判断后: ' + taskIsRun) 之后,就一定会继续执行下面的 then 函数么?并不,这时候 wait 函数内部实现中的 setInterval 还在运转,实际上 nodejs 并不会优先继续 then 这种 microtask(微任务),而是会继续进行 setInterval 这种 macrotask(宏任务)。这是 nodejs 与浏览器实现不一致的地方,吾辈将这些代码复制到浏览器上,确实可以正常执行并得到预期的结果。

微任务与宏任务参考

1
2
3
4
5
6
if (taskIsRun) {
console.log(i + ' 判断前: ')
return wait(() => !taskIsRun).then(() => {
console.log(i + ' 判断后: ' + taskIsRun)
})
}

当然,nodejs 11 修复了这个问题,参考 https://github.com/nodejs/node/pull/22842。然而目前 NodeJS LTS 为 10,最新版本为 12,这个问题可能还要持续一段时间。

解决

那么,难道吾辈就必须等到 NodeJS LTS 最新版之后才能用 wait 么?或者说,吾辈就必须依赖于浏览器的 microtask/macrotask 么?并不,吾辈对之手动进行了处理即可!

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* 等待指定的时间/等待指定表达式成立
* 如果未指定等待条件则立刻执行
* @param {Number|Function} [param] 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
export const wait = param => {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
const timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}
;(async () => {
// 标识当前是否有异步函数 add 在运行了
let taskIsRun = false
const add = async (_v, i) => {
// 如果已经有运行的 add 函数,则等待
if (taskIsRun) {
console.log(i + ' 判断前: ')
await wait(() => {
const result = !taskIsRun
// 关键在于这里
if (result) {
taskIsRun = true
}
return result
})
console.log(i + ' 判断后: ' + taskIsRun)
}
try {
taskIsRun = true
console.log(i + ' 执行前: ' + taskIsRun)
await wait(100)
} finally {
console.log(i + ' 执行后: ')
taskIsRun = false
}
}

const start = Date.now()
await Promise.all(
Array(10)
.fill(0)
.map(add),
)
console.log(Date.now() - start)
})()

吾辈在 wait 函数中,即 setInterval 循环调用的函数中对 taskIsRun 进行了修改,而不是在 wait 后面,即 then 之后的 microtask 中进行修改,结果便一切如同吾辈所期待的一样了!

JavaScript 防抖和节流

场景

网络上已经存在了大量的有关 防抖节流 的文章,为何吾辈还要再写一篇呢?事实上,防抖和节流,吾辈在使用中发现了一些奇怪的问题,并经过了数次的修改,这里主要分享一下吾辈遇到的问题以及是如何解决的。

为什么要用防抖和节流?

因为某些函数触发/调用的频率过快,吾辈需要手动去限制其执行的频率。例如常见的监听滚动条的事件,如果没有防抖处理的话,并且,每次函数执行花费的时间超过了触发的间隔时间的话 – 页面就会卡顿。

演进

初始实现

我们先实现一个简单的去抖函数

1
2
3
4
5
6
7
8
9
function debounce(delay, action) {
let tId
return function(...args) {
if (tId) clearTimeout(tId)
tId = setTimeout(() => {
action(...args)
}, delay)
}
}

测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 使用 Promise 简单封装 setTimeout,下同
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
let num = 0
const add = () => ++num

add()
add()
console.log(num) // 2

const fn = debounce(10, add)
fn()
fn()
console.log(num) // 2
await wait(20)
console.log(num) // 3
})()

好了,看来基本的效果是实现了的。包装过的函数 fn 调用了两次,却并没有立刻执行,而是等待时间间隔过去之后才最终执行了一次。

this 怎么办?

然而,上面的实现有一个致命的问题,没有处理 this!当你用在原生的事件处理时或许还不觉得,然而,当你使用了 ES6 class 这类对 this 敏感的代码时,就一定会遇到 this 带来的问题。

例如下面使用 class 来声明一个计数器

1
2
3
4
5
6
7
8
class Counter {
constructor() {
this.i = 0
}
add() {
this.i++
}
}

我们可能想在 constructor 中添加新的属性 fn

1
2
3
4
5
6
7
8
9
class Counter {
constructor() {
this.i = 0
this.fn = debounce(10, this.add)
}
add() {
this.i++
}
}

但很遗憾,这里的 this 绑定是有问题的,执行以下代码试试看

1
2
const counter = new Counter()
counter.fn() // Cannot read property 'i' of undefined

会抛出异常 Cannot read property 'i' of undefined,究其原因就是 this 没有绑定,我们可以手动绑定 this .bind(this)

1
2
3
4
5
6
7
8
9
class Counter {
constructor() {
this.i = 0
this.fn = debounce(10, this.add.bind(this))
}
add() {
this.i++
}
}

但更好的方式是修改 debounce,使其能够自动绑定 this

1
2
3
4
5
6
7
8
9
function debounce(delay, action) {
let tId
return function(...args) {
if (tId) clearTimeout(tId)
tId = setTimeout(() => {
action.apply(this, args)
}, delay)
}
}

然后,代码将如同预期的运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
;(async () => {
class Counter {
constructor() {
this.i = 0
this.fn = debounce(10, this.add)
}
add() {
this.i++
}
}

const counter = new Counter()
counter.add()
counter.add()
console.log(counter.i) // 2

counter.fn()
counter.fn()
console.log(counter.i) // 2
await wait(20)
console.log(counter.i) // 3
})()

返回值呢?

不知道你有没有发现,现在使用 debounce 包装的函数都没有返回值,是完全只有副作用的函数。然而,吾辈还是遇到了需要返回值的场景。
例如:输入停止后,使用 Ajax 请求后台数据判断是否已存在相同的数据。

修改 debounce 成会缓存上一次执行结果并且有初始结果参数的实现

1
2
3
4
5
6
7
8
9
10
11
function debounce(delay, action, init = undefined) {
let flag
let result = init
return function(...args) {
if (flag) clearTimeout(flag)
flag = setTimeout(() => {
result = action.apply(this, args)
}, delay)
return result
}
}

调用代码变成了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
;(async () => {
class Counter {
constructor() {
this.i = 0
this.fn = debounce(10, this.add, 0)
}
add() {
return ++this.i
}
}

const counter = new Counter()

console.log(counter.add()) // 1
console.log(counter.add()) // 2

console.log(counter.fn()) // 0
console.log(counter.fn()) // 0
await wait(20)
console.log(counter.fn()) // 3
})()

看起来很完美?然而,没有考虑到异步函数是个大失败!

尝试以下测试代码

1
2
3
4
5
6
7
8
9
10
11
;(async () => {
const get = async i => i

console.log(await get(1))
console.log(await get(2))
const fn = debounce(10, get, 0)
fn(3).then(i => console.log(i)) // fn(...).then is not a function
fn(4).then(i => console.log(i))
await wait(20)
fn(5).then(i => console.log(i))
})()

会抛出异常 fn(...).then is not a function,因为我们包装过后的函数是同步的,第一次返回的值并不是 Promise 类型。

除非我们修改默认值

1
2
3
4
5
6
7
8
9
10
11
12
;(async () => {
const get = async i => i

console.log(await get(1))
console.log(await get(2))
// 注意,修改默认值为 Promise
const fn = debounce(10, get, new Promise(resolve => resolve(0)))
fn(3).then(i => console.log(i)) // 0
fn(4).then(i => console.log(i)) // 0
await wait(20)
fn(5).then(i => console.log(i)) // 4
})()

支持有返回值的异步函数

支持异步有两种思路

  1. 将异步函数包装为同步函数
  2. 将包装后的函数异步化

第一种思路实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function debounce(delay, action, init = undefined) {
let flag
let result = init
return function(...args) {
if (flag) clearTimeout(flag)
flag = setTimeout(() => {
const temp = action.apply(this, args)
if (temp instanceof Promise) {
temp.then(res => (result = res))
} else {
result = temp
}
}, delay)
return result
}
}

调用方式和同步函数完全一样,当然,是支持异步函数的

1
2
3
4
5
6
7
8
9
10
11
12
;(async () => {
const get = async i => i

console.log(await get(1))
console.log(await get(2))
// 注意,修改默认值为 Promise
const fn = debounce(10, get, 0)
console.log(fn(3)) // 0
console.log(fn(4)) // 0
await wait(20)
console.log(fn(5)) // 4
})()

第二种思路实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const debounce = (delay, action, init = undefined) => {
let flag
let result = init
return function(...args) {
return new Promise(resolve => {
if (flag) clearTimeout(flag)
flag = setTimeout(() => {
result = action.apply(this, args)
resolve(result)
}, delay)
setTimeout(() => {
resolve(result)
}, delay)
})
}
}

调用方式支持异步的方式

1
2
3
4
5
6
7
8
9
10
11
12
;(async () => {
const get = async i => i

console.log(await get(1))
console.log(await get(2))
// 注意,修改默认值为 Promise
const fn = debounce(10, get, 0)
fn(3).then(i => console.log(i)) // 0
fn(4).then(i => console.log(i)) // 4
await wait(20)
fn(5).then(i => console.log(i)) // 5
})()

可以看到,第一种思路带来的问题是返回值永远会是 旧的 返回值,第二种思路主要问题是将同步函数也给包装成了异步。利弊权衡之下,吾辈觉得第二种思路更加正确一些,毕竟使用场景本身不太可能必须是同步的操作。而且,原本 setTimeout 也是异步的,只是不需要返回值的时候并未意识到这点。

避免原函数信息丢失

后来,有人提出了一个问题,如果函数上面携带其他信息,例如类似于 jQuery$,既是一个函数,但也同时含有其他属性,如果使用 debounce 就找不到了呀

一开始吾辈立刻想到了复制函数上面的所有可遍历属性,然后想起了 ES6 的 Proxy 特性 – 这实在是太魔法了。使用 Proxy 解决这个问题将异常的简单 – 因为除了调用函数,其他的一切操作仍然指向原函数!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const debounce = (delay, action, init = undefined) => {
let flag
let result = init
return new Proxy(action, {
apply(target, thisArg, args) {
return new Promise(resolve => {
if (flag) clearTimeout(flag)
flag = setTimeout(() => {
resolve((result = Reflect.apply(target, thisArg, args)))
}, delay)
setTimeout(() => {
resolve(result)
}, delay)
})
},
})
}

测试一下

1
2
3
4
5
6
7
8
;(async () => {
const get = async i => i
get.rx = 'rx'

console.log(get.rx) // rx
const fn = debounce(10, get, 0)
console.log(fn.rx) // rx
})()

实现节流

以这种思路实现一个节流函数 throttle

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
/**
* 函数节流
* 节流 (throttle) 让一个函数不要执行的太频繁,减少执行过快的调用,叫节流
* 类似于上面而又不同于上面的函数去抖, 包装后函数在上一次操作执行过去了最小间隔时间后会直接执行, 否则会忽略该次操作
* 与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作, 而非仅执行最后一次操作
* 注: 该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值
* 注: 返回函数结果的高阶函数需要使用 {@link Proxy} 实现,以避免原函数原型链上的信息丢失
*
* @param {Number} delay 最小间隔时间,单位为 ms
* @param {Function} action 真正需要执行的操作
* @return {Function} 包装后有节流功能的函数。该函数是异步的,与需要包装的函数 {@link action} 是否异步没有太大关联
*/
const throttle = (delay, action) => {
let last = 0
let result
return new Proxy(action, {
apply(target, thisArg, args) {
return new Promise(resolve => {
const curr = Date.now()
if (curr - last > delay) {
result = Reflect.apply(target, thisArg, args)
last = curr
resolve(result)
return
}
resolve(result)
})
},
})
}

总结

嘛,实际上这里的防抖和节流仍然是简单的实现,其他的像 取消防抖/强制刷新缓存 等功能尚未实现。当然,对于吾辈而言功能已然足够了,也被放到了公共的函数库 rx-util 中。

layui-layer load 弹窗自动关闭的问题

场景

项目中的 Ajax 加载时的 loading 框有时候会关闭了弹窗之后很久页面上的数据才加载出来,而且这个问题是随机出现的,有些页面存在,有些页面则正常。

最小复现代码

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
31
32
33
34
35
36
37
38
39
40
41
42
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/rx-util@1.6.3/dist/rx-util.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.0/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/dist/layer.js"></script>
<script>
/**
* 加载遮罩框
*
* @returns {Function} 一个关闭遮罩框的函数
*/
function load() {
const id = layer.load(1)
return () => {
layer.close(id)
}
}
/**
* 模拟 ajax 异步请求
*/
async function request(time) {
const close = load()
console.log('request start: ', time)
await rx.wait(time)
close()
console.log('request end: ', time)
}

;(() => {
request(5000).then(() => console.log('第二个请求加载完成了'))
request(1000).then(() => console.log('第一个请求加载完成了'))
})()
</script>
</body>
</html>

控制台打印

1
2
3
4
5
6
request start:  5000
request start: 1000
request end: 1000
第一个请求加载完成了
request end: 5000
第二个请求加载完成了

思考

本来吾辈猜测是 vuejs 页面渲染的锅,认为 vuejs 的生命周期函数 mouted 执行时 DOM 还没加载完全的缘故。
所以把 load 异步化,等待 document 加载完毕才会真正执行。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/rx-util@1.6.3/dist/rx-util.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.0/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/dist/layer.js"></script>
<script>
/**
* 加载遮罩框
*
* @returns {Function} 一个关闭遮罩框的函数
*/
async function load() {
await rx.wait(() => document.readyState === 'complete')
const id = layer.load(1)
return async () => {
await rx.wait(() => document.readyState === 'complete')
layer.close(id)
}
}
/**
* 模拟 ajax 异步请求
*/
async function request(time) {
const close = await load()
console.log('request start: ', time)
await rx.wait(time)
await close()
console.log('request end: ', time)
}

;(() => {
request(5000).then(() => console.log('第二个请求加载完成了'))
request(1000).then(() => console.log('第一个请求加载完成了'))
})()
</script>
</body>
</html>

控制台打印

1
2
3
4
5
6
request start:  5000
request start: 1000
request end: 1000
第一个请求加载完成了
request end: 5000
第二个请求加载完成了

然而实际上却并不是这个问题。。。

经过某位网友提醒,layer 源码中默认只允许一个活动的 load 弹窗。瞬间吾辈都不知道要怎么吐槽了,单例模式避免无谓的内存浪费是正常的,然而新的 load 函数却会关闭之前的 load 这种操作真的是很厉害了呢

例如下面这段代码,无论调用多少次 layer.close(id1),页面上的 loading 都不会关闭。。。

1
2
3
4
5
6
7
const id1 = layer.load()
const id2 = layer.load()
layer.close(id1)
layer.close(id1)
// ...
layer.close(id1)
layer.close(id1)

这里吾辈可以想象到,layer 认为先加载的 load() 就应该先被 close(),而没有考虑到复杂异步的情况。

解决

既然 layer 的 load 本身存在缺陷,那么却是只能自己对 loadclose 功能做控制了
基本思路

  1. layer.load 每次都会关闭掉之前的弹窗,那么就记录最后一次的弹窗 id,在真正需要关闭的时候 close 掉就好了
  2. layer.load 关闭是直接关闭弹窗,如果是最后一个就会出现弹窗消失但数据没加载完全的问题,那么关闭这儿要判断当前是否还有活动的弹窗,只有在没有的情况下才真正关闭

修改后的代码

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/rx-util@1.6.3/dist/rx-util.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.4.0/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/dist/layer.js"></script>
<script>
/**
* 加载遮罩框
*
* @returns {Function} 一个关闭遮罩框的函数
*/
const load = ((num, lastId) => () => {
lastId = layer.load(1)
num++
return () => {
num--
if (num < 0) {
num = 0
}
if (num > 0) {
console.log('弹窗没有真正关闭哦')
return
}
layer.close(lastId)
console.log('弹窗真的关闭啦')
}
})(0)

/**
* 模拟 ajax 异步请求
*/
async function request(time) {
const close = await load()
console.log('request start: ', time)
await rx.wait(time)
await close()
console.log('request end: ', time)
}

;(() => {
request(5000).then(() => console.log('第二个请求加载完成了'))
request(1000).then(() => console.log('第一个请求加载完成了'))
})()
</script>
</body>
</html>

控制台打印

1
2
3
4
5
6
7
8
request start:  5000
request start: 1000
弹窗没有真正关闭哦
request end: 1000
第一个请求加载完成了
弹窗真的关闭啦
request end: 5000
第二个请求加载完成了

使用 GitHub 作为 Maven 仓库

GitHub 示例

场景

吾辈在日常工具中也有一些公共的代码库,一直想分离成单独的类库却没有机会,看到使用 github 就能部署 maven 仓库就尝试了一下。

这里吐槽一下 maven 中央仓库的发布流程,不知道为什么不能像 npm 一样一个简单的命令就能发布多好!

创建一个 maven 项目上传到 github

这是初始的 pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<!-- 项目的组织名,如果没有域名或组织的话就是用 com.github.[你的用户名] -->
<groupId>com.rxliuli</groupId>
<!-- 项目的名字 -->
<artifactId>maven-repository-example</artifactId>
<!-- 版本号,默认是 1.0-SNAPSHOT -->
<version>1.0-SNAPSHOT</version>
</project>

添加一个忽略配置 .gitignore 就可以上传到 GitHub 了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar

# 忽略 IDEA 配置文件
*.iml
.idea/
rebel.xml

修改 maven-deploy-plugin 插件

主要是设置部署目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 其他内容。。。 -->
<build>
<plugins>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<version>2.8.1</version>
<configuration>
<!--设置部署目录-->
<altDeploymentRepository>
internal.repo::default::file://${project.build.directory}/mvn-repo
</altDeploymentRepository>
</configuration>
</plugin>
</plugins>
</build>
<!-- 其他内容。。。 -->

在 settings.xml 中添加 github 用户信息

找到 maven 用户配置文件,默认位置在 _~/.m2/settings.xml_。如果不存在,则从 maven 安装目录复制一份过来,具体位置在 _MAVEN_HOME/conf/settings.xml_。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 其他内容。。。 -->
<servers>
<server>
<!-- id,这只是一个标识名,根据它找到用户名和密码 -->
<id>github</id>
<!-- github 用户名 -->
<username>rxliuli</username>
<!-- github 密码 -->
<password>123456</password>
</server>
</servers>
<!-- 其他内容。。。 -->

添加插件 com.github.github

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!-- 其他内容。。。 -->
<properties>
<!-- 设置 github 服务器使用的配置,在 ~/.m2/settings.xml 中定义 -->
<github.global.server>github</github.global.server>
</properties>

<build>
<plugins>
<plugin>
<groupId>com.github.github</groupId>
<artifactId>site-maven-plugin</artifactId>
<!--
这里需要使用 0.12, 0.9 部署时会出错,具体查看
https://github.com/github/maven-plugins/issues/105
-->
<version>0.12</version>
<configuration>
<!--git 提交的消息-->
<message>Maven artifacts for ${project.version}</message>
<!--禁用网页处理-->
<noJekyll>true</noJekyll>
<!--部署的目录,这里是和上面的 maven-deploy-plugin 的 configuration.altDeploymentRepository 对应-->
<outputDirectory>${project.build.directory}/mvn-repo
</outputDirectory> <!-- matches distribution management repository url above -->
<!--远程分支名-->
<branch>refs/heads/mvn-repo</branch>
<includes>
<include>**/*</include>
</includes>
<!--github 仓库的名字-->
<repositoryName>maven-repository-example</repositoryName>
<!--github 用户名-->
<repositoryOwner>rxliuli</repositoryOwner>
</configuration>
<executions>
<execution>
<goals>
<!--suppress MybatisMapperXmlInspection -->
<goal>site</goal>
</goals>
<phase>deploy</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
<!-- 其他内容。。。 -->

进行部署

使用命令进行部署

1
mvn clean deploy

查看 github 项目库,可以看到已经自动创建了一个分支 mvn-repo 并存放了部署后的文件。

使用

添加仓库地址

1
2
3
4
5
6
7
8
9
<repositories>
<repository>
<id>maven-repository-example</id>
<!-- 格式是 https://raw.githubusercontent.com/[github 用户名]/[github 仓库名]/[分支名]/repository -->
<url>
https://raw.githubusercontent.com/rxliuli/maven-repository-example/mvn-repo/repository
</url>
</repository>
</repositories>

就像其他 maven 仓库一样,我们知道 groupId, artifactIdversion,自然可以直接使用啦

1
2
3
4
5
<dependency>
<groupId>com.rxliuli</groupId>
<artifactId>maven-repository-example</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

注: 这种使用 github 部署的 maven 仓库,在 maven 中央仓库 中并不能搜索到的哦

程序员对 996 的反抗引来全球关注,它是如何以程序员自己的方式建立起来?

转自: https://m.douban.com/note/712945658/?dt_dapp=1,原文应该来自 好奇心日报,但目前已被删除。
国内的各大互联网公司争相屏蔽 996.ICU 及其 GitHub,甚至在 GitHub Issues 大肆雇佣水军发布乱七八糟的东西导致 Issues 被关闭。
包括百度上无法搜索到官网,中文搜索结果中甚至搜索不到 GitHub。QQ/360/百度/UC 这些常见的国内浏览器直接添加本地规则禁止访问,但 Chrome/Firefox 则安然无恙(所以真的无法理解程序员为什么会用 360?)

一场关于劳工权益的抗议在过去一周里爆发,让中国互联网公司习惯的 996(早九点、晚九点、一周六天)加班时间成为中国乃至全球关注的焦点。

有程序员发起了一个抵制 996 工作制的项目 996.ICU。讨论从技术社区开始,在互联网公司的微信群流传开来,蔓延到豆瓣、知乎,很快上了微博热搜,在百度的搜索热度达到平日的十倍。《中国青年报》、《南方日报》的报道被项目发起人标注进了项目的说明页面。

一周下来,事件的影响在海外逐渐升温。最早跟进报道的英文媒体是关注中国的《南华早报》,它在 3 月 29 日对此事的报道被 Python 之父 Guido van Rossum 转发并附上一句评论:“996 工作制是不人道的。

全球程序员聚集讨论区 Hacker News 和相应 Reddit 论坛,996 也成了一个热门话题。The Verge、Financial Times 分别在 4 月 2 日和 4 月 3 日跟进,《连线》等媒体也已经在采访参与者。

和前两年以悲剧方式获得全球关注的中国制造业从业者不同,程序员看起来是最不容易为工作环境走向对抗的一个群体。外界对他们的印象主要是收入高、习惯天天加班、办公室放着行军床、生活简单就想着买房结婚。一个典型的中产阶级行业,而中产阶级往往被认为是革命中重要但同时也是阻力最大的阶层。

但不同于其它也要日常加班,强度更大收入更少的行业,程序员有一个说话的渠道。

1

996.icu 网站的域名最早由一个年轻的程序员注册。

在技术论坛 V2EX 上,这位用户曾在一个关于工作薪酬讨论的帖子里提到自己毕业于北京一所 211 大学,在一家与百度、阿里、腾讯同级的大型互联网公司工作,刚转正没多久,每月薪酬在一万多元。

之前,他都在论坛里发帖讨论技术,讨论同一个公司不同的团队之间为什么薪资会有差别。但最近,他所在的公司开始实行 996 工作制。

3 月 20 日,他在论坛的域名推广版宣布注册了一个域名 http://996.icu,口号是:“工作 996,生病 ICU。”

3 月 26 日后,他在一个职场话题下回复道:“我才感到 996 多么毁人,除了工作就是休息,跟家人沟通都少了。”在那个回帖里,他再次推荐了 996.ICU 网站。

996.ICU 就是一个网站,自上而下分成 996 介绍、十七条劳动权益相关法规和相关事件报道三个部分。这三部分自第一天就在,虽然内容在过去一周日渐丰富起来。

996 icu

网站作者还在介绍后面加上了一句“Developers’ Lives Matter”(开发者的命也是命),模仿美国对抗种族不平等运动的“Black Lives Matter”(黑人的命也是命)。

它的影响力并不来自这个网页。而是来自同样在 3 月 26 日上线的 GitHub 项目。

项目由一个叫 996icu 的匿名 ID 发起,和域名持有者的关系不得而知。但从每天对最多 260 个建议的快速处理来看,这个账号即便是那位年轻程序员创建,现在很可能也不是他一个人在用。

今天,软件开发已经不需要凡事都重新发明一个轮子,用开源的代码可以快速完成一个功能。

而 GitHub 就是存放这些代码的最大平台。程序员们在 GitHub 上管理自己的项目,发起讨论、提交修改建议。

目前 GitHub 有三千万程序员用户,托管了大约 8000 万个代码仓库,平均每秒创建的存储库就有 1.6 个。2018 年,微软宣布 75 亿美元收购 GitHub

GitHub 上之前也有关于中国互联网公司加班问题的项目,两个月前一个主题是 “程序员找工作黑名单” 的项目上线,这个黑名单在两个月内获得了超过 18000 次“加星点赞”(Star)。

但这看起来更像是程序员在找工作时的一份操作指南。而 996.ICU 则更直接、更简单,从一开始就是一份反 996 工作制的宣言。

这让它迅速引起共鸣。

996 一直没有好名声。最早可以追溯到 2014 阿里巴巴怀孕员工加班回家后大出血去世 的新闻报道。2016 年,58 同城就因为对 2 万多员工强制 996 遭到不少抵制。

今年 1 月,杭州有赞 CEO 在公司年会上突然宣布全公司强制执行 996,有赞高管表示如果工作家庭不好平衡,可以选择离婚。

更近的,京东 3 月初开始执行 995 工作制度。面对质疑,京东公关总监刘力回应不会强制要求,但鼓励员工全情投入

中国互联网公司加班不被认为是特例,但以往有高回报盼头。2017 年,软件业平均薪资 13.3 万,连续两年超过金融业成为全国最赚钱的行业。阿里巴巴纽交所挂牌上市,一夜之间产生了 1 万多名千万富翁。

2018 年开始的上市潮没能重现另一个阿里巴巴。小米 2014 年估值达到 450 亿美元,2018 年上市前预期公司市值能有 800 - 1000 亿美元,四年老员工手中股票价值有望翻倍。但等到 2019 年年初员工可以卖股票的时候,小米市值缩水至 315 亿美元,股票价值也比 2014 年缩水 30%。

而小米已经是过去一年上市公司里表现很好的。蘑菇街直接以稀释员工股权的手段将员工持股价值缩水为 1/25。蘑菇街挂牌价为 14 美元,1 万股理应价值 96.6 万元,但稀释 25 倍后只有 4 万元。

甚至工作也不一定能保证。互联网行业早几年处于风口,大量风险投资涌入。创业公司不在乎利润,可以开更高薪水来挖人。

但现在形势变了。2018 年,中国 VC/PE 募资规模 341.12 亿美元,同比骤降 74.59%。靠烧钱做大规模的互联网公司上市后也面对盈利问题。缺乏资金来源,为了减少亏损,裁员成为一个普遍的做法。

去年 8 月,美图、拉勾网各裁员 20%。10 月,锤子科技解散成都分公司,为它从北京搬去成都的程序员也失去工作。11 月,趣店裁员 200 人,不从北京搬去杭州也得走,同月斗鱼裁员 70 人。12 月,知乎裁员 20%,300 人被要求当天离开公司;科大讯飞裁员 20%,回应为末位淘汰;摩拜裁员 30%,称为正常调整。今年 2 月,滴滴裁员 15%,2000 人离职;京东先裁员 20% 员工,2 月又裁员 10% 副总裁级别高管。3 月,腾讯中层裁去 20%,腾讯总裁刘炽平称 为年轻化主动革新。

高强度的工作变得强度更高、曾经可以指望的上市故事愈加渺茫,甚至能工作多少年也在大裁员背景下变得难以确定。

在这样的背景之下,996.ICU 项目上线一小时内就收获了超过 2000 颗星星,一天内加星数超过一万,登上了 GitHub 实时热门榜。

更多人注意到了。

2

Anony 就是在 3 月 26 日 996.ICU 上线一小时的时候开始关注的。他自称是一个普通的码农,平时喜欢逛 GitHub。他说自己平时逛 GitHub 就“类似于你们刷微博”。

尽管 Anony 所在公司没有实行 996 工作制,他还是持续对这个项目的进展保持关注,也参与了修改措辞的工作。

在 996.ICU 的讨论区(Issues 区)里,加星即将破十万前,有人留言说,没想到以这种方式参与了大型开源项目。

在社交网络上人们分享自己关于 996 工作的经历、见解以及解决方案。但在程序员社区里,一切都按照程序员们开发开源软件的方式推进着。

想象一栋充满无数会议室的大楼,每间会议室里的白板上写着一项软件的所有代码。GitHub 可以让程序员走入其中开放的会议室,观看白板上的代码(Watch),点赞(Star),并按自己的想法进行修改。

但随意修改会扰乱白板上的代码,Github 方便用户将白板上的代码库整体复制到自己账户下,修改不影响项目代码。修改完成后,程序员可以提交“拉回请求”(Pull Request)给项目维护者进行合并,将自己的修改加入项目。

这是目前开源软件在线协作的工作方法,而 996.ICU 的进化过程完全遵照此方法进行。

GitHub 上最初的 996.ICU 项目很简单:一段 26 行的 Markdown 格式网页文档。

一段段劳动法摘录、五一国际劳动节的由来、国际歌歌词、英文版本是最早的一批更新。

996.ICU 项目的补充、完善,都是由参与者以提交“拉回”请求的方式推进,逐渐成为今天的样子。

这和在论坛留言不一样,如果一个人看到代码就产生不适感,那就无法在这里提出修改请求。

各种细节被快速完善。有人提交请求,称主页面上的案例之一京东的英文版本如果直译 JINGDONG 无法让外国投资者理解,要改成 JD.COM

大小写、空格、错误链接、全角半角符号错用等问题都被更新,放进 Markdown 格式的文档里,还有人上传了自制的 Logo。

法语、德语、意大利语、日语、韩语、葡萄牙语、巴西葡萄牙语、泰语、繁体中文、越南语、俄语、希腊语等主要语言版本翻译都在项目上线第二天提交。还有人写了颇正式的文言文版本:“996”事制,即日早九至岗,直至晚九点事,每周工作六日。2016 年九初起,续有网爆料称,58 同城行全员 996 事制,且周末加班无文……

3 月 28 日是进展飞快第一天,项目有了两个关联项目:996 公司黑名单955 公司白名单

黑名单上都是实行了 996 或过度加班的公司,国内的大互联网公司基本在列,京东以超过 1200 票遥遥领先。作者还提出建议,希望在提名的时候加上部门、省区、工资范围,总之给出的信息越详细越好。

黑名单的作者原先将投票设置在一个论坛上,并没有考虑到会有那么多人参与投票,以至于网站太卡,人们只关注到了排名在前面的大公司。目前,这位作者正在建设新的网站,他在项目说明里写道:“因为太卡大家都只关注到了前面的大公司,希望大家能够注意到 996 的群体实际上很大,不仅仅有 BAT ,还有很多小公司。”

而白名单上的公司更多是外企。

955 公司白名单作者 formulahendry 在上海一家大外企做程序员,他的 GitHub 页面有超过 600 个关注者,还坚持用英文写过一段时间技术博客。

像几乎所有外企一样,formulahendry 并不需要 996。他对《好奇心日报》称,996 就完全没有个人的生活时间,最近这个现象越来越普遍,并不是一个好的趋势,他反对 996 的理由与许多人类似:比起一周工作 40 小时,一周工作 72 小时的 996 单位时间的产出不高。

注意到 996.ICU 之后,他觉得与其吐槽 996,不如看看有哪些公司实行 955(一周工作五天,朝九晚五) 的公司。

项目上线第二天,他创建白名单投票,项目的名字 WLB 是他自创的缩写,意思是 Work Life balance,即生活与工作的平衡。WLB 一天内加星数超过了 1000,上线第二天上了 GitHub 实时热门榜。

投票的雏形是基于 formulahendry 对上海 IT 公司的了解,在接下来的几天里,他利用下班在班车上的时间以及周末的时间维护着项目更新。

虽然 formulahendry 建了白名单,但是项目被创建出来之后就不再是他一个人能决定的事:一切都得按照 GitHub 的方式来。

996 公司黑名单则吸引了更多的参与:一个公司被添加到黑名单的方式与开源项目代码审核没什么太大区别,每个人都可以提交论据,只是提交论据的方式对非程序员来说有一定门槛。论据包括媒体报道、知乎讨论、公司官网公告等。

华为 和 字节跳动等公司 是否能够上榜都引发了众多讨论。在要求“删除黑名单字节跳动”的请求下有 51 组对话,要求删除的人和不同意删除的人各自举证。

发起删除请求的用户给出了两点理由:

  1. 公司虽然下班晚,上班也晚,算上午饭、晚饭后的休息时间,实际工作时长并没有一天 12 小时;公司实行大小周,但是小周周日加班给加班费;
  2. 猝死事件的程序员是刚从腾讯跳槽到字节跳动不久,不能武断归咎于字节跳动加班。

反驳方指出“说的好像 955 的公司不午休不吃饭一样。”接着还有反驳者给出加班费计算方法的猫腻,比如周日加班按 1.2 倍工资而不是 2 倍工资计算。

一个请求能否通过,实际上是以一种简单的投票实现的。删除字节跳动的请求有 8 人支持,269 人反对。最后 996.ICU 项目负责人拒绝了删除字节跳动的请求。

虽然项目负责账号可以对请求作出判断,但由于开源项目的可复制性,如果项目发起人背离社区大多数人的诉求,程序员们可以很容易转而支持另一个在这个基础上分出来的项目。

从一个个人项目,演变为社区项目之后,决定项目发展的就不再是发起人,而是所有参与贡献的人共同决定的结果。这些都是开源软件开发的基本方式。

就像比如 Linux 系统最早是 Linus 一个人的作品。但随着全球开发者的加入,有 1.5 万开发者和超过 1400 家公司添砖加瓦。现在 Linux 远不是 Linus 所能控制的,它的管理由一个专门的基金会所负责。

996.ICU 也没什么区别。不论是谁启动了这个项目,现在它已经不再是一个人决定的事。

3

讨论变得敏感,是从 3 月 29 日项目的 issues 被关闭开始。

仓库下的讨论区(Issues)突然被关闭,也引发了不少讨论,比如 issues 太多(过了十万)、太多广告,还有人贴出白宫请愿的号召截图怀疑是政治原因。几小时后,作者贴出解释称是主动选择关闭。

不过这对讨论没有太大影响。项目上线之初,有人发出各地微信群的二维码,与此同时就有人回应说程序员应该用程序员的工具,而不是用 QQ、微信来沟通。于是程序员常用的即时通信工具 Slack、游戏即时通信工具 Discord 都被用起来、国内不能直接访问的通讯工具 Telegram 上也出现了 996.ICU 讨论组。

996.ICU 的 Slack 讨论频道在五天内吸引了 1000 多名成员,成员自发将 Slack 群组划分成了公告、招聘、讨论、中文、英文、城市等数个频道。在 Slack 频道里讨论最多的,是劳动权益受到侵害之后如何维权。

一名拥有二十年经验的程序员陈皓 在知乎回答了问题:“如果想抵制 996,除了利用 GitHub 发起抗议,还有哪些巧妙的方案?”,提出通过法律手段、政府信访、集体抗议等途径。

在 Slack 群组讨论中,他的解决方案遇到了不少阻力,一些成员质疑劳动仲裁的效率,还有一些成员则担忧信访手段可能招致政府机关的强制手段。

在完善了措辞、不同语言版本和黑名单、白名单之后。996.ICU 从上周末开始的最大进展是“反 996 软件授权协议”从想法到落地。

3 月 27 日,一个想法被热切讨论后采用:设计一种关于劳动保护的软件授权协议——996ICU 协议,如果这个协议被兼容进各个开源项目的授权协议,实行 996 工作制的公司不得使用该开源项目,就可能给公司带来实际的约束作用。

劳动法一直在,但执行是问题。大互联网公司往往为一个城市解决数万就业,有些还提供数十甚至上百亿利税。理论上地方法院,比如深圳当地法院应该公平对待腾讯、华为和它们的员工,但这是理论上。

而软件授权协议是软件行业内的一个约束。简单来说,软件授权协议就像是一本书的版权声明。在项目开发中开源软件的使用不可避免,使用开源软软件不需要付费,但是需要遵守作者写在授权协议中的条款,如果公司或个人使用了开源代码,但是没有遵守条款,作者可以据此提起诉讼,要求赔偿、停止使用代码。

这个想法被上千人点赞,400 多人参与了该协议的讨论。

但协议怎么写还没人想好,一开始只有这样三行:

The 996ICU License (996ICU)Copyright © 2019 3 月 30 日晚间,伊利诺伊大学厄巴纳 - 香槟分校法学博士 Katt Gu 花了一夜时间起草了授权协议。这个版本在常用的 MIT 开源授权协议基础之上 改编。协议一共三条,在所有协议都会有的惯例版权声明条款,Katt Gu 增加了两条内容:

  • 个人或法人实体必须严格遵守与个人实际所在地或个人出生地或归化地、或法人实体注册地或经营地(以较严格者为准)的司法管辖区所有适用的与劳动和就业相关法律、法规、规则和标准。如果该司法管辖区没有此类法律、法规、规章和标准或其法律、法规、规章和标准不可执行,则个人或法人实体必须遵守国际劳工标准的核心公约。

  • 个人或法人不得以任何方式诱导或强迫其全职或兼职员工或其独立承包人以口头或书面形式同意直接或间接限制、削弱或放弃其所拥有的,受相关与劳动和就业有关的法律、法规、规则和标准保护的权利或补救措施,无论该等书面或口头协议是否被该司法管辖区的法律所承认,该等个人或法人实体也不得以任何方法限制其雇员或独立承包人向版权持有人或监督许可证合规情况的有关当局报告或投诉上述违反许可证的行为的权利。

Katt Gu 关注新技术立法,也著有关于 中国特定领域法律与实践鸿沟 的论文。

3 月 29 日晚上,Katt Gu 的丈夫,一直关注软件版权演进的创业者 Suji Yan 在看到 996.ICU 之后催促她起草一份协议。Gu 回忆说最初她并不愿意。

“因为我本人是一个工作狂,但是仔细想想这是一个关乎自愿选择的事。”Gu 对《好奇心日报》表示,她的顾虑还来源于专业,这份协议起草涉及 IP 法、劳动法以及国际公约等领域的研究,她从未有过相关经验。

起草协议之前,一位律师告诉她,要做好这份协议,得先研究十年劳动法、再研究十年 IP 法,或者在几个涉及的领域各找十个专家来讨论、研究,再拟定草稿。

但是 Katt Gu 觉得协议的实用效果远小于宣传效果,今天最重要的那些开源协议一开始也不是以那样的立法方式做出来的。

经过一夜研究,她决定以最简单的通用法(Common Law)为基础,草拟一份协议草案。而起草的模版则是简单且应用广泛的 MIT 开源协议。

这份协议以及协议期待产生的效果,都是相当理想化的。

根据 Suji Yan 所设想的最理想状态,协议可以对中国互联网公司有一个约束。他的逻辑是今天软件开发基本上已经不可能不用开源代码——微软一度都是开源软件的最大贡献者。当足够多的开源项目用了 996ICU 协议,企业强制 996 就等于自己的产品违反协议,开源代码拥有者就可以起诉该公司。哪怕在中国大陆起诉艰难,这些公司基本都在香港或美国上市,也可以在其它地区发起诉讼。

不过解释完之后,他自己也说“这当然是一个极其理想化的设想”。

“本来觉得我们不做会有别人做,但是现在觉得还是需要更多人关注。”Katt Gu 说协议公布之后收到一封感谢邮件,“感觉就像看医生收到那种‘妙手回春’的锦旗。”

对协议约束力持怀疑态度的人很普遍,而反对者也不少。比如目前 GitHub 上第三大项目的创作者尤雨溪在微博上表示自己个人反对和谴责 996,但是也反对在开源项目许可证中包含 Discriminatory Clause(禁止部分用户使用的条款),或是利用开源项目做任何形式的政治博弈。

“我个人有个人的看法,但项目是中立的。Vue 的许可证不会禁止任何人使用,现在不会,以后也不会。”他在微博上写道。Vue.js 是一个开发网页用户界面的框架。

但添加反 996 许可证的项目 在不断增多。目前已经有 75 个开源项目添加了许可证,其中大部分是个人开发者维护的项目,但也有多个点赞数在四五千以上,有一定规模、被很多公司使用的开源软件项目加入。而和 Vue 相关的数个插件项目也加入了反 996 许可协议——任何项目开源扩大之后,就不再是作者一个人决定的了。

Katt Gu 称接下来自己的协议修改要它与更多的主流开源软件协议兼容,这样才有希望被更多开源项目所接纳。

“我自己只想把这个协议写好,写好估计就没有我啥事了,如果要写细,可能要花两三年。”Katt Gu 说做完这个应该就不会管了。Suji Yan 则补充了一句:“还给社区。”

社区有一个大目标。4 月 1 日,有人号召把反 996 协议加入 GitHub 官方收录协议 当中,这样新开源项目就可以很容易把协议加入项目。

更长远的目标是让它被 GitHub 高亮显示,这样任何项目在创建选择协议时都有机会看到它。

这些都不容易,尤其是在主页高亮,需要超过 1000 个公开项目使用该协议,3 个明星项目使用、OSI 和 GNU 批准。

作为开源软件的推动力量,理查德·斯托曼( Richard Stallman)在 1985 年发表《GNU 宣言》,提出所有软件都应该贯彻自由软件的精神——运行、复制、发布、研究、修改和改进该软件的自由。

但从一开始,这个运动就没有停留在技术或者免费用软件的层面,发起者认为大众都能接触到的应用都应完全掌控在用户自己手中。如果 996.ICU 的贡献者们计划成功,这将是开源软件届第一个被认可的约定劳动权益的协议。

一周不到,对 996.ICU 的反击已经来了。

没有公司敢直接攻击 GitHub。2015 年的持续攻击 没有成功。现在它是微软的财产,商业公司攻击它就更不明智。

攻击者们用了自己最习惯的手段,屏蔽。

3 月 30 日开始,Slack 讨论群组中开始有人报错:北京地区微信内无法打开 996.ICU 的 GitHub 页面,更多的测试显示,这不是孤例。

QQ 浏览器称这个唯一公开诉求就是让公司遵守劳动法的网站包含欺诈信息。微信显示的理由则是“该网页包含违法或违规内容,为维护绿色上网环境,已停止访问”。阿里巴巴旗下的 UC 浏览器和 360 浏览器都将该页面认定为包含违法信息的网站。最独特的猎豹浏览器打开这个页面会弹出提示称“网站含有大量淫秽色情信息”。

使用百度搜索 996,中文版搜索结果里找不到 996.icu 网站和对应 GitHub 页面的链接。

和以往不同的是,这一次它们并不能直接封杀 996.ICU 的存在,也没法让讨论它的渠道消失。