我自2012年左右开始全职从事JavaScript工作,这既是幸运也是不幸。不幸的是,在2015年左右,当ECMAScript规范开始出现重大改进之前,这门语言每天都在让我头疼。
然而,我也很幸运,因为自那以后JavaScript进行了很多改进,但语言的基本工作原理仍然相同,所以对新语法(去掉糖衣)有更深入的理解,在调试、处理遗留项目或深入研究底层代码时非常有用。我也能够更好地欣赏到我们这些JavaScript开发者现在有多好。
简短的背景
我最早在2012年为一家名为Lanica的小公司专业地开始使用JavaScript。这是一家由Appcelerator资助的公司,它为Titanium SDK制作了一个移动游戏引擎(我们在加利福尼亚州山景城的Appcelerator办公室工作)。
我来自Lua,这是一种简单但非常“理智”的脚本语言(我在前一家公司大量使用),JavaScript学习(并使用)起来简直是一场噩梦。后来我开始使用JavaScript在传统web前端工作(还有人记得Backbone.js吗?)以及使用Node.js进行后端开发。
我最不喜欢的是函数作用域变量、调用者上下文、回调地狱,以及到处写“function() function() function()…”。所有这些因素结合起来,不仅使编写JavaScript代码变得令人沮丧,而且阅读也是如此。
函数作用域与块作用域变量
let
和const
关键字几乎完全取代了var
用于定义变量。除了明显的好处,即知道一个变量是否可变之外,一个较少为人所知的好处是,使用let
和const
定义的变量是块作用域的,而不是函数作用域(var
),这有助于防止许多微妙的错误潜入你的代码。
我们过去不得不用自执行匿名函数来伪造:
function example() {
// 没有块作用域:
for (var i = 0; i < list.length; i += 1) {
var x = list[i];
// x看起来像是块作用域,但它不是!
}
// 尽管在上述循环块中定义,
// x在函数的任何地方都可以访问
console.log(x);
console.log(i); // 迭代器在这里也是可用的
// 块作用域可以被“伪造”为
// 自执行匿名函数:
for (i = 0; i < list.length; i += 1) {
(function() {
var y = list[i];
// y只在该函数内可访问
})();
}
}
第二个循环中伪造的块作用域是有效的,但它的可读性要低得多,也更容易出错,而不是真正的块作用域(稍后会更多讨论)。JavaScript代码库到处都是这样的东西,以解决语言的怪癖。
函数上下文(this)
现在,如果你使用this
关键字,你很可能正在为一个类方法编写代码,引用当前实例(类似于其他面向对象的语言)。在JavaScript的早期时代,你必须非常清楚一个叫做“函数上下文”(也称为“this”上下文)的东西,this
所引用的内容可能会因函数的调用方式而有所不同。
如果JavaScript开发者是在ES6之后学习这门语言的,他们可能不知道什么是函数上下文,因为它不像以前那样直接使用。
简而言之,JavaScript为每个函数调用都有一个映射到this
关键字的“上下文”。记得前面的例子中我们定义了一个自调用的匿名函数吗?如果我们试图在那个函数中访问外部函数的this
关键字,会导致问题:
function example() {
console.log("1:", this.someVar); // 1: "hello"
(function() {
console.log("2:", this.someVar); // 2: undefined
})();
}
example.call({ someVar: "hello" });
因为每个函数都有自己的调用者上下文,内部函数不会继承外部函数的上下文,所以this
不会引用相同的东西。你需要在一个单独的变量中存储外部函数的this
值,或者使用call()
或apply()
来显式设置内部函数的上下文(thisArg
)。
ECMAScript 2015(ES6)引入了“箭头函数”,表面上看,它是一种让我们不必每次都输入“function”的方式(它确实是),但它不仅仅是这样。这些箭头函数还继承了它们定义时父作用域的上下文。所以这个例子按预期工作:
function example() {
console.log("1:", this.someVar); // 1: "hello"
(() => {
console.log("2:", this.someVar); // 2: "hello"
})();
}
example.call({ someVar: "hello" });
我不记得上次在现代JavaScript代码库中使用call()
或apply()
是什么时候了,或者因为this
不是它看起来的样子而修复bug。但我还记得,我必须时刻痛苦地意识到函数调用者上下文。如果我再也不用call()
和apply()
,我会很高兴。
回调地狱
在Promise和async/await内置之前,有回调地狱。如果你没有在那些日子里使用JavaScript的荣幸,那你很幸运。例如:
function getResults(callback) {
makeFirstRequest(function(result1, err1) {
if (err1) {
callback(null, err1);
return;
}
makeSecondRequest(function(result2, err2) {
if (err2) {
callback(null, err2);
return;
}
makeThirdRequest(function(result3, err3) {
if (err3) {
callback(null, err3);
return;
}
// 通过顶级回调参数返回结果
callback({
result1: result1,
result2: result2,
result3: result3,
}, null);
})
});
});
}
getResults(function(result, err) {
if (err) {
console.log(err.message);
return;
}
console.log("Got results:", results);
});
上面的代码只是为了说明概念,当然有更干净的方式来编写它,但无论你给这只猪涂多少口红,它仍然看起来很痛苦(更不用说编写了)。
示例调用了一个函数(getResults()
),该函数执行了三个请求,合并了结果,并通过回调函数“返回”了一个对象(或一个错误)。这就是JavaScript中处理异步代码的方式,被称为“回调地狱”,因为嵌套回调的迷宫(想象一下,如果每个嵌套函数都更大更复杂)。
与现在使用Promise和async/await(以及对象简写语法和错误处理)的便利性形成对比:
async function getResults() {
const result1 = await makeFirstRequest()
const result2 = await makeSecondRequest();
const result3 = await makeThirdRequest();
return {
result1,
result2,
result3,
};
}
try {
const results = await getResults();
console.log("Got results:", results);
} catch (err) {
console.log(err.message);
}
Node.js的异步特性是回调地狱真正流行的地方,但在前端(例如进行网络请求)也不少见。
展望未来
在ECMAScript 2015(ES6)给JavaScript带来了巨大的“推动”之后,语言持续改进,它只会变得更好。由于实现范围广泛,使用转译器已经成为社区的标准,所以我们都可以在每个项目的基础上利用最新的JavaScript特性,而不必担心每个单独的JS引擎支持什么。
后来的JavaScript版本并没有移除通常与该语言相关的“瑕疵”。你仍然可以像以前一样以完全相同的方式编写代码(如果你是个受虐狂),但新添加的特性提供了更好的编写代码的方式,有效地使旧的做事方式变得过时(尽管仍然有一些需要注意的奇怪之处)。结合像ESLint这样的优秀linting工具(以及最近出现的Biome.js),今天的JavaScript几乎无法从它过去的样子中认出来。
回顾过去,这门语言能够在保持与之前语言规范几乎完全向后兼容的同时,发展如此之多,实际上是相当令人惊叹的。在更广泛的编程社区中,JavaScript仍然有很多蔑视,但我认为这是因为人们被昨天的JavaScript吓坏了(我并不怪他们!)。但我认为,如果它基于今天的语言来评判,许多人会看到它实际上现在相当不错。
我现在已经“全力以赴”地使用TypeScript,但作为超集,归根结底它仍然是“只是JavaScript”(带类型)。我个人现在不会在没有它的情况下开始新的JavaScript项目,但这又是另一天的话题。
更新: 记得当我说过“在更广泛的编程社区中,JavaScript仍然有很多蔑视”吗?看看Hacker News的讨论串 来证明这一点。这可能是一个不受欢迎的观点(可能还有一些斯德哥尔摩综合症在起作用),但我个人现在喜欢它。