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);
我知道,这看起来像个愚蠢的 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.";
}
简而言之,我了解了 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();
您可以使用下面的 Repl 来检查您的预测。
为什么第二个对数(第 8 行)不起作用?为什么第一个对数(第 5 行)起作用?为什么使用箭头函数(第 7 行)可以解决问题?
如果你无法回答这些问题,那是因为你对 JavaScript 上下文的概念还不熟悉。这很正常。在 JavaScript 中,上下文的行为方式与其他语言截然不同。
我们正在面对一个怪物。
理论上,“this”代表函数的上下文,即与函数调用关联的对象。但实际情况并非如此简单。实际上,它取决于函数的调用方式。
我们来看一些例子。
在函数中调用时,上下文将是全局对象。如果你不知道这一点,就会错误地修改全局对象。这是非常糟糕的。
this.creed = "Nothing is true, everything is permitted.";
function showCreed() {
console.log(this.creed)
}
showCreed();
严格模式下除外。严格模式下,它是未定义的。你不知道,这次一切都出错了。
"use strict"
this.creed = "Nothing is true, everything is permitted.";
function showCreed() {
console.log(this)
}
showCreed(); // undefined
在函数方法中调用时,上下文将是目标对象,这正是我们所期望的。这就是为什么上面的“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}`)
});
}
我不知道你是否见过创建像“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}`)
});
}
如今,最优雅的方法是使用箭头函数。它不仅能使代码更易读、更简洁,还能将当前上下文传递给被调用的函数。真棒!
showTemplarsKilled: function() {
console.log(`List of templar killed (${this.templarsKilled.length}) by ${this.name}`)
this.templarsKilled.forEach(templarKilled => console.log(`${this.name} killed ${templarKilled}`));
}
我跟你说过我不想说教,但我还是忍不住要解释。如果我开始胡言乱语,请你打断我。
总之,在我做这项著名服务的时候,我对这一切都毫不知情。而且,所有这些根据呼叫地点和方式而定的规则让我感到非常恐慌。
这让我作品的制作速度和质量……嗯,可以说是令人质疑。最初的几周非常艰难。即便事实并非如此,我也感觉我的团队开始怀疑我能带来什么。
耗费了大量(甚至可以说是太多)时间和精力,我才逐渐地,一个模块一个模块地,最终做出一些东西。然而,这仅仅是我探索的开始。我的痛苦远未结束。
部署
我就不赘述路上的各种奇遇了,咱们直接进入部署环节。到了部署那一步,我确信我的代码已经运行正常。我做了三百万次测试,已经在开发环境运行了一周。我当时真是信心满满,愿意为此打赌。
周一早上,我终于部署了服务,一切运行正常。
但随着时间的推移,新版本用户越来越多,我发现响应时间也越来越令人担忧地增加。下午时分,我收到了第一封客户邮件。
这显然与我的服务有关。
即使我仔细查看了运行缓慢的代码,我仍然不明白。响应时间越来越长,我越来越摸不着头脑。
这并非什么大错,而是一系列细微的错误累积起来,才拖慢了我的申请进度。我们来仔细看看其中一个。我保证,这是最后一个面试问题,问完我就不打扰你了。
以下代码有什么问题?
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))
}
问题出在第 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







