你尚未登录,仅允许查看本站部分内容。请登录使用邀请码注册
yibuyisheng

受保护的对象 0个回复 专栏 @ Javascript

yibuyisheng 发布于 10 月前

原文链接

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

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

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

任务执行器

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

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

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

  • 设置节点 a 的文本: a.innerText = 'text1'
  • 设置节点 a 的文本: a.innerText = 'text2'
  • 设置节点 a 的文本: 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;
    }
}

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

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

  • 代码的第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 对象的键并没有销毁,所以每次任务执行的时候,遍历次数都会只增不减。

事件基类

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

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

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] 进行锁定。

总结

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

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

等待第一条回复
登录后回复,如无账号,请使用邀请码注册