JavaScript 几乎已经是所有现代 web 应用程序的核心。虽然将基本的 JavaScript 功能构建到网页中都是一项相当简单的任务,即使他们是JavaScript新手。但是 Javascript 本身的灵活性、微妙性导致开发者(特别是初级开发者)经常会面临一些 Javascript 带来的问题。
本篇文章,我会和你一起讨论其中的 10 个问题。如果你想成为一名成熟的 JavaScript 开发者,了解并避免这些问题是很重要的。
1. this:错误引用
JavaScript 中回调和闭包中的自引用作用域常常在设计模式中用到,这是导致 JavaScript问题的"混乱"的一个相当常见的来源。
例如下面这段代码:
js
const Game = function() {
this.clearLocalStorage = function() {
console.log("clear storage");
};
this.clearBoard = function() {
console.log("clear board");
};
};
Game.prototype.restart = function () {
this.clearLocalStorage();
this.timer = setTimeout(function() {
this.clearBoard(); // this
}, 0);
};
const myGame = new Game();
myGame.restart();
执行上述代码会导致以下错误:
js
Uncaught TypeError: this.clearBoard is not a function
为什么会导致这样的错误? 一切都取决于你的开发/生产环境。你得到这个错误的原因是因为,当你调用 setTimeout()
时,你实际上是在调用 window.setTimeout()
。因此,传递给 setTimeout()
的匿名函数是在 window
对象的上下文中定义的,该对象没有clearBoard()
方法。
一个传统的解决方案是简单地将你对 this
的引用保存在一个变量中,这个变量可以被闭包继承,例如:
js
Game.prototype.restart = function () {
this.clearLocalStorage();
const self = this; // 保存在一个变量中
this.timer = setTimeout(function(){
self.clearBoard(); // 通过
}, 0);
};
或者,你可以使用 bind()
方法来传递正确的引用:
js
Game.prototype.restart = function () {
this.clearLocalStorage();
this.timer = setTimeout(this.reset.bind(this), 0); // bind
};
Game.prototype.reset = function(){
this.clearBoard(); // 通过
2. 块级作用域
JavaScript 开发者一个常见的 bug 是假设 JavaScript 为每个代码块创建一个新的作用域。虽然这在许多其他语言中是正确的,但在 JavaScript 中不是这样。例如,下面这段代码:
js
for (var i = 0; i < 10; i++) {
/* ... */
}
console.log(i);
如果你猜测 console.log()
是输出 undefined
,或者是抛出错误,那么你猜错了。因为,它将输出 10。为什么?
在大多数其他语言中,上面的代码都会导致类似这样的错误。因为变量 i
的"生命周期"(即作用域)被限制在 for
循环语句中。但在 JavaScript 中,情况并非如此,即使在 for
循环完成后,变量 i
仍留在作用域中,在退出循环后保留其最后一个值 。(这种行为被称为变量提升。)
有一个解决办法。通过 let
关键字可以在 JavaScript 中支持块级作用域。
3. 内存泄漏
内存泄漏在 JavaScript 中几乎是不可避免的问题。它们发生的方式有很多种,因此这里我只想向你强调两种更常见的情况。
3.1 对失效对象的空引用
虽然这个例子只适用于老旧的 JavaScript 引擎(因为现代的引擎有足够聪明的垃圾收集器来处理这种情况),但是我还是想要强调一下。
例如下面这段代码:
js
var theThing = null;
var replaceThing = function () {
var priorThing = theThing;
var unused = function () {
if (priorThing) {
console.log("hi");
}
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
setInterval(replaceThing, 1000);
当你运行上面的代码并监视内存使用情况,将发现有一个严重的内存泄漏问题。即使是手动垃圾收集器也无济于事。看起来每次调用 replaceThing
时我们都会泄漏longStr
。但是这是为什么呢?
让我们重新更详细地检查这段代码一下,发现:
每个 theThing
对象都包含大小为 1MB
的 longStr
对象。每一秒钟,当我们调用replaceThing
时,它都会保存一个对 priorThing
中先前的 theThing
对象的引用。但我们仍然不认为这是一个问题,因为每次通过先前引用的 priorThing
将被解除引用。而且,它只在replaceThing
的主体和未使用的函数中被引用,而未使用的函数实际上从未使用过。
所以再次疑惑为什么这里会有内存泄漏。
为了理解发生了什么,我们需要更好地理解 JavaScript 的内部工作原理。闭包通常由链接到表示其词法范围的字典对象的每个函数对象实现。如果在 replaceThing
内部定义的两个函数,实际上都使用了 priorThing
,那么它们都获得相同的对象,即使priorThing
被反复赋值,以便两个函数共享相同的词法环境。但是,一旦某个变量被任何闭包使用,它就会进入该范围内所有闭包共享的词法环境中。正是这个细微差别导致了这种严重的内存泄漏。
3.2 循环引用
下面这段代码:
js
function addClickHandler(element) {
element.click = function onClick(e) {
alert("Clicked the " + element.nodeName)
}
}
这里,onClick
有一个闭包,它通过 element.nodename
保持对 element
的引用。触发点击之后,循环引用被创建,即 element→onClick→element→onClick→element...
有趣的是,即使从 DOM 中删除了 element
,上面的循环引用也会阻止 element
和onClick
被收集,从而导致内存泄漏。
所以,要如何避免?接着往下看。
3.3 避免内存泄漏
JavaScript 的内存管理(特别是它的垃圾收集)很大程度上是基于对象可达性的概念。
以下对象被认为是可达的:
- 从当前调用堆栈中的任何位置引用的对象(即当前被调用的函数中的所有局部变量和参数,以及闭包作用域中的所有变量)
- 所有全局变量
- 只要对象可以通过引用或引用链从任何根访问,对象就会保存在内存中
浏览器中有一个垃圾收集器,用于清理不可访问对象占用的内存。换句话说,当且仅当 GC 认为对象不可访问时,对象才会从内存中删除。不幸的是,很容易得到不再使用的"僵尸"对象,但 GC 仍然认为它们是可访问的。
4 .等号的困惑
JavaScript 的一个便利之处在于,它将自动强制在布尔上下文中引用的任何值转化为布尔值。但在某些情况下,这种做法既方便又令人困惑。例如,对于许多 JavaScript 开发者来说,下面的表达式是很麻烦的:
js
console.log(false == '0'); // true
console.log(null == undefined); // true
console.log(" \t\r\n" == 0); // true
console.log('' == 0); // true
// true
if ({}) // ...
if ([]) // ...
至于最后两个,尽管是空的,但{}
和[]
实际上都是对象,并且任何对象都将在JavaScript 中强制为布尔值 true
,这与ECMA-262
规范一致。
因此,除非明确需要类型强制转换,否则通常最好使用===
和!==
(而不是==
和!=
),以避免类型强制转换的任何意外副作用。因为,==
和!=
在比较两个东西时会自动执行类型转换,而===
和!==
在不进行类型转换的情况下执行相同的比较。
由于我们正在讨论类型强制转换和比较,因此值得一提的是,将 NaN
与任何东西(甚至NaN!
)进行比较总是返回 false
。因此,不能使用相等运算符(==、===、!=、!==
)来确定一个值是否为 NaN
。相反,应该使用内置的全局函数 isNaN()
:
js
console.log(NaN == NaN); // false
console.log(NaN === NaN); // false
console.log(isNaN(NaN)); // true
5. 低效的 DOM 操作
虽然使用 JavaScript 操作 DOM (例如,添加、修改和删除元素)变得相对容易,但却无法提高操作效率。
一个常见的例子是每次添加一个 DOM 元素的代码。添加 DOM 元素是一项开销很大的操作,连续添加多个 DOM 元素的代码效率很低,很可能不能很好地工作。
当需要添加多个 DOM 元素时,一种有效的替代方法是使用文档片段(document fragments),它将有效提高效率和性能:
js
const div = document.getElementById("my_div");
const fragment = document.createDocumentFragment();
const elems = document.querySelectorAll('a');
for (let e = 0; e < elems.length; e++) {
fragment.appendChild(elems[e]);
}
div.appendChild(fragment.cloneNode(true));
6. 在 for 循环中错误地使用函数定义
考虑这段代码:
js
const elements = document.getElementsByTagName('input');
const n = elements.length;
for (var i = 0; i < n; i++) {
elements[i].onclick = function() {
console.log("This is element #" + i);
};
}
根据上面的代码,如果有 10 个输入元素,单击其中任何一个都会显示"这是元素#10"! 这是因为,当对任何元素调用 onclick
时,上面的 for
循环已经完成,i
的值已经是 10 了。
下面我们来纠正这个问题:
js
const elements = document.getElementsByTagName('input');
const n = elements.length;
var makeHandler = function(num) {
return function() {
console.log("This is element #" + num);
};
};
for (var i = 0; i < n; i++) {
elements[i].onclick = makeHandler(i+1);
}
在这个修改后的代码版本中,每次通过循环时都立即执行 makeHandler
,每次都接收当时的值 i+1
并将其绑定到一个有作用域的 num
变量。外部函数返回内部函数(它也使用这个作用域为 num
的变量),元素的 onclick
被设置为内部函数。通过限定范围的 num
变量,确保每个 onclick
接收并使用正确的 i 值。
7. 未能恰当地利用原型继承
相当多的 JavaScript 开发者没有完全理解原型继承的特性,因此也没有充分利用原型继承的特性。
这里有一个简单的例子:
js
BaseObject = function(name) {
if (typeof name !== "undefined") {
this.name = name;
} else {
this.name = 'default'
}
};
这看起来相当简单。如果提供了一个name
,就使用这个 name
,否则将 name
设置为' default '。例如:
js
var firstObj = new BaseObject();
var secondObj = new BaseObject('unique');
console.log(firstObj.name); // -> 'default'
console.log(secondObj.name); // -> 'unique'
但是如果我们这样做呢:
js
delete secondObj.name;
我们会得到 undefined
:
js
console.log(secondObj.name); // -> 'undefined'
如果我们修改原始代码来利用原型继承,这很容易做到,如下所示:
js
BaseObject = function (name) {
if(typeof name !== "undefined") {
this.name = name;
}
};
BaseObject.prototype.name = 'default';
在这个版本中,BaseObject
从它的原型对象中继承了 name
属性,在那里它被设置为 'default'。因此,如果在没有 name 的情况下调用构造函数,则该名称将默认为 default。类似地,如果 name
属性从 BaseObject
的实例中移除,那么原型链将被搜索,name
属性将从原型对象中检索,其值仍然是'default'。现在我们得到:
js
const thirdObj = new BaseObject('unique');
console.log(thirdObj.name); // -> 'unique'
delete thirdObj.name;
console.log(thirdObj.name); // -> 'default'
8. 对实例方法的错误引用
让我们定义一个简单的对象,并创建它的实例,如下所示:
js
const MyObjectFactory = function() {}
MyObjectFactory.prototype.whoAmI = function() {
console.log(this);
};
const obj = new MyObjectFactory();
现在,为了方便起见,让我们创建一个对 whoAmI
方法的引用,这样我们就可以仅仅通过whoAmI()
而不是更长的 obj.whoAmI()
来访问它:
js
const whoAmI = obj.whoAmI;
为了确保我们已经存储了一个对函数的引用,让我们打印出新的 whoAmI
变量的值:
js
console.log(whoAmI);
输出:
js
function () {
console.log(this);
}
到目前为止看起来还不错。
但是,当我们调用 obj.whoAmI()
和 whoAmI()
时,看看它们有什么区别:
js
obj.whoAmI(); // "MyObjectFactory {...}"
whoAmI(); // "window"
出了什么问题? 我们的 whoAmI()
调用位于全局空间中,因此它被设置为 window
(或者,在严格模式下,为 undefined
),而不是 MyObjectFactory
的 obj
实例!
换句话说,this 的值通常取决于调用的上下文。
但是,现在有了新的方法。由于 箭头函数 ((params) =>{})
提供了一个静态 this ,它不像常规函数那样基于调用上下文,因为我们可以使用箭头函数处理这个问题:
js
const MyFactoryWithStaticThis = function() {
this.whoAmI = () => {
console.log(this);
};
}
const objWithStaticThis = new MyFactoryWithStaticThis();
const whoAmIWithStaticThis = objWithStaticThis.whoAmI;
objWithStaticThis.whoAmI(); // "MyFactoryWithStaticThis"
whoAmIWithStaticThis(); // "MyFactoryWithStaticThis" 箭头函数起效了!
9. 字符串作为 setTimeout 或 setInterval 的第一个参数
对于初学者,让我们在这里弄清楚一些事情: 字符串作为 setTimeout 或setInterval
的第一个参数本身并不是一个错误。这是完全合法。这里的问题更多的是性能和效率问题。
我们经常会忽略一个问题,如果将字符串作为第一个参数传递给 setTimeout或setInterval,它将被传递给函数构造函数以转换为新函数 。这个过程可能是缓慢和低效的。例如下面这段代码:
js
setInterval("logTime()", 1000);
setTimeout("logMessage('" + msgValue + "')", 1000);
那么,你的更好选择是传入一个函数作为初始参数,例如:
js
setInterval(logTime, 1000); // 传入函数 logTime
setTimeout(function() { // 传入匿名函数
logMessage(msgValue);
}, 1000);
10. 没有使用 "严格模式"
"严格模式"是一种在运行时自愿对 JavaScript 代码执行更严格的解析和错误处理的方法,也是一种使代码更安全的方法。
不使用严格模式并不是一个真正的"错误",但它的使用越来越受到鼓励。
以下我总结了严格模式一些主要的好处:
- 使调试更容易。原本会被忽略或悄无声息地失败的代码错误现在会生成错误或抛出异常,从而更快地提醒你,并更快地引导你找到它们的来源。
- 防止意外的全局变量。在没有严格模式的情况下,将值赋给未声明的变量会自动创建一个具有该名称的全局变量。这是最常见的 JavaScript错 误之一。在严格模式下,尝试这样做会抛出错误。
- 在没有严格模式的情况下,对 this 值 null 或 undefined 的引用将自动强制到
globalThis
变量,这可能会导致许多意外的错误。但在严格模式下,引用 this 值为null 或 undefined 会抛出错误。 - 禁止重复的属性名或参数值。当严格模式检测到对象中的重复命名属性 或函数的重复命名参数 (例如,函数
foo(val1, val2, val1){}
)时,会抛出错误,从而捕获代码中几乎可以肯定的错误,否则可能会浪费大量时间来跟踪。 - 使
eval()
更安全。eval()
在严格模式和非严格模式下的行为方式有所不同。最重要的是,在严格模式下,在eval()
语句中声明的变量和函数不会在包含范围内创建。它们是以非严格模式在包含范围中创建的,这也可能是 JavaScript 的常见问题。 - 无效使用
delete
时抛出错误。删除操作符(用于从对象中删除属性)不能用于对象的不可配置属性 。当尝试删除不可配置的属性时,非严格模式代码将静默失败,而在这种情况下,严格模式将抛出错误。
好了,上面就是我想写给 Javascript 初级开发者的一些问题总结。 最后,我想说的是,与任何技术一样,你越了解 JavaScript 的工作原理,你的代码就越可靠,你就越能够有效地利用该语言的力量处理问题。相反,缺乏对 JavaScript 概念的正确理解是许多 JavaScript问题的根源。彻底熟悉语言的细微差别和微妙之处是提高你的编码效率的最有效策略。