虽然 JavaScript 没有多线程变量共享的问题,但是在一些场景中,我们还是希望能对某些对象进行适当的保护(锁定),防止发生一些不可预期的错误。

本文主要从如下两个实际场景展开:

  • 任务执行器;
  • 事件基类。

任务执行器

现在,我们需要一个 DOM 操作的任务执行器,这个任务执行器满足的主要功能有:

  • 能够添加任务;
  • 能够批量执行任务;
  • 能够随时启动和停止任务的执行。

为啥需要这么个东西呢?假设其中有下面三步 DOM 操作:

  • 设置节点 a 的文本: a.innerText = 'text1'
  • 设置节点 a 的文本: a.innerText = 'text2'
  • 设置节点 a 的文本: a.innerText = 'text3'

如果老老实实设置三次,感觉太不划算了!实际上只需要设置最后一次就好了,这样就可以减少两次无谓的 DOM 操作了。

初步看起来,我们的任务执行器代码大致会像这个样子:

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
const TASKS = Symbol('tasks');
const COUNTER = Symbol('counter');
const EXECUTE = Symbol('execute');
const IS_RUNNING = Symbol('isRunning');
export default class DomUpdater {
constructor() {
this[TASKS] = {};
this[COUNTER] = 0;
}
/**
* 获取任务ID,每一种类型操作对应一个任务ID,
* 比如对某个节点的innerText就可以算是一种类型的操作,具有唯一的任务ID
*
* @public
* @return {string} 任务ID
*/
getTaskId() {
return '' + ++this[COUNTER];
}
/**
* 添加任务函数
*
* @public
* @param {Function} taskFn 任务函数
* @param {Function} notifyFn 任务执行完成之后的回调函数
*/
add(taskId, taskFn, notifyFn) {
const task = this[TASKS][taskId] || {};
task.taskFn = taskFn;
// 为啥notifyFns会是一个数组,而taskFn不是数组呢?
// 因为我们期望后续同类型的(taskId相同)的任务能够覆盖掉之前的任务,
// 而之前任务的回调函数需要保留,这样就可以保证一定会通知外界某个任务已经执行完成了。
task.notifyFns = task.notifyFns || [];
task.notifyFns.push(notifyFn);
this[TASKS][taskId] = task;
this[EXECUTE]();
}
/**
* 启动任务执行
*
* @public
*/
start() {
this[IS_RUNNING] = true;
this[EXECUTE]();
}
stop() {
this[IS_RUNNING] = false;
}
/**
* 执行任务
*
* @private
*/
[EXECUTE]() {
if (!this[IS_RUNNING]) {
return;
}
window.requestAnimationFrame(() => {
for (let taskId in this[TASKS]) {
const task = this[TASKS][taskId];
if (!task) {
continue;
}
let result;
let error;
try {
result = task.taskFn();
}
catch (err) {
error = err;
}
for (let i = 0, il = task.notifyFns.length; i < il; ++i) {
task.notifyFns[i](error, result);
}
this[TASKS][taskId] = null;
}
});
}
destroy() {
this[TASKS] = null;
}
}

好了,看起来似乎可以了,那就到实际环境遛遛吧!

不遛不知道,一遛吓一跳,跳出来一些莫名其妙的问题:

  • 代码的第90行报this[TASKS]不存在;
  • 总是会有任务的回调函数没有被调用。

仔细分析一下代码,可以发现:

  • 对于第一个问题,在执行传入 requestAnimationFrame 的回调函数的时候,某个 taskFn 或者 notifyFn 可能会调用 destroy() 方法,从而将 this[TASKS] 设为了 false ,然后再执行到90行,就报错了。
  • 对于第二个问题,假设有两个同类型的任务,在 ‘EXECUTE’ 中调用第一个任务的 notifyFn 的时候,添加进第二个任务(调用了 add() 方法),然后执行到90行,将该类型任务置为 null ,这样一来,第二个任务的回调函数就没有机会执行了。

所以,问题的根源就在任务执行过程中调用了不可控的外部函数,从而导致 this[TASKS] 发生变化。

对于第一个类型的问题,可以简单地使用 IS_RUNNING 状态绕开。对于第二种类型的问题,就最好找一种更优雅通用的解决方案了。

我们注意到,传入 requestAnimationFrame 的回调函数体(行范围:[72-90])是一个敏感地带,执行这块代码的时候,应该将 this[TASKS] 锁定,防止不可控的外部函数( taskFn 和 notifyFn )对其进行干扰。

其实简单说起来,这类问题就是 for in 循环中,被遍历的对象应该是可读的的一个变体,所以,可以抽离出来一个比较通用的类,具体实现代码请移步到这里

现在,我们的DOM 操作任务执行器看起来就像这样了

目前看来,这个 DomUpdater 还有些小地方需要优化:

  • TASKS 任务遍历顺序不应该依赖于对象上键的遍历顺序。
  • TASKS 对象的键并没有销毁,所以每次任务执行的时候,遍历次数都会只增不减。

事件基类

在搭建前端框架的时候,一般都会期望各个功能模块能够解耦合。通常情况下,会使用事件来达到这个效果。

第一次写这个类的话,很有可能就写成了这样:

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import {isFunction} from './util';
const EVENTS = Symbol('events');
const STATE = Symbol('state');
const STATE_READY = Symbol('stateReady');
const STATE_DESTROIED = Symbol('stateDestroied');
const CHECK_READY = Symbol('checkReady');
export default class Event {
constructor() {
this[EVENTS] = {};
this[STATE] = STATE_READY;
}
/**
* 在调用on、trigger、safeTrigger、asyncTrigger、off的时候,要检查一下当前event对象的状态。
*
* @private
*/
[CHECK_READY]() {
if (this[STATE] !== STATE_READY) {
throw new Error('wrong event state: the event object is not ready.');
}
}
/**
* 绑定事件
*
* @public
* @param {string} eventName 事件名字
* @param {Function} fn 回调函数
* @param {Object=} context 上下文对象
*/
on(eventName, fn, context) {
this[CHECK_READY]();
if (!isFunction(fn)) {
return;
}
let events = this[EVENTS];
events[eventName] = events[eventName] || [];
events[eventName].push({fn, context});
}
/**
* 同步触发事件
*
* @public
* @param {string} eventName 事件名字
* @param {...[*]} args 要传给事件回调函数的参数列表
*/
trigger(eventName, ...args) {
this[CHECK_READY]();
let fnObjs = this[EVENTS][eventName];
for (let fnObj of fnObjs) {
fnObj.context::fnObj.fn(...args);
}
}
/**
* 移除事件回调
*
* @public
* @param {...[*]} args eventName,fn,context
* @param {string=} args.0 参数名字
* @param {function=} args.1 回调函数
* @param {Object=} args.2 上下文对象
*/
off(...args) {
this[CHECK_READY]();
let [eventName, fn, context] = args;
if (args.length === 0) {
this[EVENTS] = {};
}
let iterator = checkFn => {
let fnObjs = this[EVENTS][eventName];
let newFnObjs = [];
for (let fnObj of fnObjs) {
if (checkFn(fnObj)) {
newFnObjs.push(fnObj);
}
}
this[EVENTS][eventName] = newFnObjs;
};
if (args.length === 1) {
this[EVENTS][eventName] = null;
}
else if (args.length === 2) {
iterator(fnObj => fn !== fnObj.fn);
}
else if (args.length === 3) {
iterator(fnObj => fn !== fnObj.fn || context !== fnObj.context);
}
}
destroy() {
this[EVENTS] = null;
this[STATE] = STATE_DESTROIED;
}
}

trigger() 循环事件处理器的时候,事件回调函数很可能会通过 on() 间接修改 this[EVENTS] ,因此,我们需要使用 ProtectObject 来对 this[EVENTS] 进行锁定。

总结

本质上,这类问题就是传入的函数中做了不希望做的事情,所以如何禁止或者兼容这些不希望做的事情是关键点。

本文为作者在实践中总结出来的方案,能力有限,期待读者提出更好的方案。