在浏览器中调试
在编写代码前看看调试。
调试是指在一个脚本中找出并修复错误的过程。
在这里我们将会使用 Chrome(谷歌浏览器),因为它拥有足够多的功能,其他大部分浏览器的功能也与之类似。
“资源(Sources)”面板
你的 Chrome 版本可能看起来有一点不同,但是它应该还是处于很明显的位置。
- 在 Chrome 中打开一个页面。
- 使用快捷键 F12 打开开发者工具。
- 选择
Sources(资源)
面板。
如果你是第一次这么做,那你应该会看到下面这个样子:
切换按钮会打开文件列表的选项卡。
资源(Sources)面板包含三个部分:
- 文件导航(File Navigator) 区域列出了 HTML、JavaScript、CSS 和包括图片在内的其他依附于此页面的文件。Chrome 扩展程序也会显示在这。
- 代码编辑(Code Editor) 区域展示源码。
- JavaScript 调试(JavaScript Debugging) 区域是用于调试的,我们很快就会来探索它。
现在你可以再次点击切换按钮 隐藏资源列表来给代码腾出一些空间。
控制台(Console)
如果我们按下 Esc,下面会出现一个控制台,我们可以输入一些命令然后按下 Enter 来执行。
语句执行完毕之后,其执行结果会显示在下面。
例如,1+2
将会返回 3
,而 hello("debugger")
函数调用什么也没返回,所以结果是 undefined
:
断点(Breakpoints)
断点 是调试器会自动暂停 JavaScript 执行的地方。
当代码被暂停时,我们可以检查当前的变量,在控制台执行命令等等。换句话说,我们可以调试它。
我们总是可以在右侧的面板中找到断点的列表。当我们在数个文件中有许多断点时,这是非常有用的。它允许我们:
- 快速跳转至代码中的断点(通过点击右侧面板中的对应的断点)。
- 通过取消选中断点来临时禁用对应的断点。
- 通过右键单击并选择移除来删除一个断点。
- ……等等。
条件断点
在行号上 右键单击 允许你创建一个 条件 断点。只有当给定的表达式(你创建条件断点时提供的表达式)为真时才会被触发。
当我们需要在特定的变量值或参数的情况下暂停程序执行时,这种调试方法就很有用了。
“debugger” 命令
我们也可以使用 debugger
命令来暂停代码,像这样:
function hello(name) {
let phrase = `Hello, ${name}!`;
debugger; // <-- 调试器会在这停止
say(phrase);
}
这样的命令只有在开发者工具打开时才有效,否则浏览器会忽略它。
暂停并查看
请打开右侧的信息下拉列表(箭头指示出的地方)。这里允许你查看当前的代码状态:
-
察看(Watch)
—— 显示任意表达式的当前值。你可以点击加号
+
然后输入一个表达式。调试器将显示它的值,并在执行过程中自动重新计算该表达式。 -
调用栈(Call Stack)
—— 显示嵌套的调用链。此时,调试器正在
hello()
的调用链中,被index.html
中的一个脚本调用(这里没有函数,因此显示 “anonymous”)如果你点击了一个堆栈项,调试器将跳到对应的代码处,并且还可以查看其所有变量。
-
作用域(Scope)
—— 显示当前的变量。Local
显示当前函数中的变量,你还可以在源代码中看到它们的值高亮显示了出来。Global
显示全局变量(不在任何函数中)。这里还有一个
this
关键字,目前我们还没有学到它,不过我们很快就会学习它了。
跟踪执行
现在是 跟踪 脚本的时候了。
在右侧面板的顶部是一些关于跟踪脚本的按钮。让我们来使用它们吧。
- “恢复(Resume)”:继续执行,快捷键 F8。
继续执行。如果没有其他的断点,那么程序就会继续执行,并且调试器不会再控制程序。
-
“下一步(Step)”:运行下一条(即当前行)指令,快捷键 F9。
运行下一条语句。
一次接一次地点击此按钮,整个脚本的所有语句会被逐个执行。
-
“跨步(Step over)”:运行下一条(即当前行)指令,但 不会进入到一个函数中,快捷键 F10。
跟上一条命令“下一步(Step)”类似,但如果下一条语句是函数调用则表现不同。这里的函数指的是:不是内建的如
alert
函数等,而是我们自己写的函数。如果我们对比一下,“下一步(Step)”命令会进入嵌套函数调用并在其第一行暂停执行,而“跨步(Step over)”对我们不可见地执行嵌套函数调用,跳过了函数内部。执行会在该函数调用后立即暂停。
如果我们对该函数的内部执行不感兴趣,这命令会很有用。
-
“步入(Step into)”,快捷键 F11。
和“下一步(Step)”类似,但在异步函数调用情况下表现不同。
至于之后,只需要记住“下一步(Step)”命令会忽略异步行为,例如
setTimeout
(计划的函数调用),它会过一段时间再执行。而“步入(Step into)”会进入到代码中并等待(如果需要)。 -
“步出(Step out)”:继续执行到当前函数的末尾,快捷键 Shift+F11。
继续执行当前函数内的剩余代码,并暂停在调用当前函数的下一行代码处。当我们使用 偶然地进入到一个嵌套调用,但是我们又对这个函数不感兴趣时,我们想要尽可能的继续执行到最后的时候是非常方便的。
-
启用/禁用所有的断点。
这个按钮不会影响程序的执行。只是一个批量操作断点的开/关。
-
启用/禁用出现错误时自动暂停脚本执行。
当启动此功能,如果开发者工具是打开着的时候,任何脚本执行错误都会导致该脚本执行自动暂停。然后我们可以在调试器中分析变量来看一下什么出错了。因此如果我们的脚本因为错误挂掉的时候,我们可以打开调试器,启用这个选项然后重载页面,查看一下哪里导致它挂掉了和当时的上下文是什么。
Continue to here
在代码中的某一行上右键,在显示的关联菜单(context menu)中点击一个非常有用的名为 “Continue to here” 的选项。
当你想要向前移动很多步到某一行为止,但是又懒得设置一个断点时非常的方便。
日志记录
想要输出一些东西到控制台上?console.log
函数可以满足你。
例如:将从 0
到 4
的值输出到控制台上:
// 打开控制台来查看
for (let i = 0; i < 5; i++) {
console.log("value", i);
}
普通用户看不到这个输出,它是在控制台里面的。要想看到它 —— 要么打开开发者工具中的 Console(控制台)选项卡,要么在一个其他的选项卡中按下 Esc:这会在下方打开一个控制台。
如果我们在代码中有足够的日志记录,那么我们可以从记录中看到刚刚发生了什么,而不需要借助调试器。
小结
我们可以看到,这里有 3 种方式来暂停一个脚本:
- 断点。
debugger
语句。- error(如果开发者工具是打开状态,并且按钮 是开启的状态)。
当脚本执行暂停时,我们就可以进行调试:检查变量,跟踪代码来查看执行出错的位置。
代码风格
我们的代码必须尽可能的清晰和易读。
语法
下面是一个备忘单,其中列出了一些建议的规则:
下面详细讨论一下这些规则和它们的原因吧。
注意:这些所谓风格只是推荐写法,你可以有自己喜欢的方式去书写漂亮的代码
花括号
在大多数的 JavaScript 项目中,花括号以 “Egyptian” 风格书写
左花括号与相应的关键词在同一行上
而不是新起一行。左括号前还应该有一个空格,如下所示:
if (condition) {
// do this
// ...and that
// ...and that
}
单行构造(如 if (condition) doSomething()
)也是一个重要的用例。我们是否应该使用花括号?如果是,那么在哪里?
下面是这几种情况的注释,你可以自己判断一下它们的可读性:
-
😠 初学者常这样写。非常不好!这里不需要花括号:
if (n < 0) {alert(`Power ${n} is not supported`);}
-
😠 拆分为单独的行,不带花括号。永远不要这样做,添加新行很容易出错:
if (n < 0) alert(`Power ${n} is not supported`);
-
😏 写成一行,不带花括号 —— 如果短的话,也是可以的:
if (n < 0) alert(`Power ${n} is not supported`);
-
😃 最好的方式:
if (n < 0) { alert(`Power ${n} is not supported`); }
对于很短的代码,写成一行是可以接受的:例如 if (cond) return null
。但是代码块(最后一个示例)通常更具可读性。
行的长度
没有人喜欢读一长串代码,最好将代码分割一下。
例如:
// 回勾引号 ` 允许将字符串拆分为多行
let str = `
ECMA International's TC39 is a group of JavaScript developers,
implementers, academics, and more, collaborating with the community
to maintain and evolve the definition of JavaScript.
`;
对于 if
语句:
if (
id === 123 &&
moonPhase === 'Waning Gibbous' &&
zodiacSign === 'Libra'
) {
letTheSorceryBegin();
}
一行代码的最大长度应该在团队层面上达成一致。通常是 80 或 120 个字符。
缩进
有两种类型的缩进:
-
水平方向上的缩进:2 或 4 个空格。
一个水平缩进通常由 2 或 4 个空格或者 “Tab” 制表符(Tab 键)构成。
选择空格而不是 tabs 的优点之一是,这允许你做出比 “Tab” 制表符更加灵活的缩进配置。
例如,我们可以将参数与左括号对齐,像下面这样:
show(parameters, aligned, // 左边有 5 个空格 one, after, another ) { // ... }
-
垂直方向上的缩进:用于将代码拆分成逻辑块的空行。
即使是单个函数通常也被分割为数个逻辑块。在下面的示例中,初始化的变量、主循环结构和返回值都被垂直分割了:
function pow(x, n) { let result = 1; // <-- for (let i = 0; i < n; i++) { result *= x; } // <-- return result; }
插入一个额外的空行有助于使代码更具可读性。写代码时,不应该出现连续超过 9 行都没有被垂直分割的代码。
分号
每一个语句后面都应该有一个分号。即使它可以被跳过。
在 JavaScript 中,极少数情况下,换行符有时不会被解释为分号,这时代码就容易出错。
嵌套的层级
尽量避免代码嵌套层级过深。
例如,在循环中,有时候使用 continue
指令以避免额外的嵌套是一个好主意。
例如,不应该像下面这样添加嵌套的 if
条件:
for (let i = 0; i < 10; i++) {
if (cond) {
... // <- 又一层嵌套
}
}
我们可以这样写:
for (let i = 0; i < 10; i++) {
if (!cond) continue;
... // <- 没有额外的嵌套
}
使用 if/else
和 return
也可以做类似的事情。
例如,下面的两个结构是相同的。
第一个:
function pow(x, n) {
if (n < 0) {
alert("Negative 'n' not supported");
} else {
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
}
第二个:
function pow(x, n) {
if (n < 0) {
alert("Negative 'n' not supported");
return;
}
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
但是第二个更具可读性,因为 n < 0
这个“特殊情况”在一开始就被处理了。一旦条件通过检查,代码执行就可以进入到“主”代码流,而不需要额外的嵌套。
函数位置
如果你正在写几个“辅助”函数和一些使用它们的代码,那么有三种方式来组织这些函数。
-
在调用这些函数的代码的 上方 声明这些函数:
// 函数声明 function createElement() { ... } function setHandler(elem) { ... } function walkAround() { ... } // 调用函数的代码 let elem = createElement(); setHandler(elem); walkAround();
-
先写调用代码,再写函数
// 调用函数的代码 let elem = createElement(); setHandler(elem); walkAround(); // --- 辅助函数 --- function createElement() { ... } function setHandler(elem) { ... } function walkAround() { ... }
-
混合:在第一次使用一个函数时,对该函数进行声明。
大多数情况下,第二种方式更好。
这是因为阅读代码时,我们首先想要知道的是“它做了什么”。如果代码先行,那么在整个程序的最开始就展示出了这些信息。之后,可能我们就不需要阅读这些函数了,尤其是它们的名字清晰地展示出了它们的功能的时候。
自动检查器
检查器是可以自动检查代码样式,并提出改进建议的工具。
它们的妙处在于进行代码风格检查时,还可以发现一些代码错误,例如变量或函数名中的错别字。因此,即使你不想坚持某一种特定的代码风格,我也建议你安装一个检查器。
我使用的是 ESLint
。
大多数检查器都可以与编辑器集成在一起:只需在编辑器中启用插件并配置代码风格即可。
例如,要使用 ESLint
你应该这样做:
- 安装 Node.JS。
- 使用
npm install -g eslint
命令(npm 是一个 JavaScript 包安装工具)安装 ESLint。 - 在你的 JavaScript 项目的根目录(包含该项目的所有文件的那个文件夹)创建一个名为
.eslintrc
的配置文件。 - 在集成了 ESLint 的编辑器中安装/启用插件。大多数编辑器都有这个选项。
下面是一个 .eslintrc
文件的例子:
{
"extends": "eslint:recommended",
"env": {
"browser": true,
"node": true,
"es6": true
},
"rules": {
"no-console": 0,
"indent": 2
}
}
这里的 "extends"
指令表示我们是基于 “eslint:recommended” 的设置项而进行设置的。之后,我们制定我们自己的规则。
此外,某些 IDE 有内建的检查器,这非常方便,但是不像 ESLint 那样可自定义。
注释
正如我们在代码结构所了解到的那样,注释可以是以 //
开始的单行注释,也可以是 /* ... */
结构的多行注释。
我们通常通过注释来描述代码怎样工作和为什么这样工作。
糟糕的注释
新手倾向于使用注释来解释“代码中发生了什么”。就像这样:
// 这里的代码会先做这件事(……)然后做那件事(……)
// ……谁知道还有什么……
very;
complex;
code;
但在好的代码中,这种“解释性”注释的数量应该是最少的。严格地说,就算没有它们,代码也应该很容易理解。
关于这一点有一个很棒的原则:“如果代码不够清晰以至于需要一个注释,那么或许它应该被重写。”
配方:分解函数
有时候,用一个函数来代替一个代码片段是更好的,就像这样:
function showPrimes(n) {
nextPrime:
for (let i = 2; i < n; i++) {
// 检测 i 是否是一个质数(素数)
for (let j = 2; j < i; j++) {
if (i % j == 0) continue nextPrime;
}
alert(i);
}
}
更好的变体,使用一个分解出来的函数 isPrime
:
function showPrimes(n) {
for (let i = 2; i < n; i++) {
if (!isPrime(i)) continue;
alert(i);
}
}
function isPrime(n) {
for (let i = 2; i < n; i++) {
if (n % i == 0) return false;
}
return true;
}
现在我们可以很容易地理解代码了。函数自己就变成了一个注释。这种代码被称为 自描述型 代码。
配方:创建函数
如果我们有一个像下面这样很长的代码块:
// 在这里我们添加威士忌(译注:国外的一种酒)
for(let i = 0; i < 10; i++) {
let drop = getWhiskey();
smell(drop);
add(drop, glass);
}
// 在这里我们添加果汁
for(let t = 0; t < 3; t++) {
let tomato = getTomato();
examine(tomato);
let juice = press(tomato);
add(juice, glass);
}
// ...
我们像下面这样,将上面的代码重构为函数,可能会是一个更好的变体:
addWhiskey(glass);
addJuice(glass);
function addWhiskey(container) {
for(let i = 0; i < 10; i++) {
let drop = getWhiskey();
//...
}
}
function addJuice(container) {
for(let t = 0; t < 3; t++) {
let tomato = getTomato();
//...
}
}
同样,函数本身就可以告诉我们发生了什么。没有什么地方需要注释。并且分割之后代码的结构也更好了。每一个函数做什么、需要什么和返回什么都非常地清晰。
实际上,我们不能完全避免“解释型”注释。例如在一些复杂的算法中,会有一些出于优化的目的而做的一些巧妙的“调整”。但是通常情况下,我们应该尽可能地保持代码的简单和“自我描述”性。
好的注释
所以,解释性注释通常来说都是不好的。那么哪一种注释才是好的呢?
-
描述架构
对组件进行高层次的整体概括,它们如何相互作用、各种情况下的控制流程是什么样的……简而言之 —— 代码的鸟瞰图。
-
记录函数的参数和用法
例如:
/**
* 返回 x 的 n 次幂的值。
*
* @param {number} x 要改变的值。
* @param {number} n 幂数,必须是一个自然数。
* @return {number} x 的 n 次幂的值。
*/
function pow(x, n) {
...
}
这种注释可以帮助我们理解函数的目的,并且不需要研究其内部的实现代码,就可以直接正确地使用它。
小结
一个好的开发者的标志之一就是他的注释:它们的存在甚至它们的缺席
好的注释可以使我们更好地维护代码,一段时间之后依然可以更高效地回到代码高效开发。
注释这些内容:
- 整体架构,高层次的观点。
- 函数的用法。
- 重要的解决方案,特别是在不是很明显时。
Polyfill 和转译器
JavaScript 语言在稳步发展。也会定期出现一些对语言的新提议,它们会被分析讨论,如果认为有价值,就会被加入到 https://tc39.github.io/ecma262/
的列表中,然后被加到规范中。
因此,一个 JavaScript 引擎只能实现标准中的一部分是很常见的情况。
作为程序员,我们希望使用最新的特性。好东西越多越好!
另一方面,如何让我们现代的代码在还不支持最新特性的旧引擎上工作?
有两个工作可以做到这一点:
- 转译器(Transpilers)。
- 垫片(Polyfills)。
下面一起了解它们的工作原理以及它们在 Web 开发中的位置。
转译器(Transpilers)
转译器是一种可以将源码转译成另一种源码的特殊的软件。它可以解析(“阅读和理解”)现代代码,并使用旧的语法结构对其进行重写,进而使其也可以在旧的引擎中工作。
例如,在 ES2020 之前没有“空值合并运算符” ??
。所以,如果访问者使用过时了的浏览器访问我们的网页,那么该浏览器可能就不明白 height = height ?? 100
这段代码的含义。
转译器会分析我们的代码,并将 height ?? 100
重写为 (height !== undefined && height !== null) ? height : 100
。
// 在运行转译器之前
height = height ?? 100;
// 在运行转译器之后
height = (height !== undefined && height !== null) ? height : 100;
现在,重写了的代码适用于更旧版本的 JavaScript 引擎。
通常,开发者会在自己的计算机上运行转译器,然后将转译后的代码部署到服务器。
说到名字,Babel是最著名的转译器之一。
现代项目构建系统,例如 Webpack,提供了在每次代码更改时自动运行转译器的方法,因此很容易将代码转译集成到开发过程中。
垫片(Polyfills)
新的语言特性可能不仅包括语法结构和运算符,还可能包括内建函数。
例如,Math.trunc(n)
是一个“截断”数字小数部分的函数,例如 Math.trunc(1.23)
返回 1
。
在一些(非常过时的)JavaScript 引擎中没有 Math.trunc
函数,所以这样的代码会执行失败。
由于我们谈论的是新函数,而不是语法更改,因此无需在此处转译任何内容。我们只需要声明缺失的函数。
更新或添加这些新函数的JS脚本被称为“polyfill”。它“填补”了空白并添加了缺失的实现。
对于这种特殊情况,Math.trunc
的 polyfill 是一个实现它的脚本,如下所示:
if (!Math.trunc) { // 如果没有这个函数
// 实现它
Math.trunc = function(number) {
// Math.ceil 和 Math.floor 甚至存在于上古年代的 JavaScript 引擎中
// 在本教程的后续章节中会讲到它们
return number < 0 ? Math.ceil(number) : Math.floor(number);
};
}
JavaScript 是一种高度动态的语言。脚本可以添加或修改任何函数,甚至包括内建函数。
两个有趣的 polyfill 库:
- core js支持了很多特性,允许只包含需要的特性。
- polyfill.io提供带有 polyfill 的脚本的服务,具体取决于特性和用户的浏览器。