一篇文章带你深入理解 JavaScript 中的 Symbol 与定时器

170 阅读5分钟

在编程的世界里,我们时常会遇到一些棘手的问题,这些问题往往需要我们深入理解语言特性才能有效解决。今天,我们将一起探讨一个常见但又容易被忽视的主题:JavaScript中的Symbol类型以及它如何帮助我们解决变量覆盖和数据丢失的问题。同时,我们也会探究定时器的行为,了解为什么有时候预期的结果并没有出现。

01 | 定时器行为解析

让我们从一个简单的定时器例子开始。假设你编写了以下代码来创建一个自定义的setTimeout函数,期望它每隔一秒打印一次'hello world',预期总共五次:

function customSetTimeout(fn, time) {
    let intervalID = null;
    function loop() {
        intervalID = setTimeout(() => {
            fn();
            loop();
        }, time);
    }
    loop();
    return () => clearTimeout(intervalID);
}

const interval = customSetTimeout(function () {
    console.log('hello world');
}, 1000);

setTimeout(() => {
    // 调用清除
    interval();
}, 5000);

出错呐~~

然而,当你运行这段代码时,可能会惊讶地发现它只打印了四次'hello world',而不是预期的五次。原因在于,在第五次调用loop函数时,虽然确实创建了一个新的定时器,计划在一秒钟后再次调用fn函数,但在该定时器触发之前,interval函数已经被调用了,从而清除了所有未来的执行。因此,fn函数实际上只会被执行四次。

这个问题的根本原因是clearTimeout被调用的时间点恰好在最后一次setTimeout之前。要确保定时器能够完整地执行预定次数,可以考虑使用计数器或者其他方法来更精确地控制循环的终止条件。

02| 解决对象属性覆盖问题

接下来,我们来看另一个常见的场景:当我们在对象中定义多个同名属性时,后定义的属性值会覆盖先前的定义。例如:

const person = {
    name: 'wes',
    name: 'wes2'
};
console.log(person.name); // 输出: 'wes2'

这种情况下,person对象的name属性最终指向的是最后一个定义的值。为了解决这一问题,我们可以引入Symbol类型作为对象的键。Symbol是ES6引入的一种原始数据类型,它的主要特点之一就是每次调用Symbol()都会返回一个全新的、独一无二的符号。这意味着即使两个Symbol拥有相同的描述符,它们也互不相同,不会导致属性覆盖。


但是,直接使用Symbol作为键时,如果不在外部保存对这个Symbol实例的引用,那么之后就无法通过这个Symbol来访问对应的属性值。这是因为每次创建Symbol都会生成一个新的实例,而这些实例之间是不可比较的。例如:

const person = {
    name: 'wes',
    [Symbol('Mark')]: 'hello2',
    [Symbol('olivia')]: { gender: 'female', age: 22 },
    [Symbol('olivia')]: { gender: 'female', age: 22 } // 重复定义
};

console.log(person.name); // 输出: 'wes'
console.log(person[Symbol('Mark')]); // 输出: undefined

如上所示,尝试访问person[Symbol('Mark')]时得到的是undefined,因为每次访问时都创建了一个新的Symbol实例,而这个新实例并不对应于对象中的任何属性。为了正确地访问由Symbol定义的属性,我们需要将Symbol实例存储在一个可访问的地方,比如全局上下文或闭包环境中。


此外,当我们遍历对象时,Object.entries()Object.keys()Object.values() 方法都不会包含Symbol类型的键或其关联的值。如果我们想要获取对象中所有的Symbol键,可以使用Object.getOwnPropertySymbols()方法。下面是一个改进后的例子,展示了如何正确地使用Symbol并遍历对象的所有键值对,包括Symbol类型的键:

const wes = Symbol('wes');
const person = {
    name: 'wes',
    [wes]: 'hello2',
    [Symbol('Mark')]: 'hello2',
    [Symbol('olivia')]: { gender: 'female', age: 22 }
};

console.log(person[wes]); // 正确输出: 'hello2'

// 遍历常规键值对
for (let [key, val] of Object.entries(person)) {
    console.log(key, val);
}

// 获取所有 Symbol 键
const syms = Object.getOwnPropertySymbols(person);
syms.forEach(sym => {
    console.log(sym.description, person[sym]);
});

03 | 关于 this 的绑定

最后,我们简要讨论一下this关键字的绑定问题。在JavaScript中,this的值取决于函数的调用方式。特别是在异步回调(如setTimeout)中,this可能不会指向我们期望的对象。考虑以下代码:

var obj = {
    name: 'cherry',
    func1: function() {
        console.log(this.name);
    },
    func2: function() {
        setTimeout(function() {
            this.func1(); // 如果不绑定,这里的 this 不指向 obj
        }.bind(obj), 4000);
    }
};

obj.func2();

如果不使用.bind(obj)来绑定this,那么在setTimeout的回调函数中,this将不再指向obj,而是指向全局对象(在浏览器环境中通常是window),这会导致this.func1()报错,因为func1不是window的成员。通过使用.bind(obj),我们可以确保this始终指向obj,从而避免错误的发生。

当然,还有其他方法可以实现类似的效果,比如使用箭头函数,它会继承外部作用域的this值,或者使用callapply方法来显式地设置this的值。不过需要注意的是,callapply会立即执行函数,这在异步操作中通常是不合适的,因为我们希望延迟执行回调函数。

总结

通过上述讨论,我们可以看到,JavaScript 提供了许多强大的工具和特性来帮助我们更好地管理代码。无论是通过Symbol来避免对象属性的意外覆盖,还是正确处理this的绑定问题,理解这些细微之处都能使我们的代码更加健壮和易于维护。