原文链接
虽然 JavaScript 没有多线程变量共享的问题,但是在一些场景中,我们还是希望能对某些对象进行适当的保护(锁定),防止发生一些不可预期的错误。
本文主要从如下两个实际场景展开:
任务执行器
现在,我们需要一个 DOM 操作的任务执行器,这个任务执行器满足的主要功能有:
为啥需要这么个东西呢?假设其中有下面三步 DOM 操作:
a.innerText = 'text1'
; a.innerText = 'text2'
; a.innerText = 'text3'
。 如果老老实实设置三次,感觉太不划算了!实际上只需要设置最后一次就好了,这样就可以减少两次无谓的 DOM 操作了。
初步看起来,我们的任务执行器代码大致会像这个样子:
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; } }
好了,看起来似乎可以了,那就到实际环境遛遛吧!
不遛不知道,一遛吓一跳,跳出来一些莫名其妙的问题:
this[TASKS]
不存在; 仔细分析一下代码,可以发现:
requestAnimationFrame
的回调函数的时候,某个 taskFn 或者 notifyFn 可能会调用 destroy()
方法,从而将 this[TASKS]
设为了 false ,然后再执行到90行,就报错了。 notifyFn
的时候,添加进第二个任务(调用了 add() 方法),然后执行到90行,将该类型任务置为 null ,这样一来,第二个任务的回调函数就没有机会执行了。 所以,问题的根源就在任务执行过程中调用了不可控的外部函数,从而导致 this[TASKS]
发生变化。
对于第一个类型的问题,可以简单地使用 IS_RUNNING 状态绕开。对于第二种类型的问题,就最好找一种更优雅通用的解决方案了。
我们注意到,传入 requestAnimationFrame
的回调函数体(行范围:[72-90])是一个敏感地带,执行这块代码的时候,应该将 this[TASKS]
锁定,防止不可控的外部函数( taskFn 和 notifyFn )对其进行干扰。
其实简单说起来,这类问题就是 for in
循环中,被遍历的对象应该是 可读的
的一个变体,所以,可以抽离出来一个比较通用的类,具体实现代码请移步到 这里 。
现在,我们的 DOM 操作任务执行器
看起来就像 这样了 。
目前看来,这个 DomUpdater
还有些小地方需要优化:
事件基类
在搭建前端框架的时候,一般都会期望各个功能模块能够解耦合。通常情况下,会使用事件来达到这个效果。
第一次写这个类的话,很有可能就写成了这样:
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]
进行锁定。
总结
本质上,这类问题就是传入的函数中做了不希望做的事情,所以如何禁止或者兼容这些 不希望做的事情
是关键点。
本文为作者在实践中总结出来的方案,能力有限,期待读者提出更好的方案。