JavaScript程序由声明、表达式和语句组成;运行时通过绑定找到值,再按运算符和控制流规则处理这些值。
const不会冻结对象,typeof null也不是可靠的对象判断。隐式类型转换还会让+、==和默认值代码产生意外结果。
默认用const,需要重新赋值时用let;在输入边界显式转换并验证,比较时先明确要比较的是值还是对象标识。
是什么,为什么存在
JavaScript基础不是一份语法符号清单,而是一组解释代码如何求值的规则。名称怎样关联到值、值有什么类型、表达式何时转换类型,以及语句怎样决定下一步执行位置,这些规则共同决定一段程序的行为。
你在浏览器脚本、Node服务、测试和构建配置中都会遇到同一套语言核心。浏览器的DOM和Node的文件系统属于宿主API,不是JavaScript语法本身。先分清语言与宿主,才能判断一段代码究竟可以在哪个环境运行。
声明会在一个 词法环境(lexical environment) 中创建绑定。绑定把cart这样的名称关联到当前值;值可以是原始值,也可以是对象。重新赋值改变绑定中的值,修改对象属性则改变绑定所指对象的状态,两者不是一回事。
JavaScript需要动态类型,因为变量声明不固定以后能保存的值类型。类型属于运行时的值,不属于变量名称。这使代码适合快速组合,但也意味着外部输入、运算符转换和可空值必须由程序明确处理。
本主题覆盖读懂普通JavaScript所需的最小模型。函数细节、原型、数组方法和错误边界各有独立主题;这里保留它们在基础程序中的连接点,不重复完整API目录。
工作原理
JavaScript引擎先解析源码,再在执行上下文中运行语句。每个执行上下文都关联当前词法环境,名称查找从当前环境开始并向外继续,直到找到绑定或抛出ReferenceError。
声明与作用域
const和let都创建块级绑定。const必须在声明时初始化,此后不能重新赋值;let可以重新赋值。两者从块开始到声明执行前都处于 暂时性死区(temporal dead zone) ,提前读取会抛出ReferenceError。
var创建函数作用域或全局作用域绑定,而不是块级绑定。它的声明会被提升,并在执行声明语句前初始化为undefined。这个行为会隐藏提前读取和循环回调中的错误,所以新代码通常只需要const与let;阅读旧代码时仍必须认识var。
作用域由源码结构决定,不由函数从哪里调用决定。块、函数和模块都可以引入新的词法环境。ECMAScript模块与类主体还会自动采用严格模式;普通脚本可以用顶部的"use strict"指令启用严格模式。
值与类型
JavaScript有七种原始类型,以及对象类型。函数和数组都是对象的专门形式;null是原始值,尽管typeof null因历史兼容原因返回"object"。
| 类别 | typeof结果 | 典型值 | 要记住的边界 |
|---|---|---|---|
undefined | "undefined" | undefined | 缺少值或未初始化结果常用它表示 |
| Boolean | "boolean" | true、false | 条件会把其他值转换成布尔值 |
| Number | "number" | 42、NaN、Infinity | 使用IEEE 754双精度浮点语义 |
| BigInt | "bigint" | 42n | 不能直接与Number混合算术运算 |
| String | "string" | "ready" | 字符串不可变,方法会产生新值 |
| Symbol | "symbol" | Symbol("id") | 每次普通构造都产生唯一值 |
null | "object" | null | 判断它要用value === null |
| Object | "object"或"function" | {}、[]、函数 | 相等比较默认比较对象标识 |
原始值本身不可变。对象是可变的属性集合,多个绑定可以保存同一个对象值,因此通过任一别名修改属性都会被其他别名看到。展开语法{ ...source }只复制一层自有可枚举属性,并不会递归复制嵌套对象。
typeof适合区分大多数原始值和函数,但不是通用分类器。数组用Array.isArray(value),null用严格相等判断;需要识别某个类的实例时,再根据跨realm等约束考虑instanceof。
表达式、转换与控制流
表达式产生值,例如price * quantity、函数调用或赋值。语句组织执行,例如声明、if、for...of、return和throw。条件位置会把值转换成布尔值,但&&、||与??返回选中的操作数,而不保证返回Boolean。
发生 类型强制转换(type coercion) 时,运算符会按自己的规则请求数字、字符串或原始值。+既能数值相加,也能字符串拼接;若其中一步选择字符串拼接,后续结果很容易偏离预期。边界代码应先用Number()、String()或Boolean()表明意图,再验证转换结果。
===不做类型转换,但对象仍按标识比较。Object.is()与===大体相同,主要差别是它认为NaN与自身相同,并区分+0和-0。宽松相等==有精确定义,却包含多条转换分支;除非接口明确依赖其中某条规则,否则严格相等更容易审查。
短路求值( short-circuiting )决定右侧表达式是否执行。left && right在左侧为假值时停止,left || right在左侧为真值时停止,left ?? right只在左侧为null或undefined时执行右侧。这个差别对合法的0、false和空字符串尤其重要。
属性访问与函数调用
点号与方括号都读取对象属性。account.name使用源码中固定的属性名,account[field]会先求值field,再把结果转换为属性键。除Symbol外的属性键都是字符串,所以record[1]与record["1"]指向同一个属性。
读取不存在的属性得到undefined,但继续读取undefined.city会抛出TypeError。可选链profile?.address只保护标有?.的那一步,不会验证对象形状,也不会让链中每个后续读取自动安全。
方法调用receiver.method()同时求值函数和接收者。普通函数体中的this取决于调用形式,因此把方法取出再调用可能失去接收者。箭头函数没有自己的this,但这不意味着它能机械替代每个对象方法。
函数调用先求值被调用者与实参,再创建新的执行上下文。缺少的参数值为undefined,多出的实参仍会求值,只是函数可以不读取它们。默认参数也只在对应实参为undefined时执行,不会替换显式传入的null。
下面几种写法处理的边界不同:
| 写法 | 处理的情况 | 不会做的事 |
|---|---|---|
object?.property | object为nullish时停止 | 验证property的类型 |
value ?? fallback | value为nullish时回退 | 替换其他假值 |
parameter = defaultValue | 实参省略或为undefined | 替换null |
Array.isArray(value) | 判断值是否为数组 | 验证元素形状 |
语句与突然完成
语句通常按源码顺序完成,但return、throw、break和continue会改变正常流程。规范把这类结果描述为突然完成;它们不是普通表达式值,外层语句必须按各自规则传播或处理它们。
return结束当前函数调用,未提供表达式时返回undefined。break结束最近的循环或switch,continue跳到最近循环的下一轮。标签可以改变目标,但基础代码很少需要多层标签。
throw可以抛出任何JavaScript值,不过应用代码通常抛出Error或其子类,以保留消息、堆栈和错误类型。try...catch只捕获其动态执行范围内的同步抛出;Promise拒绝属于异步主题,需要由await或拒绝处理器观察。
finally会在离开try或catch时执行,包括return或throw路径。finally自己的return或throw会覆盖此前的完成结果,因此在其中返回值通常是缺陷。把finally留给必须发生的局部清理,控制流会更容易追踪。
读控制流时,按以下顺序检查:
- 确认进入了哪个分支,以及条件怎样转成Boolean。
- 找出会提前结束当前块、循环或函数的语句。
- 检查清理代码会不会覆盖原返回值或错误。
- 对异步调用继续追踪其返回的Promise,而不是只看调用所在的
try。
这套顺序把语法阅读变成可执行的追踪。遇到复杂表达式时,先把中间值写成具名绑定,通常比继续压缩代码更清楚。
示例
绑定与对象状态
第一个例子区分重新赋值与对象修改。status的绑定发生变化;cart的绑定保持不变,但对象属性可以修改。
const cart = { owner: "Lin", items: 2 };
const alias = cart;
cart.items += 1;
let status = "draft";
status = "ready";
const unitPrice = 19.5;
const quantity = 2;
const total = unitPrice * quantity;
console.log(status);
console.log(alias === cart, alias.items);
console.log(total);ready
true 3
39alias === cart为true,因为两个绑定保存同一个对象标识。const cart只禁止把cart重新赋成另一个值,不禁止cart.items += 1。价格和数量都是Number,所以乘法直接得到数值39。
在边界转换并验证
来自表单、命令行参数或JSON的字段可能不符合业务需要的类型。转换和验证应该紧挨在边界处完成,让后续代码只处理已经规范化的对象。
function normalizeOrder(input) {
const quantity = Number(input.quantity);
if (!Number.isInteger(quantity) || quantity < 1) {
throw new RangeError("quantity must be a positive integer");
}
return {
id: String(input.id),
quantity,
note: input.note ?? "(none)",
expedited: Boolean(input.expedited),
};
}
const order = normalizeOrder({
id: 1042,
quantity: "2",
note: "",
expedited: 0,
});
console.log(JSON.stringify(order));
try {
normalizeOrder({ id: 1043, quantity: "two" });
} catch (error) {
console.log(error.name, error.message);
}{"id":"1042","quantity":2,"note":"","expedited":false}
RangeError quantity must be a positive integerNumber("2")得到2,而Number("two")得到NaN,所以必须检查转换结果。??保留合法的空字符串,仅在备注为null或undefined时提供默认值。这里Boolean(0)明确得到false,但真实接口仍要先定义允许哪些输入形式。
用控制流处理集合
规范化后的数据可以交给小函数处理。这个例子用for...of迭代数组,用continue跳过停用条目,并返回一个新对象。
function summarizeCart(items) {
let total = 0;
const labels = [];
for (const item of items) {
if (!item.active) continue;
total += item.price * item.quantity;
labels.push(`${item.name} x${item.quantity}`);
}
return { labels, total };
}
const cart = [
{ name: "Notebook", price: 4.5, quantity: 2, active: true },
{ name: "Pen", price: 1.25, quantity: 3, active: false },
{ name: "Folder", price: 3, quantity: 1, active: true },
];
const { labels, total } = summarizeCart(cart);
console.log(labels.join(", "));
console.log(total.toFixed(2));Notebook x2, Folder x1
12.00total需要累加,所以使用let;labels的绑定从未重新赋值,所以使用const。toFixed(2)返回的是字符串"12.00",适合展示,不应直接当作仍是Number的金额继续计算。
默认值与相等性的边界
最后一个例子把三类容易混淆的规则放在一起:假值默认、NaN比较和对象标识。每行输出对应一种不同语义。
const preferences = {
pageSize: 0,
theme: null,
};
const pageSizeWithOr = preferences.pageSize || 20;
const pageSizeWithNullish = preferences.pageSize ?? 20;
const theme = preferences.theme ?? "system";
console.log(pageSizeWithOr, pageSizeWithNullish, theme);
console.log(NaN === NaN, Object.is(NaN, NaN));
const saved = { id: 7 };
const sameShape = { id: 7 };
const sameObject = saved;
console.log(saved === sameShape, saved === sameObject);20 0 system
false true
false true||把0当作需要替换的假值,??则保留它。两个对象即使属性相同也不是同一个对象;若业务要比较内容,需要定义哪些字段参与比较,而不是期待===自动执行深比较。
陷阱
**修复:**把绑定稳定性与数据所有权分开审查。需要不可变更新时构造新对象,并测试嵌套对象是否仍与输入共享标识。
**修复:**只有null与undefined表示缺失时才用??。若null表示无效输入,就先验证,不要用默认值掩盖它。
**修复:**先排除null,再按契约使用Array.isArray()或检查必需属性。类型标签只能帮助缩小范围,不能代替输入验证。
**修复:**先定义输入语法,再转换并检查结果。需要完整十进制字符串时,应验证整段文本,而不是依赖宽松的前缀解析。
**修复:**检查真正的业务条件,例如Array.isArray(items) && items.length > 0。对字符串、数字和对象分别写出所需约束。
值、引用与相等性
ECMAScript从可观察语义描述值和对象,不要求实现采用某种“栈上原始值、堆上引用值”的固定内存布局。把对象笼统叫作“引用类型”有时方便教学,却容易让人误以为赋值或传参有另一套机制。更准确的模型是:表达式总会产生值,其中对象值带有标识。
把原始值赋给另一个绑定后,两边得到不可变值;之后重新赋值一边不会影响另一边。把对象值赋给另一个绑定后,两边指向同一对象标识;修改属性可以从两边观察到,但把其中一个绑定重新赋成新对象不会改变另一个绑定。
函数参数也遵循按值传递。传入对象时,传递的值让形参与实参访问同一对象,所以函数能修改其属性;函数内部把形参重新赋成别的对象,不会重新赋值调用方的变量。称它为“对象引用按值传递”可以避免“按引用传递”造成的错误预期。
选择相等操作前要先定义问题。原始业务值通常用===;检测NaN可以用Number.isNaN(),需要SameValue语义时用Object.is();对象内容相等则需要领域规则,因为日期、数组顺序、缺失属性、undefined、Symbol键和循环结构都可能改变答案。
表达式如何选择转换
抽象转换不是随机行为。条件、算术运算符、关系比较和字符串拼接各自请求不同形式。困难来自同一个操作符可能进入不同分支,以及对象转原始值时可能调用Symbol.toPrimitive、valueOf()或toString()。
+最容易暴露分支差异。两个操作数先转成原始值;只要其中一个结果是字符串,就进行字符串拼接,否则进入数值加法。求值从左到右,因此1 + 2 + "3"是"33",而"1" + 2 + 3是"123"。
关系比较也依赖操作数类型。"10" < "9"比较字符串序列,结果为true;"10" < 9会进入数值比较,结果为false。与其记忆孤立的怪例子,不如在数据进入系统时统一类型,使核心逻辑不再反复触发转换分支。
宽松相等包含一些有用但狭窄的规则,例如null == undefined为true。问题在于审查者必须证明代码只依赖预期分支,并排除"" == 0等其他结果。明确的可空判断value === null || value === undefined或value == null都能表达意图;团队应选定一种并保持边界契约清楚。
严格模式不会改变上述类型转换,却会把部分静默失败变成异常,并禁止意外创建全局变量等旧式行为。它不能替代验证、不可变数据策略或严格相等。模块已经自动处于严格模式,因此不要为了“更严格”而在同一模块中堆叠无关防护说法。
数字与外部文本
JavaScript的Number同时表示整数和小数,并包含NaN与正负无穷。typeof NaN仍是"number",所以类型检查无法证明计算结果有效。需要有限数时使用Number.isFinite(),需要整数时再加Number.isInteger()和业务范围判断。
二进制浮点无法精确表示许多十进制小数。0.1 + 0.2不严格等于0.3,这不是JavaScript独有的随机误差。金额系统通常保存最小货币单位的整数,或采用明确的十进制定点方案;展示阶段再格式化。
Number(text)要求整段文本能按其语法转换,却把空白文本当成0。parseInt()和parseFloat()接受可解析前缀,因此适合有意读取前缀的场景,不适合自动验证完整字段。两者不能仅凭函数名互换。
BigInt表示任意精度整数,但不能与Number直接混合算术。把超出安全整数范围的Number转成BigInt,无法恢复此前已经丢失的精度。外部大整数应以文本进入系统,再直接交给BigInt(text)并处理转换失败。
常见数字边界可以这样选择检查:
| 需求 | 转换后检查 | 仍需定义 |
|---|---|---|
| 有限测量值 | Number.isFinite(value) | 单位和允许范围 |
| 整数数量 | Number.isInteger(value) | 正负与上限 |
| 安全整数标识 | Number.isSafeInteger(value) | 前导零与文本规范 |
| 任意精度整数 | BigInt(text)加异常处理 | 符号、位数和存储格式 |
字符串按UTF-16码元提供length和索引。一个用户感知字符可能占两个码元,组合字符还可能由多个Unicode码点构成。基础字符串处理可以用内置方法,按用户感知字符截断或计数则需要明确Unicode需求。
对象与浅层复制
对象字面量创建带属性的新对象,数组字面量创建带索引属性和length行为的数组。属性读取可以沿原型链继续查找;Object.hasOwn(object, key)只判断对象自己的属性。业务映射若不能接受继承属性,就应明确使用自有属性检查。
对象展开和Object.assign()都会读取源对象的可枚举自有属性。读取可能触发getter,目标上得到的是普通数据属性;原型、不可枚举属性和大多数属性描述符不会按原样保留。因此它们是数据投影工具,不是任意对象克隆器。
浅复制保留嵌套对象标识。const copy = { ...order }创建新的外层对象,但copy.customer === order.customer仍可能为true。更新copy.customer.name时,原订单也会观察到变化。
structuredClone()能复制许多内置结构并处理循环,但它也不是任何值都能复制的通用方案。函数等值会失败,类实例语义和传输行为也需要单独确认。选择复制策略前,应先定义哪些标识必须共享、哪些必须隔离。
作为函数参数传递对象不会自动复制它。若函数承诺不修改输入,测试应保存嵌套标识或冻结测试数据,以捕获意外写入。单看外层对象在调用后是否严格相等,无法证明内部没有改变。
跨越信任边界时,按允许列表构造输出比展开后删除秘密字段可靠。删除列表会在以后新增token或其他凭据字段时默认泄露它们。允许列表还能让返回对象的形状直接对应接口契约。
延伸阅读
4个问题 · 1道输出预测题 · 1道找错题