现代JavaScript的真正复杂性源自工具链,而非语言本身。
本文介绍了诸如 async/await 和可选链操作符等核心 JavaScript 特性如何简化代码,而过多的工具和依赖则会导致不必要的复杂性。
在 JavaScript 开发过程中,会有这样一个奇怪的时刻:你意识到自己懂得太多了。
并非 JavaScript 本身懂得太多了。
而是对JavaScript 周边的生态系统了解得太多。
你先学习了 React,接着出现了 hooks,随后状态管理技术出现,又有了自己偏爱的状态管理库。你对在客户端获取数据形成了自己的见解。尽管很少打开打包器的配置文件,但你明白它的作用。你还接触过服务器组件、信号机制、岛屿架构、边缘运行时、元框架、构建工具、代码检查工具、格式化工具、包管理器,以及至少三种用于组织名为components的文件夹的不同规范。
然后当你打开一个很小的 JavaScript 文件时,会发现实际逻辑其实就简化为这样:
const users = await fetch("/api/users").then(res => res.json());
就是这么简单。
这种语言本身往往出奇地简单直接。
让现代 JavaScript 显得令人生畏的,是叠加在其之上的各种复杂元素。
看着 JavaScript 代码库从寥寥几个文件发展到包含数百个依赖项的庞大系统,说明我们将复杂性误当成了高级性。
使用现代 JavaScript 并不意味着要采用它提供的每一个新功能。
而是要明白这种语言已经为你解决了哪些问题。
这种语言已悄然成熟
JavaScript 曾被认为是一种设计草率、风格怪异的语言,却不知怎的成为了整个网络的运行基础。
这种评价确实有一定道理。
早期版本存在诸多问题。浏览器API在不同环境中的表现并不一致,理解异步代码也极为困难。由于运行时缺乏内置模块,大型代码库不得不依赖一些往往比语言本身更为复杂的模式。
如今的JavaScript则完全不同了。
以下是您现在可以使用的功能:
- 通过
const和let实现块级作用域变量 - 利用箭头函数简化函数语法
- 通过解构赋值从对象和数组中提取值
- 使用模板字面量进行字符串插值
- 通过默认参数避免手动编写备用逻辑
- 利用展开运算符复制和合并数据
- 内置的模块系统
- 仅在确实需要时才使用类
async 和 awaitMap 和 Set 数据结构上述列表中的任何一项都不会让该语言难以使用。
真正的问题在于,开发者往往将这些内容当作一堆繁杂的语法来记忆,而非将其视为表达日常编程概念的简洁工具集。
一旦理解了其背后的原理,这些语法就不再令人望而生畏。
你可能并不需要一半的架构设计
让 JavaScript 项目变得过于复杂的最快方式之一,就是在尚未理解问题之前就急于做出架构决策。
想象一个控制面板:它需要加载一些数据,允许用户编辑这些数据,并保存结果。
一个合理的初步设计可能如下所示:
async function loadUsers() {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Failed to load users");
} return response.json();
}
之后,调用它就非常简单:
const users = await loadUsers();
仅凭这种模式,就能构建出大量功能完备的软件。
然而现代的习惯却驱使我们立刻提出一连串问题:
是否应该立即选用某个框架?
用于数据获取的层要如何设计?
是否需要专门的库来管理状态?
在请求处理之前是否应该加入缓存机制?
是否有必要使用模式验证库?
文件夹和文件应如何组织?
组件应该遵循哪种设计模式?
该用何种构建工具来整合所有组件?
服务器应采用何种方式处理请求?
这些问题最终可能会很重要。
但它们很少是合适的起点问题。
首要的问题始终应该是:
这个应用实际上需要什么?
这种区分很重要,因为每一种抽象层都会带来相应的成本。
每一种新概念都意味着在能够安全地操作代码之前,人们还需要学习相应的知识。
如果一个普通函数就能完成工作,那通常就不需要类。
如果一个模块就能解决问题,那通常也就不需要框架。
如果一个普通对象就能存储状态,那通常就不需要状态管理系统。
如果fetch()已经能满足需求,再增加一层数据获取机制只会让你与网络之间的距离变得更远。
这并不是说框架本身就是坏事。
而是意味着框架应该解决你实际面临的问题,而不是你臆想中的问题。
async与await大幅减少了思维负担
现代JavaScript的每一次重大升级并不都那么引人注目。
async 和 await 正是那种悄然而又意义重大的改进典范。
再来看看旧式的基于回调的方式:
getUser(id, function (user) {
getOrders(user, function (orders) {
processOrders(orders, function (result) {
console.log(result);
});
});
});
问题不仅在于这段代码看起来很糟糕。
深度嵌套的回调使得执行流程极难追踪。
Promises 让情况有所改善:
getUser(id)
.then(user => getOrders(user))
.then(orders => processOrders(orders))
.then(result => console.log(result));
而后 async 和 await 更进一步,让异步代码更接近于普通的顺序逻辑:
const user = await getUser(id);
const orders = await getOrders(user);
const result = await processOrders(orders);
console.log(result);
这类功能一旦成为习惯,就会逐渐被忽视。
这其实正是良好设计的体现。
设计精良的语言特性往往会在日常使用中悄然融入,而不会刻意吸引注意力。
错误在于认为,既然异步行为本身就很复杂,那么用来封装它的句法也必然复杂。
但实际上大多并非如此。
解构并非性格测试
有些开发者过度依赖解构,导致普通代码变得更难理解。
看看这个例子:
const { name, email, address: { city } } = user;
它也有其适用场景。
但有时这样写会更清晰:
const name = user.name;
const email = user.email;
const city = user.address.city;
在单行代码中塞入更多句法并不会带来额外好处。
解构的价值在于它能让代码与底层数据之间的关联更加清晰。
例如:
function createUser({ name, email }) {
// ...
}
这样表达非常清晰。
它能明确告诉读者函数期望的对象结构是怎样的。
但一旦解构嵌套得过深,以至于读者不得不在脑海中重新构建原有的对象结构,这一特性就不再起作用,反而会妨碍工作。
只要将语法视为一种交流方式而非装饰,现代 JavaScript 依然保持简洁。
可选链操作解决了实际问题
有些特性确实能减少代码的杂乱。
可选链操作就是一个很好的例子。
在它出现之前,人们常常会看到类似这样的代码:
if (
user &&
user.profile &&
user.profile.settings &&
user.profile.settings.theme
) {
return user.profile.settings.theme;
}
现在则可以这样写:
return user?.profile?.settings?.theme;
这不仅更简短,还能直接表达意图。
开发者实际上是在说:
这条路径可能不存在。如果不存在,则安全地退出。
同样的逻辑也适用于空值合并操作:
const pageSize = options.pageSize ?? 20;
它往往比以下方式更为精确:
const pageSize = options.pageSize || 20;
因为 || 会将像 0 或 "" 这样的假值视为不存在,而 ?? 只会对 null 或 undefined 作出反应。
学习这类特性是值得的,因为它们能消除不必要的复杂性,而非增添花哨的写法。
以下是一个可用于判断任何新语法的实用标准:
这会让代码更易于理解吗?
如果答案是肯定的,那就去学习它。
如果只是让代码看起来更炫酷,就需要谨慎对待了。
现代 JavaScript 并不意味着必须使用函数式编程技巧
另一个可避免的复杂性来源是人们认为现代 JavaScript 必须以高度函数式的风格来编写。
你经常会遇到类似这样的情况:
const result = users
.filter(user => user.active)
.map(user => ({
...user,
displayName: `${user.firstName} ${user.lastName}`
}))
.reduce((groups, user) => {
const key = user.department;
groups[key] ??= [];
groups[key].push(user);
return groups;
}, {});
单独来看并没有什么问题。
但人们很容易忘记,普通的循环仍然是完全有效的 JavaScript 代码。
这种写法同样合理:
const result = {};
for (const user of users) {
if (!user.active) continue; const department = user.department; if (!result[department]) {
result[department] = [];
} result[department].push({
...user,
displayName: `${user.firstName} ${user.lastName}`
});
}
循环版本需要更多的行数。
但在调试时,它可能更易于逐行查看。
专业代码的评判标准并非行数多少,而是能否让后续阅读者付出最少的不必要的思考精力。
有时 map、filter 和 reduce 能减少这种思考负担。
有时循环反而能更好地完成工作。
知道何时该使用哪种工具,比记住所有可用的数组方法更重要。
不再与 JavaScript 的对象模型对抗,它就会变得更简单
关于JavaScript的诸多困惑,大多源于试图将其强行套入另一种语言的思维模式中。
JavaScript对象可以用来存储数据:
const user = {
name: "Sarah",
age: 31
};
它们还可以存储函数:
const user = {
name: "Sarah",
greet() {
return `Hello, ${this.name}`;
}
};
你可以定义类:
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Hello, ${this.name}`;
}
}
但并非所有情况都必须使用类。
一个普通对象搭配工厂函数往往同样清晰易懂:
function createUser(name) {
return {
name,
greet() {
return `Hello, ${name}`;
}
};
}
这两种方式并非在所有情况下都更优。
真正的技巧在于判断某段代码实际需要什么。
当需要处理真实的对象身份、行为、生命周期管理、继承或封装时,类才显得有用。
普通对象则更适合存储简单数据。
函数则更适用于执行数据转换操作。
模块通常用于划分系统各部分之间的边界。
不必仅仅因为语言支持就将每个概念都转化为类。
模块系统是 JavaScript 最强大的特性之一
原生模块悄然解决了曾经带来诸多不必要的复杂性的问题。
请看这种模式:
// math.js
export function add(a, b) {
return a + b;
}
与之搭配的是:
// app.js
import { add } from "./math.js";
console.log(add(2, 3));
这种架构能够形成清晰的依赖边界。导入模块只会获取其真正需要的内容,而依赖关系本身在代码中依然清晰可见。你可以单独测试该函数。而且这一切都不需要将所有内容塞进庞大的全局命名空间中。
其重要性在于,实现模块化并不需要依赖框架。语言本身就允许我们在系统的不同部分之间划清界限。一个项目可以这样组织:
src/
users.js
orders.js
payments.js
validation.js
config.js
app.js
无需强制每个文件都体现某种设计模式。
一个结构良好的模块,仅通过查看就能让人清楚地了解三件事:它包含哪些功能、这些功能中哪些部分对外公开,以及它依赖哪些其他代码才能运行。
一旦这些信息一目了然,你的项目就已经具备不错的结构了。
并非所有内容都需要抽象化
这可能是此处最重要的观点。
开发者们往往一发现重复代码就会着手进行抽象处理。但单纯的重复本身并不一定就是需要解决的难题。
假设你有:
const user = await fetch("/api/users/42");
const order = await fetch("/api/orders/18");
在某些时候,你可能会发现响应处理逻辑一直在重复。这一观察结果会促使你写出如下代码:
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
} return response.json();
}
这样就可以简化为:
const user = await getJson("/api/users/42");
const order = await getJson("/api/orders/18");
这种抽象方式很有价值——它有明确的功能定位。
现在再对比一下另一种情况:在尚未编写第二段 API 调用之前,就先构建通用的请求工厂、请求策略模式、数据存储接口、传输适配器、响应包装器以及完整的服务层。在这种情形下,你实际上并没有降低复杂性,只是将其转移到了架构本身之中。
这种模式在 JavaScript 代码库中随处可见。开发者一旦发现两段相似的代码,就会立刻开始为假想中的未来进行设计。而良好的工程实践往往要求采取相反的做法。
要等到重复出现的模式足够明显,从而自然显现出合适的抽象方式。
过早引入的抽象结构日后极难删除,主要是因为最终没人能确定它们是否仍在发挥实际作用。
浏览器已经为你完成了许多工作
现代 JavaScript 看起来比实际需要的更复杂,部分原因在于开发者忽视了平台本身具备的功能。
你可以直接操作 DOM,可以添加事件监听器,可以发起网络请求,可以使用 Web Storage,可以创建 Web Worker,可以建立 WebSocket 连接,可以管理浏览器历史记录,还可以操作原生表单。
所有这些功能都无需为每个小任务单独寻找库即可使用。例如:
const button = document.querySelector("#save");
button.addEventListener("click", async () => {
const response = await fetch("/api/save", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ message: "Hello" })
}); if (!response.ok) {
throw new Error("Save failed");
}
});
这是在真实应用中实现的完整功能交互,无需任何框架支持。
这些并非反对框架的理由——它们确实能解决实际问题。但底层平台应得到比目前更多的认可。一旦你了解了浏览器和编程语言已经免费提供的功能,就能更准确地判断框架究竟应该承担什么职责。
依赖关系有其代价
安装软件包确实能节省时间,但同时也可能让你长期绑定在某个工具上,而这些功能原本可能只需十行代码就能自己实现。
以这样一个小型工具为例:
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
很可能你并不需要外部包来解决那个问题。但身份验证、加密技术、复杂的日期运算、数据库客户端、图像处理以及成熟的验证逻辑则属于完全不同的范畴。
这里要传达的教训并非“不惜一切代价避免依赖”,而是要清楚每个依赖实际上会给你带来什么成本。
每一个依赖都会带来:
- 持续的维护工作
- 安全方面的隐患
- 版本升级的工作量
- 需要阅读的文档
- 一系列的间接依赖关系
- 对构建过程的影响
- 另一层需要学习的抽象概念
最优秀的 JavaScript 开发者未必是那些记住了最多 npm 包的人,而是那些能够判断某个包是否真的不需要的人。
TypeScript看似增加了复杂性,但这属于另一个话题
对于规模较大的代码库,TypeScript能够显著提升日常开发体验。同时,它也为该语言增添了另一层结构。
最终你会写出类似这样的代码:
type User = {
id: number;
name: string;
email: string;
};
此外还有:
function getUser(id: number): Promise<User> {
// ...
}
这些确实具有实际价值。但重要的是要将语言本身带来的复杂性与工具配置带来的复杂性区分开来。
仅仅因为一个项目额外添加了类型检查工具、代码检查器、模块打包工具、UI框架、自动化测试套件以及多个构建插件,JavaScript本身并不会因此变得更难。真正变得更为复杂的是你的开发环境,而这种复杂性完全有可能带来回报。
但复杂的工具链并不意味着其底层语言本身就十分繁琐,实际上往往相当简单。
真正的技巧在于选择最简洁实用的抽象层
迄今为止讨论的所有内容都遵循着同一规律。
写出优秀的 JavaScript 代码,关键不在于掌握所有可用功能,而在于挑选最精简且能完成任务的工具。
需要转换数组中的内容?使用 map。
需要筛选出一组值?使用 filter。
需要按顺序处理异步任务?使用 async 和 await。
需要具有明确边界且可重复使用的逻辑代码?那就编写函数或模块。
需要键值对类型不限于字符串的集合?可以考虑使用 Map。
需要保证唯一性?可以使用 Set。
需要安全地访问可能不存在的属性?请使用可选链操作符。
只有在值为 null 或 undefined 时才需要默认值?可以使用 ??。
需要一个简单的数据容器?对象就足够了。
需要更丰富的功能以及与数据相关的标识性?类可能更适合。
需要构建规模极大的应用程序?这时就该考虑使用框架了。
注意这个顺序。
首先从语言本身开始。
接着查看平台已提供的功能。
然后是专为特定目的设计的简单抽象结构。
之后是外部库。
最后才是框架。
反其道而行之就会带来许多不必要的复杂性。
简洁并不意味着没有工程思维
关于“简单”代码的真正含义,存在一种常见但具有误导性的看法。
有些人将简单性与经验不足等同起来。
这种观点是站不住脚的。
试想,一个刚入行的人可能会写出类似这样的代码:
function process(data) {
// everything
}
表面上看它非常简洁,但一旦代码规模扩大,就真的很难维护了。
而经验更丰富的开发者则可能会将同样的逻辑拆分成五个小模块,每个模块都有明确界定的范围。
虽然这样会有更多的组成部分,但实际上却更容易理解。
这才是另一种更有意义的简单性。
目标并非减少文件数量、依赖项列表或行数,而是消除那些本不应存在的复杂性。
随着代码库的不断增长,这种区别变得越来越重要。
即使只有二十行代码的脚本也可能令人困惑。
令人意外的是,两千行代码的应用程序反而可能很容易理解。
决定性的因素通常并非代码的总量。
而是这些代码能否让读者清晰地理解其背后的逻辑模型。
别再把每个新功能都当作必须掌握的知识
JavaScript的发展速度极快,这种快速变化本身就会带来压力。
你习惯了某种做事方式,几个月后另一种方法却成了所有讨论的焦点。
一个新的框架出现了。
一种新的运行时开始流行起来。
一种新的渲染模式成了趋势。
又有一套新语法被引入。
很容易让人觉得自己一直在落后。
但实际上很可能并非如此。
不必在有人刚写出关于新 JavaScript 技术的文章时就立刻去学习它。
相反,应把精力投入到基础知识上。
熟练掌握异步编程。
熟悉作用域的概念。
理解闭包的原理。
掌握对象的运用方法。
熟悉模块化编程。
了解事件循环的运作机制。
掌握 Promise 的使用方式。
学会错误处理技巧。
熟悉浏览器或运行时提供的 API。
能够追踪数据在应用程序中的流动路径。
只有到那时,且在遇到实际问题需要时,再去学习新工具。
这样的方法才能建立起持久的知识体系。
对特定框架的熟悉度可能会过时。
而对程序运作原理的扎实理解则不会。
最优秀的 JavaScript 代码往往看起来很普通
开发者最终都会意识到这一点。
最容易维护的代码在第一眼阅读时很少能给人留下深刻印象。
它不会把五种抽象层叠在一起。
在第一个函数能被理解之前,它不需要任何图表说明。
它不会使用语言最新版本中的所有功能。
通常,它看起来非常简单。
这并非批评,而是一种优势。
打开文件后,其意图立刻就很清晰:
import { getUser } from "./users.js";
export async function loadProfile(id) {
const user = await getUser(id); if (!user) {
throw new Error("User not found");
} return {
name: user.name,
email: user.email
};
}
几乎没有什么需要解读的。
该函数用于获取用户信息。
它会检查查询是否成功。
然后返回实际需要的数据。
这里没有任何内容试图证明其作者有多聪明。
它只是避免让读者做不必要的工作,而这在代码中才是更为宝贵的特性。
现代 JavaScript 比我们的项目更简单
JavaScript 也存在不少真正的缺陷,每种语言都是如此。
但人们认为“现代 JavaScript”复杂的诸多因素其实并非源自该语言本身。
它们源于叠加在它之上的各种层。
框架。
构建系统。
依赖项。
惯例。
抽象概念。
工具。
架构选择。
其中一些层确实是必要的。
另一些则并非如此。
正确的问题不在于某个工具是否属于现代工具,
而在于它是否能真正降低你当前所面临问题的复杂性。
在引入另一个依赖项之前,先检查平台是否已经提供了它。
在添加另一种抽象层之前,先确认你要解决的重复问题确实存在。
在增加另一层架构之前,先明确它旨在防止哪种故障发生。
在选择另一个框架之前,先弄清楚你当前环境中真正需要解决的局限性是什么。
在认定 JavaScript 本身过于复杂之前,试着仅使用该语言及其运行的平台来解决问题。
你可能会对结果感到惊讶。
语言往往是整个问题中最简单的部分。
是开发者不断在它之上堆砌更多层次。
有时最具有前瞻性的做法是去除一层而非增加一层。
最好的 JavaScript 并非那些使用了最多工具的代码。
而是那些每一项所使用的工具都确实有其存在必要的代码。