JS 爱好者的十大反向教学(译)

大家好,这里是大家的林语冰。

免责声明

本文属于是语冰的直男翻译了属于是,仅供粉丝参考,英文原味版请临幸 The 10 Most Common JavaScript Issues Developers Face。

今时今日,JS(JavaScript)几乎是所有现代 Web App 的核心。这就是为什么 JS 出问题,以及找到导致这些问题的错误,是 Web 开发者的最前线。

用于 SPA(单页应用程序)开发、图形和动画以及服务器端 JS 平台的给力的 JS 库和框架不足为奇。JS 在 Web App 开发领域早已无处不在,因此是一项越来越需要加点的技能树。

乍一看,JS 可能很简单。事实上,对于任何有经验的软件开发者而言,哪怕它们是 JS 初学者,将基本的 JS 功能构建到网页中也是举手之劳。

虽然但是,这种语言比大家起初认为的要更微妙、给力和复杂。事实上,一大坨 JS 的微妙之处可能导致一大坨常见问题,无法正常工作 ------ 我们此处会讨论其中的 10 个问题。在成为 JS 大神的过程中,了解并避免这些问题十分重要。

问题 1:this 引用失真

JS 开发者对 JS 的 this 关键字不乏困惑。

多年来,随着 JS 编码技术和设计模式越来越复杂,回调和闭包中自引用作用域的延伸也同比增加,此乃导致 JS "this 混淆"问题的"万恶之源"。

请瞄一眼下述代码片段:

js 复制代码
const Game = function () {
  this.clearLocalStorage = function () {
    console.log('Clearing local storage...')
  }
  this.clearBoard = function () {
    console.log('Clearing board...')
  }
}

Game.prototype.restart = function () {
  this.clearLocalStorage()
  this.timer = setTimeout(function () {
    this.clearBoard() // this 是什么鬼物?
  }, 0)
}

const myGame = new Game()
myGame.restart()

执行上述代码会导致以下错误:

md 复制代码
未捕获的类型错误: this.clearBoard 不是函数

为什么呢?这与上下文有关。出现该错误的原因是,当您执行 setTimeout() 时,您实际是在执行 window.setTimeout()。因此,传递给 setTimeout() 的匿名函数定义在 window 对象的上下文中,该对象没有 clearBoard() 方法。

一个传统的、兼容旧浏览器的技术方案是简单地将您的 this 引用保存在一个变量中,然后可以由闭包继承,举个栗子:

js 复制代码
Game.prototype.restart = function () {
  this.clearLocalStorage()
  const self = this // 当 this 还是 this 的时候,保存 this 引用!
  this.timer = setTimeout(function () {
    self.clearBoard() // OK,我们可以知道 self 是什么了!
  }, 0)
}

或者,在较新的浏览器中,您可以使用 bind() 方法传入正确的引用:

js 复制代码
Game.prototype.restart = function () {
  this.clearLocalStorage()
  this.timer = setTimeout(this.reset.bind(this), 0) // 绑定 this
}

Game.prototype.reset = function () {
  this.clearBoard() // OK,回退到正确 this 的上下文!
}

问题 2:认为存在块级作用域

JS 开发者之间混淆的"万恶之源"之一(因此也是 bug 的常见来源)是,假设 JS 为每个代码块创建新的作用域。尽管这在许多其他语言中是正确的,但在 JS 中却并非如此。举个栗子,请瞄一眼下述代码:

js 复制代码
for (var i = 0; i < 10; i++) {
  /* ... */
}
console.log(i) // 输出是什么鬼物?

如果您猜到调用 console.log() 会输出 undefined 或报错,那么恭喜您猜错了。信不信由你,它会输出 10。为什么呢?

在大多数其他语言中,上述代码会导致错误,因为变量 i 的"生命"(即作用域)将被限制在 for 区块中。虽然但是,在 JS 中,情况并非如此,即使在循环完成后,变量 i 仍保留在范围内,在退出 for 循环后保留其最终值。(此行为被称为变量提升。)

JS 对块级作用域的支持可通过 let 关键字获得。多年来,let 关键字一直受到浏览器和后端 JS 引擎(比如 Node.js)的广泛支持。如果这对您来说是新知识,那么值得花时间阅读作用域、原型等。

问题3:创建内存泄漏

如果您没有刻意编码来避免内存泄漏,那么内存泄漏几乎不可避免。它们有一大坨触发方式,因此我们只强调其中两种更常见的情况。

示例 1:失效对象的虚空引用

注意:此示例仅适用于旧版 JS 引擎,新型 JS 引擎具有足够机智的垃圾回收器(GC)来处理这种情况。

请瞄一眼下述代码:

js 复制代码
var theThing = null
var replaceThing = function () {
  var priorThing = theThing // 保留之前的东东
  var unused = function () {
    // unused 是唯一引用 priorThing 的地方,
    // 但 unused 从未执行
    if (priorThing) {
      console.log('hi')
    }
  }
  theThing = {
    longStr: new Array(1000000).join('*'), // 创建一个 1MB 的对象
    someMethod: function () {
      console.log(someMessage)
    }
  }
}
setInterval(replaceThing, 1000) // 每秒执行一次 replaceThing

如果您运行上述代码并监视内存使用情况,就会发现严重的内存泄漏 ------ 每秒有一整兆字节!即使是手动垃圾收集器也无济于事。所以看起来每次调用 replaceThing 时我们都在泄漏 longSte。但是为什么呢?

如果您没有刻意编码来避免内存泄漏,那么内存泄漏几乎不可避免。

让我们更详细地检查一下:

每个 theThing 对象都包含自己的 1MB longStr 对象。每一秒,当我们调用 replaceThing 时,它都会保留 priorThing 中之前的 theThing 对象的引用。但我们仍然不认为这是一个问题,因为每次先前引用的 priorThing 都会被取消引用(当 priorThing 通过 priorThing = theThing; 重置时)。此外,它仅在 replaceThing 的主体中和 unused 函数中被引用,这实际上从未使用过。

因此,我们再次想知道为什么这里存在内存泄漏。

要了解发生了什么事,我们需要更好地理解 JS 的内部工作原理。闭包通常由链接到表示其词法作用域的字典风格对象(dictionary-style)的每个函数对象实现。如果 replaceThing 内部定义的两个函数实际使用了 priorThing,那么它们都得到相同的对象是很重要的,即使 priorThing 逐次赋值,两个函数也共享相同的词法环境。但是,一旦任何闭包使用了变量,它就会进入该作用域中所有闭包共享的词法环境中。而这个小小的细微差别就是导致这种粗糙的内存泄漏的原因。

示例 2:循环引用

请瞄一眼下述代码片段:

js 复制代码
function addClickHandler(element) {
  element.click = function onClick(e) {
    alert('Clicked the ' + element.nodeName)
  }
}

此处,onClick 有一个闭包,它保留了 element 的引用(通过 element.nodeName)。通过同时将 onClick 赋值给 element.click,就创建了循环引用,即 element -> onClick -> element -> onClick -> element ......

有趣的是,即使 element 从 DOM 中删除,上述循环自引用也会阻止 element 和 onClick 被回收,从而造成内存泄漏。

避免内存泄漏:要点

JS 的内存管理(尤其是它的垃圾回收)很大程度上基于对象可达性(reachability)的概念。

假定以下对象是可达的,称为"根":

  • 从当前调用堆栈中的任意位置引用的对象(即,当前正在执行的函数中的所有局部变量和参数,以及闭包作用域中的所有变量)
  • 所有全局变量

只要对象可以通过引用或引用链从任何根访问,那么它们至少会保留在内存中。

浏览器中有一个垃圾回收器,用于清理不可达对象占用的内存;换而言之,当且仅当 GC 认为对象不可达时,才会从内存中删除对象。不幸的是,很容易得到已失效的"僵尸"对象,这些对象不再使用,但 GC 仍然认为它们可达。

问题 4:混淆相等性

JS 的便捷性之一是,它会自动将布尔上下文中引用的任何值强制转换为布尔值。但在某些情况下,这可能既香又臭。

举个栗子,对于一大坨 JS 开发者而言,下列表达式很头大:

js 复制代码
// 求值结果均为 true!
console.log(false == '0');
console.log(null == undefined);
console.log(" \t\r\n" == 0);
console.log('' == 0);

// 这些也是 true!
if ({}) // ...
if ([]) // ...

关于最后两个,尽管是空的(这可能会让您相信它们求值为 false),但 {} 和 [] 实际上都是对象,并且 JS 中任何对象都将被强制转换为 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 操作

JS 使得操作 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))

除了这种方法固有的提高效率之外,创建附加的 DOM 元素代价昂贵,而在分离时创建和修改它们,然后附加它们会产生更好的性能。

问题 6:在 for 循环中错误使用函数定义

请瞄一眼下述代码:

js 复制代码
var elements = document.getElementsByTagName('input')
var n = elements.length // 我们假设本例有 10 个元素
for (var i = 0; i < n; i++) {
  elements[i].onclick = function () {
    console.log('This is element #' + i)
  }
}

根据上述代码,如果有 10 个输入元素,单击其中任何一个都会显示"This is element #10"!这是因为,在为任何元素调用 onclick 时,上述 for 循环将完成,并且 i 的值已经是 10(对于所有元素)。

以下是我们如何纠正此问题,实现所需的行为:

js 复制代码
var elements = document.getElementsByTagName('input')
var n = elements.length // 我们假设本例有 10 个元素
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 会设置为该内部函数。这确保每个 onclick 接收和使用正确的 i 值(通过作用域的 num 变量)。

问题 7:误用原型式继承

令人惊讶的是,一大坨 JS 爱好者无法完全理解和充分利用原型式继承的特性。

下面是一个简单的示例:

js 复制代码
BaseObject = function (name) {
  if (typeof name !== 'undefined') {
    this.name = name
  } else {
    this.name = 'default'
  }
}

这似乎一目了然。如果您提供一个名称,请使用该名称,否则将名称设置为"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

然后我们会得到:

js 复制代码
console.log(secondObj.name) // -> 结果是 'undefined'

骚然但是,将其恢复为"default"不是更好吗?如果我们修改原始代码以利用原型式继承,这很容易实现,如下所示:

js 复制代码
BaseObject = function (name) {
  if (typeof name !== 'undefined') {
    this.name = name
  }
}

BaseObject.prototype.name = 'default'

在此版本中,BaseObject 从其 prototype 对象继承该 name 属性,其中该属性(默认)设置为 'default'。因此,如果调用构造函数时没有名称,那么名称将默认为 default。同样,如果从 BaseObject 的实例删除该 name 属性,那么会搜索原型链,并从 prototype 对象中检索值仍为 'default' 的 name 属性。所以现在我们得到:

js 复制代码
var thirdObj = new BaseObject('unique')
console.log(thirdObj.name) // -> 结果是 'unique'

delete thirdObj.name
console.log(thirdObj.name) // -> 结果是 'default'

问题 8:创建对实例方法的错误引用

让我们定义一个简单对象,并创建它的实例,如下所示:

js 复制代码
var MyObjectFactory = function () {}

MyObjectFactory.prototype.whoAmI = function () {
  console.log(this)
}

var obj = new MyObjectFactory()

现在,为了方便起见,让我们创建一个 whoAmI 方法的引用,大概这样我们就可以通过 whoAmI() 访问它,而不是更长的 obj.whoAmI():

js 复制代码
var whoAmI = obj.whoAmI

为了确保我们存储了函数的引用,让我们打印出新 whoAmI 变量的值:

js 复制代码
console.log(whoAmI)

输出:

js 复制代码
function () {
    console.log(this);
}

目前它看起来不错。

但是瞄一眼我们调用 obj.whoAmI() 与便利引用 whoAmI() 时的区别:

js 复制代码
obj.whoAmI() // 输出 "MyObjectFactory {...}" (预期)
whoAmI() // 输出 "window" (啊这!)

哪里出了问题?我们的 whoAmI() 调用位于全局命名空间中,因此 this 设置为 window(或在严格模式下设置为 undefined),而不是 MyObjectFactory 的 obj 实例!换而言之,该 this 值通常取决于调用上下文。

箭头函数((params) => {} 而不是 function(params) {})提供了静态 this,与常规函数基于调用上下文的 this 不同。这为我们提供了一个技术方案:

js 复制代码
var MyFactoryWithStaticThis = function () {
  this.whoAmI = () => {
    // 请注意此处的箭头符号
    console.log(this)
  }
}

var objWithStaticThis = new MyFactoryWithStaticThis()
var whoAmIWithStaticThis = objWithStaticThis.whoAmI

objWithStaticThis.whoAmI() // 输出 "MyFactoryWithStaticThis" (同往常一样)
whoAmIWithStaticThis() // 输出 "MyFactoryWithStaticThis" (箭头符号的福利)

您可能已经注意到,即使我们得到了匹配的输出,this 也是对工厂的引用,而不是对实例的引用。与其试图进一步解决此问题,不如考虑根本不依赖 this(甚至不依赖 new)的 JS 方法。

问题 9:提供一个字符串作为 setTimeout or setInterval 的首参

首先,让我们在这里明确一点:提供字符串作为首个参数给 setTimeout 或者 setInterval 本身并不是 一个错误。这是完全合法的 JS 代码。这里的问题更多的是性能和效率。经常被忽视的是,如果将字符串作为首个参数传递给 setTimeout 或 setInterval,它将被传递给函数构造函数以转换为新函数。这个过程可能缓慢且效率低下,而且通常非必要。

将字符串作为首个参数传递给这些方法的替代方法是传入函数。让我们举个栗子。

因此,这里将是 setInterval 和 setTimeout 的经典用法,将字符串作为首个参数传递:

js 复制代码
setInterval('logTime()', 1000)
setTimeout("logMessage('" + msgValue + "')", 1000)

更好的选择是传入一个函数作为初始参数,举个栗子:

js 复制代码
setInterval(logTime, 1000) // 将 logTime 函数传给 setInterval

setTimeout(function () {
  // 将匿名函数传给 setTimeout
  logMessage(msgValue) // (msgValue 在此作用域中仍可访问)
}, 1000)

问题 10:禁用"严格模式"

"严格模式"(即在 JS 源文件的开头包含 'use strict';)是一种在运行时自愿对 JS 代码强制执行更严格的解析和错误处理的方法,也是一种使代码更安全的方法。

诚然,禁用严格模式并不是真正的"错误",但它的使用越来越受到鼓励,省略它越来越被认为是不好的形式。

以下是严格模式的若干主要福利:

  • 更易于调试。本来会被忽略或静默失败的代码错误现在将生成错误或抛出异常,更快地提醒您代码库中的 JS 问题,并更快地将您定位到其源代码。
  • 防止意外全局变量。如果没有严格模式,将值赋值给给未声明的变量会自动创建同名全局变量。这是最常见的 JS 错误之一。在严格模式下,尝试这样做会引发错误。
  • 消除 this 强制类型转换 。如果没有严格模式,对 null 或 undefined 值的 this 引用会自动强制转换到 globalThis 变量。这可能会导致一大坨令人沮丧的 bug。在严格模式下,null 或 undefined 值的 this 引用会抛出错误。
  • 禁止重复的属性名或参数值 。严格模式在检测到对象中的重名属性(比如 var object = {foo: "bar", foo: "baz"};)或函数的重名参数(比如 function foo(val1, val2, val1){})时会抛出错误,从而捕获代码中几乎必然出错的 bug,否则您可能会浪费大量时间进行跟踪。
  • 更安全的 eval() 。严格模式和非严格模式下 eval() 的行为存在某些差异。最重要的是,在严格模式下,eval() 语句中声明的变量和函数不会在其包裹的作用域中创建。(它们在非严格模式下是在其包裹的作用域中创建的,这也可能是 JS 问题的常见来源。)
  • delete 无效使用时抛出错误 。delete 运算符(用于删除对象属性)不能用于对象的不可配置属性。当尝试删除不可配置属性时,非严格代码将静默失败,而在这种情况下,严格模式将抛出错误。

使用更智能的方法缓解 JS 问题

与任何技术一样,您越能理解 JS 奏效和失效的原因和方式,您的代码就会越可靠,您就越能有效地利用语言的真正力量。

相反,缺乏 JS 范式和概念的正确理解是许多 JS 问题所在。彻底熟悉语言的细微差别和微妙之处是提高熟练度和生产力的最有效策略。

您现在收看的是前端翻译计划,学废了的小伙伴可以订阅此专栏合集,我们每天佛系投稿,欢迎持续关注前端生态。谢谢大家的点赞,掰掰~

相关推荐
有味道的男人7 分钟前
搜好货平台关键词商品搜索接口:分页容错与商品数据轻量化提取方案
服务器·前端·javascript
lovingsoft1 小时前
Chrome Lighthouse 详解
前端·chrome
Qlly1 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝1 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦1 小时前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡1 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI1 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95272 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人2 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载2 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码