发布于 2026-01-05 2 阅读
0

Javascript:我不明白的地方 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

Javascript:我不明白的地方

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

JavaScript 是最容易上手的语言之一。但是,使用者和精通者之间存在着明显的差距。JavaScript 充满了细微差别、模糊的行为模式和隐藏的概念。如果你不了解它们,它会把你逼疯。

JavaScript陷阱

很久很久以前,在一个遥远的星系,我加入了一个新团队。我的团队以强大的 PHP 技术著称。这一天意义非凡。我放弃了对 PHP 的执着,加入了一个以 JavaScript 为核心的团队。

现在,我确信两件事。JavaScript很简单,我已经完全掌握了。使用它不需要真正理解这门语言的底层工作原理。一切都会没问题的。

但很快,我开始察觉到一些令人不安的迹象。我遇到了一些完全晦涩难懂的代码、概念和术语。我当时并没有立刻感到担忧,因为这离我的职责范围还很远。

我当时就应该感到担忧。

几周后,我接到了团队中的第一个重要任务。

对产品的关键服务进行全面重写。

无需赘述细节,我们可以将这项服务比作某种 CDN。客户端发送一个 ZIP 文件,我的服务需要处理很多事情,包括实时递归解压缩(ZIP 嵌套压缩)、上传、缓存、静态文件服务、版本控制和元数据管理。所有这些操作都必须保证 100% 的调用延迟在 200 毫秒以内。

要正确地完成这类事情,需要对 JavaScript 的工作原理有深入的了解。我当时还不懂。我即将面对各种错误和令人费解的行为。

我刚刚落入了JavaScript的陷阱。

表面上看,JavaScript 非常容易上手,你可以很快用它做出很多精彩的东西。通常只需要对它的内部机制略知一二就足够了。因此,很多人在使用它的时候并不真正了解自己在做什么。

但当你着手处理更复杂的事情时,你很快就会迷失方向,你的冒名顶替综合症就会开始强烈地困扰着你。

未知变量

在我讲述我创办这项服务之初让我抓狂的事情之前,让我们先回到几年前。和许多人一样,我是在工作中自学 JavaScript 的。我不得不学,所以就开始学了。

当时需要我写 jQuery 代码。我自认为在这方面很厉害,所有任务都能完成。然而,尽管我这么想,但时不时还是会遭遇一些挫折。

简单的事情都不管用。它莫名其妙地出问题。更奇怪的是,我用力敲键盘也没用。

我的问题源于我对 Javascript 的第一个不理解之处:变量和类型的内部工作原理。

为了理解我的意思,我们来看一些代码。

这段代码会显示什么内容?为什么

const originalEzio = {
  "name": "ezio Auditore da Firenze",
  "weapon": "Hidden Blade",
  "metadata": {
    "version": "Original",
    "type": "Assassin"
  }
};

originalEzio.name[0] = 'E';

function getHeroCopy(originalHero) {
  let copyHero = {
    name: originalHero.name,
    weapon: originalHero.weapon,
    metadata: originalHero.metadata
  };

  copyHero.metadata.version = 'Copy';

  return copyHero;
}

const copyOfEzio = getHeroCopy(originalEzio);

console.log('Original : ', originalEzio);
console.log('Copy : ', copyOfEzio);
Enter fullscreen mode Exit fullscreen mode

我知道,这看起来像个愚蠢的 JavaScript 小知识问答题。但请玩玩这个游戏,花点时间预测一下它会显示什么。

点击下方 Repl 的播放按钮,让我们来验证一下你的预测。

如果你无法解释这个结果,说明你对语言的基础理解有误。以下是简要解释。

变量分为两大类:基本变量和复变量。

  • 基本类型(字符串、数字、布尔值等)指向唯一值。

它们是不可变的。因此字符串不会改变(第 10 行)。顺便说一句,如果在文件开头添加“use strict”,它会立即抛出异常。在严格的 JavaScript 环境下,这种“魔鬼操作”是不允许的。

  • 复合体(对象,…)指向值引用。

它们是可变的。第 16 行,我引用了原始英雄的元数据对象,并将其赋值给副本的元数据对象。因此,通过更改副本,我实际上也更改了对原始英雄的引用。

我刚开始的时候并没有这些想法。相信我,没有这些想法可不好玩。很多人都没有这些想法。

今天的目的不是给你们上课,而是指出我遇到的一些陷阱,以确保你们能够避免它们。

文章结尾处我有一些建议,可以帮助你理解并克服所有这些陷阱。

但在此之前,让我们继续指出我沉溺于痛苦之中的地方。

这他妈是什么玩意儿

为了重写这项服务,我使用了多个内部和外部库。有些库比较新,有些则比较旧;有些库的编写水平也参差不齐。它们都充分利用了 JavaScript 的对象特性。

更准确地说,是面向原型的编程,一种不完整的面向对象编程形式。

即使到了今天,尽管有了类这种语法糖,它本质上仍然是原型。JavaScript 并非真正的面向对象语言。欢迎在推特上与持不同意见者辩论。

// what you use
class Assassin {
  constructor(name) {
    this.name = name;
  }

  getCreed() {
    return "Nothing is true, everything is permitted.";
  }
}

//---------------

// what JS really does behind
function Assassin(name){
  this.name = name;
}

Assassin.prototype.getCreed = function() {
  return "Nothing is true, everything is permitted.";
}

Enter fullscreen mode Exit fullscreen mode

简而言之,我了解了 JavaScript 中的上下文。面对这些混乱不堪的规则,我立刻开始疯狂敲击键盘。

又是一道烦人的冷知识题。

***这段代码会显示什么内容?为什么? ***


const altair = {
  name: "Altaïr Ibn-La'Ahad",
  templarsKilled: ['Tamir', 'Talal', 'Sibrand'],
  showTemplarsKilled: function() {
    console.log(`List of templar killed (${this.templarsKilled.length}) by ${this.name}`)

    this.templarsKilled.forEach(function(templarKilled) {
      console.log(`${this.name} killed ${templarKilled}`)
    });
  }
};

altair.showTemplarsKilled();

Enter fullscreen mode Exit fullscreen mode

您可以使用下面的 Repl 来检查您的预测。

为什么第二个对数(第 8 行)不起作用?为什么第一个对数(第 5 行)起作用?为什么使用箭头函数(第 7 行)可以解决问题?

如果你无法回答这些问题,那是因为你对 JavaScript 上下文的概念还不熟悉。这很正常。在 JavaScript 中,上下文的行为方式与其他语言截然不同。

我们正在面对一个怪物。

理论上,“this”代表函数的上下文,即与函数调用关联的对象。但实际情况并非如此简单。实际上,它取决于函数的调用方式。

我们来看一些例子。

在函数中调用时,上下文将是全局对象。如果你不知道这一点,就会错误地修改全局对象。这是非常糟糕的。

this.creed = "Nothing is true, everything is permitted.";

function showCreed() {
    console.log(this.creed)
}

showCreed();
Enter fullscreen mode Exit fullscreen mode

严格模式下除外。严格模式下,它是未定义的。你不知道,这次一切都出错了。

"use strict"

this.creed = "Nothing is true, everything is permitted.";

function showCreed() {
    console.log(this)
}

showCreed(); // undefined
Enter fullscreen mode Exit fullscreen mode

在函数方法中调用时,上下文将是目标对象,这正是我们所期望的。这就是为什么上面的“showTemplarsKilled”函数可以正常工作。但下一个嵌套函数却不行。下一个嵌套函数有它自己的上下文。

showTemplarsKilled: function() {
    // this -> objet context
    console.log(`List of templar killed (${this.templarsKilled.length}) by ${this.name}`)

    this.templarsKilled.forEach(function(templarKilled) {
      // this -> function context
      console.log(`${this.name} killed ${templarKilled}`)
    });
}
Enter fullscreen mode Exit fullscreen mode

我不知道你是否见过创建像“self”或“_this”这样会传递当前上下文的变量的代码?这就是原因所在。这是一种相当糟糕的权宜之计,用来保留当前上下文。

showTemplarsKilled: function() {
    const self = this;
    console.log(`List of templar killed (${self.templarsKilled.length}) by ${self.name}`)

    self.templarsKilled.forEach(function(templarKilled) {
      console.log(`${self.name} killed ${templarKilled}`)
    });
  }
Enter fullscreen mode Exit fullscreen mode

如今,最优雅的方法是使用箭头函数。它不仅能使代码更易读、更简洁,还能将当前上下文传递给被调用的函数。真棒!

showTemplarsKilled: function() {
    console.log(`List of templar killed (${this.templarsKilled.length}) by ${this.name}`)

    this.templarsKilled.forEach(templarKilled => console.log(`${this.name} killed ${templarKilled}`));
  }
Enter fullscreen mode Exit fullscreen mode

我跟你说过我不想说教,但我还是忍不住要解释。如果我开始胡言乱语,请你打断我。

总之,在我做这项著名服务的时候,我对这一切都毫不知情。而且,所有这些根据呼叫地点和方式而定的规则让我感到非常恐慌。

这让我作品的制作速度和质量……嗯,可以说是令人质疑。最初的几周非常艰难。即便事实并非如此,我也感觉我的团队开始怀疑我能带​​来什么。

耗费了大量(甚至可以说是太多)时间和精力,我才逐渐地,一个模块一个模块地,最终做出一些东西。然而,这仅仅是我探索的开始。我的痛苦远未结束。

部署

我就不赘述路上的各种奇遇了,咱们直接进入部署环节。到了部署那一步,我确信我的代码已经运行正常。我做了三百万次测试,已经在开发环境运行了一周。我当时真是信心满满,愿意为此打赌。

周一早上,我终于部署了服务,一切运行正常。

但随着时间的推移,新版本用户越来越多,我发现响应时间也越来越令人担忧地增加。下午时分,我收到了第一封客户邮件。

这显然与我的服务有关。

即使我仔细查看了运行缓慢的代码,我仍然不明白。响应时间越来越长,我越来越摸不着头脑。

这并非什么大错,而是一系列细微的错误累积起来,才拖慢了我的申请进度。我们来仔细看看其中一个。我保证,这是最后一个面试问题,问完我就不打扰你了。

以下代码有什么问题?

function _load (assetFile, assetRoute) {
  return this.cdn.getFileInfo(assetFile)

  .then(assetInfo => this.setAssetInCache(JSON.Stringify(assetFile), assetInfo))

  .then(() => this.getAssetFromCache(assetRoute))

  .then(data => {
    if (data) {
      return Promise.resolve(data)
    } else {
      return Promise.reject("Can't get asset from cache.")
    }
  })

  .catch(error => Promise.reject(error))
}
Enter fullscreen mode Exit fullscreen mode

问题出在第 5 行使用了 JSON.stringify。这​​是一个阻塞操作。在非阻塞的异步环境中,必须格外小心这类操作。

JSON.stringify 会阻塞它所在的线程。由于 JavaScript 是单线程的,这会造成问题。所以,Promise 的确可以延迟阻塞的执行。但是,当 stringify 执行时,在它完成之前,其他任何操作都会停止。

因此,应用程序的其余部分都被阻塞了。

大多数情况下,字符串化操作不会有问题。需要字符串化的文件很小,几乎瞬间就能完成。但这里的情况不同,需要同时处理成千上万个大小不一的文件。

响应时间一毫秒一毫秒地增加,最终每次呼叫需要 1 秒!

用户使用这款应用越多,对所有人来说就越是一种折磨。

就是从那天起,我对事件循环真正产生了兴趣。

它如何运作,关键所在,以及各个阶段。从定时器到关闭回调再到 I/O 轮询。它在 NodeJS 中非常有用,而且在浏览器中的 JavaScript 应用中也同样适用。

所以,即使浏览器和 NodeJS 中事件循环的全局运行机制相同,缩放时也会存在差异,这一点很重要。我这么说是因为总会有自诩为“专家”的人用令人难以忍受的方式纠正你,好像这很重要似的。

总之,花了一些时间,也流了点血汗,我最终还是把所有出错的地方都改正了。响应时间降到了200毫秒以内。我还以为我已经彻底摆脱了这种痛苦的学习方式呢。

崩溃点

几周后,我参加了一个与同事的会议。这是一次重要的会议,我要在会上讨论一些技术问题。会议计划推出一项新服务。

这次会议本应成为促使我采取行动的转折点。

我几乎没提那次会议。尽管我了解了一些服务内容,但还是跟不上。各种概念和技术术语满天飞。

讨论变得越来越复杂,参与其中又不说蠢话更是难上加难。讨论的内容涉及闭包、生成器、内存泄漏风险以及使用代理进行高级监控等问题。

这一切在我脑子里一片混乱。是时候采取行动,摆脱这片迷雾了。

提升你的游戏水平

会后回到岗位,我鼓起勇气,向一位同事询问会议内容。讨论很快就转到了他读过的一本书上。

今日推荐:《JavaScript忍者的秘密》

这本书是我建立JavaScript自信的起点。

通过深入讲解内部运作原理,表面上的行为也变得清晰明了。我的代码运行速度更快,也更健壮。面试陷阱题也迎刃而解了。

本书以非常浅显易懂的方式讲解浏览器中 JavaScript 的工作原理。然后,他迅速切入核心,深入探讨各种函数。真正理解它们的运作方式,将彻底改变你的认知。

然后,关于词缀和词域运作的不可思议的部分,对我来说简直是醍醐灌顶。

然后是生成器、承诺和原型。最后,它深入探讨了我终于理解的神圣事件循环。读完这本书,我思路清晰,蓄势待发。

所以,首先我要说明一点。我一向对我的推荐非常坦诚。这本书读起来并不轻松。

如果你刚开始学习 JavaScript,这本书可能不太适合你。书中有些地方比较复杂,我需要反复思考、阅读、重读,还要参考图表才能真正理解。但这正是这本书的精髓所在。

本书面向已经使用 JavaScript 一段时间并希望提升技能的人,面向想要精通这门语言的人,面向想要成为这门语言专家的人。

如果真那么简单,人人都能成为专家了。这本书会带你深入迷雾,最终带你走出迷雾。没有摩擦,就没有进步。

结语

和许多人一样,我当初也误以为 JavaScript 是一门“简单”的语言,结果却掉进了陷阱。如果我事先认真对待学习过程,所有那些错误和痛苦的经历原本都可以避免。至于你是否愿意承担这个风险,就看你自己的选择了。

文章来源:https://dev.to/jesuisundev/javascript-what-i-didn-t-understand-14hl