你不需要构建步骤
最早传播开来的 XKCD 漫画之一是这篇 #303
今天,Web 开发人员的版本会是“我的网站正在构建”,而他们会在 VR 中玩剑术游戏。
现在构建网站需要时间。一个大型的 Next.js 11 网站需要几分钟才能构建完成。这在开发周期中是浪费的时间。诸如 Vite 或 Turbopack 等构建工具突出了它们缩短这个数字的能力。
但是更深层次的问题尚未被考虑
我们为什么甚至需要构建步骤?
构建如何成为常态
在更简单的时代,你在你的 index.html
中添加几个 <script src="my_jquery_script.js"></script>
标签,一切都很完美。
然后 Node 被创建出来,允许开发者用 JavaScript 编写服务器和后端。很快,开发者不再需要学习多种语言来构建可扩展的、生产就绪的应用程序。他们只需要懂 JavaScript 就行了。

如果我们就此止步,一切都会很好。但在某个时候,有人提出了一个危险的问题
如果我可以在浏览器中编写服务器端 JS 会怎么样?
Node 的服务器端 JavaScript 与浏览器 JavaScript 不兼容,因为每种实现都满足两个完全不同的系统
- Node 是围绕文件系统构建的。 服务器有 HTTP 驱动的 IO,但其内部机制都是关于在文件系统中找到正确的文件。
- JavaScript 是为浏览器创建的,在浏览器中,脚本/资源通过 URL 异步导入。
推动构建步骤必要性的其他关键问题包括
- 浏览器没有“包管理器”,而 npm 正在迅速成为 Node 和大型 JavaScript 的事实上的包管理器。前端开发者想要一种在浏览器中轻松管理 JavaScript 依赖项的方法。
- npm 模块及其导入方法 (CommonJS) 在浏览器中不受支持。
- 浏览器 JavaScript 持续发展(自 2009 年以来,它添加了 Promises、
async
/await
、顶层await
、ES 模块和类),而 Node 的 JavaScript 则落后几个周期。 - 服务器上使用的 JavaScript 有不同的风格。CoffeeScript 为该语言带来了 Pythonic 和 Ruby 风格,JSX 允许编写 HTML 标记,而 Typescript 启用了类型安全。但所有这些都需要转换为常规 JavaScript 才能在浏览器中使用。
- Node 是模块化的,因此来自不同 npm 模块的代码需要捆绑和压缩,以减少发送到客户端的代码量。
- 原始代码中使用的一些功能可能在旧版本的浏览器中不可用,因此需要添加 polyfills 来弥合差距。
- CSS 框架和预处理器(例如 LESS 和 SASS)的创建是为了改善编写和维护复杂 CSS 代码库的体验,它们需要被转译成原始的、浏览器可解析的 CSS。
- 通过 HTML 渲染动态数据(类似于静态站点生成器)通常需要在 HTML 部署到托管提供商之前进行单独的步骤。
随着时间的推移,框架和元框架通过使编写和管理复杂应用程序变得更容易,从而改善了开发人员的体验。但是,为了更好的开发人员体验而付出的代价是更复杂的构建步骤。例如,你可以制作一个零构建博客并用 HTML 编写。或者,你可以用 markdown 编写你的博客,并通过 HTML 渲染,这需要一个构建步骤。
但并非所有的构建步骤都是为了获得良好的开发人员体验。其他构建步骤是为了提高最终用户的性能(例如,优化步骤,如创建多种图像尺寸并将它们转换为最佳格式)。
总而言之,为了使代码在浏览器中运行,不可避免地必须应用一组代码转换,今天我们都知道这组代码转换就是……构建步骤。
JavaScript 构建工具的兴起
为了满足让服务器端 JavaScript 在浏览器中工作的日益增长的兴趣,几个开源构建工具应运而生,标志着 JavaScript “构建工具生态系统” 的到来。
2011 年,Browserify 发布,用于为浏览器捆绑 Node/npm。随后出现了 Gulp (2013) 和其他构建工具、任务运行器等,以管理各种构建任务,从而使开发人员能够继续编写 Node 代码,但用于浏览器。越来越多的构建工具涌现出来。
以下是随时间推移的构建工具的不完全列表
- Browserify - 2011
- Grunt - 2012
- Bower - 2012
- Gulp - 2013
- Babel - 2014
- Webpack - 2014
- Rollup - 2015
- Parcel - 2017
- SWC - 2019
- Vite - 2020
- ESBuild - 2020
- Turbopack - 2022
到 2020 年代,构建工具已经成为它们自己的 JS 库/框架类别。许多这些工具也有自己的插件和加载器生态系统,允许开发人员使用他们最喜欢的技术。
例如,Webpack 为 SASS、Babel、SVG 和 Bootstrap 等提供了各种加载器。这允许开发人员选择他们自己的构建堆栈:他们可以使用 webpack 作为他们的模块捆绑器,babel 作为他们的 TS 转译器,以及用于 Tailwind 的 postcss 加载器。
构建步骤在现代 Web 开发中是不可避免的。但在我们询问是否甚至需要构建工具之前,我们首先要问
究竟需要发生什么才能使服务器端 JavaScript 在浏览器中运行?
Next.js 四步构建过程
让我们以 Next.js 为例来看看实际情况。我们不会启动他们的基本应用程序,而是使用 博客启动器,这可能是你使用此框架构建的东西
npx create-next-app --example blog-starter blog-starter-app
在不更改任何内容的情况下,我们将运行
npm run build
这将启动一个 4 步过程,使你的 Next.js 项目在浏览器中运行
构建过程中的每个步骤要么是为了支持开发人员编写代码的体验,要么是为了提高最终用户的性能。让我们深入了解一下。
编译
当你构建 Web 应用程序时,你的主要关注点是生产力和体验。因此,你将使用像 Next.js 这样的框架,这意味着你可能也在使用 React、ESM 模块、JSX、async
/await
、TypeScript 等。但是,此代码需要转换为浏览器可用的原始 JavaScript,这发生在编译步骤中
- 首先,解析代码并将其转换为抽象表示,称为 抽象语法树
- 然后,将此 AST 转换为目标语言支持的表示形式
- 最后,从这个新的 AST 表示形式生成新代码
如果你想了解更多关于编译器内部原理的信息,The Super Tiny Compiler 是一个关于它们如何工作的优秀教程。
Next.js 的第一步是将你的所有代码编译为纯 JavaScript。让我们以 [slug].tsx
中的 Post 函数为例
export default function Post({ post, morePosts, preview }: Props) {
const router = useRouter()
if (!router.isFallback && !post?.slug) {
return <ErrorPage statusCode={404} />
}
return (
<Layout preview={preview}>
<Container>
<Header />
{router.isFallback ? (
<PostTitle>Loading…</PostTitle>
) : (
<>
<article className="mb-32">
<Head>
<title>
{post.title} | Next.js Blog Example with {CMS_NAME}
</title>
<meta property="og:image" content={post.ogImage.url} />
</Head>
<PostHeader
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
/>
<PostBody content={post.content} />
</article>
</>
)}
</Container>
</Layout>
)
}
编译器将解析此代码,将其转换为 AST,将该 AST 操作为浏览器 JS 的正确功能形式,并生成新代码。这是为该函数编译的代码,它将被发送到浏览器
function y(e) {
let { post: t, morePosts: n, preview: l } = e,
c = (0, r.useRouter)();
return c.isFallback || (null == t ? void 0 : t.slug)
? (0, s.jsx)(v.Z, {
preview: l,
children: (0, s.jsxs)(a.Z, {
children: [
(0, s.jsx)(h, {}),
c.isFallback
? (0, s.jsx)(j, {
children: "Loading…",
})
: (0, s.jsx)(s.Fragment, {
children: (0, s.jsxs)("article", {
className: "mb-32",
children: [
(0, s.jsxs)(N(), {
children: [
(0, s.jsxs)("title", {
children: [
t.title,
" | Next.js Blog Example with ",
w.yf,
],
}),
(0, s.jsx)("meta", {
property: "og:image",
content: t.ogImage.url,
}),
],
}),
(0, s.jsx)(p, {
title: t.title,
coverImage: t.coverImage,
date: t.date,
author: t.author,
}),
(0, s.jsx)(x, {
content: t.content,
}),
],
}),
}),
],
}),
})
: (0, s.jsx)(i(), {
statusCode: 404,
});
}
压缩
当然,这段代码不是为了让人阅读的,它只需要被浏览器理解。压缩步骤用单个字符替换函数和组件名称,以减少发送到浏览器的千字节数,从而提高最终用户的性能。
上面也是“美化”版本。这是它真正的样子
function y(e) {
let { post: t, morePosts: n, preview: l } = e, c = (0, r.useRouter)();
return c.isFallback || (null == t ? void 0 : t.slug)
? (0, s.jsx)(v.Z, {
preview: l,
children: (0, s.jsxs)(a.Z, {
children: [
(0, s.jsx)(h, {}),
c.isFallback
? (0, s.jsx)(j, { children: "Loading…" })
: (0, s.jsx)(s.Fragment, {
children: (0, s.jsxs)("article", {
className: "mb-32",
children: [
(0, s.jsxs)(N(), {
children: [
(0, s.jsxs)("title", {
children: [
t.title,
" | Next.js Blog Example with ",
w.yf,
],
}),
(0, s.jsx)("meta", {
property: "og:image",
content: t.ogImage.url,
}),
],
}),
(0, s.jsx)(p, {
title: t.title,
coverImage: t.coverImage,
date: t.date,
author: t.author,
}),
(0, s.jsx)(x, { content: t.content }),
],
}),
}),
],
}),
})
: (0, s.jsx)(i(), { statusCode: 404 });
}
捆绑
以上所有代码都包含在一个名为(对于此构建)[slug]-af0d50a2e56018ac.js
的文件中。当代码被美化后,该文件有 447 行代码。相比之下,我们编辑的原始 [slug].tsx
代码要小得多,只有 56 行。
为什么我们发送了一个大 10 倍的文件?
因为构建过程的另一个关键部分:捆绑。
尽管 [slug].tsx
只有 56 行代码,但它依赖于许多依赖项和组件,而这些依赖项和组件又依赖于更多的依赖项和组件。所有这些模块都需要加载才能使 [slug].tsx
正常工作。
让我们使用 dependency cruiser 来可视化这一点。首先,我们只看一下组件
npx depcruise --exclude "^node_modules" --output-type dot pages | dot -T svg > dependencygraph.svg
这是依赖关系图
还不错。但这些中的每一个都有 node 模块依赖项。让我们删除 --exclude "^node_modules"
以查看此项目中的所有内容
npx depcruise --output-type dot pages | dot -T svg > dependencygraph.svg
当我们考虑这些因素时,图表变得更大。就像,巨大。它太大了,以至于为了保持图像的趣味性,我们将其注释为好像它是一张有趣的、中世纪时代的地图。(此外,这是一个 svg 文件,所以请随意在新标签页中打开它,以便你可以放大并沉浸在所有有趣的细节中。)
(谁知道 date-fns
中包含了这么多东西?)
捆绑器需要为代码的入口点(通常是 index.js)创建依赖关系图,然后向后查找 index.js
依赖的所有内容,然后再查找 index.js
依赖项所依赖的所有内容,依此类推。然后,它将所有这些捆绑到一个可以发送到浏览器的单个输出文件中。
对于大型项目,这是花费大部分构建时间的地方:遍历和创建依赖关系图,然后添加发送到客户端的单个捆绑包中所需的内容。
代码拆分
或者不进行代码拆分,如果你没有代码拆分的话。
如果没有代码拆分,则会在用户第一次访问该站点时将单个捆绑的 JS 文件发送到客户端,而不管是否需要该 JavaScript 的全部内容。通过代码拆分(一种性能优化步骤),JavaScript 按入口点(例如,按页面或按 UI 组件)或按动态导入进行分块(因此在任何一次只需要发送一小部分 JavaScript)。代码拆分有助于“延迟加载”用户当前需要的内容,方法是仅加载所需的内容,并避免加载可能永远不会使用的代码。使用 React,当使用代码拆分时,你可以体验到主捆绑包大小最多减少 30%。
在我们的示例中,[slug]-af0d50a2e56018ac.js
是加载特定博客文章页面所需的代码,它不包含主页或站点上任何其他组件的任何代码。
你可以开始明白为什么生态系统中构建系统和工具激增:这玩意儿太复杂了。我们甚至还没有涉及到你必须配置和编译 CSS 的所有选项。YouTube 上的 Webpack 教程时长达数小时。长时间的构建是常见的挫败感,以至于 最近的 Next.js 13 更新 的一个主要主题是更快的构建。
当 JavaScript 社区致力于改善构建应用程序的开发人员体验(元框架、CSS 预处理器、JSX 等)时,它还必须致力于构建更好的工具和任务运行器,以减少构建步骤的痛苦。
如果还有另一种方法呢?
使用 Deno 和 Fresh 进行非构建
我发现 Deno 与此类似:如果你使用 Deno 学习服务器端 JavaScript,你可能会顺便学习 Web 平台。这是可以转移的知识。
— Jim Nielsen,《Deno is Webby (pt. 2)》
上面的构建步骤都源于一个简单的问题 —— Node 的 JavaScript 已经与浏览器的 JavaScript 分道扬镳。但是,如果我们能够从一开始就编写浏览器兼容的 JavaScript,它使用像 fetch
和原生 ESM 导入这样的 Web API,会怎么样呢?
那就是 Deno。Deno 采取的方法是,Web JS 近年来已得到极大的改进,现在是一种极其强大的脚本语言。我们都应该使用它。
这是你如何做与上面相同的事情,构建一个博客,但使用 Deno 和 Fresh。
Fresh 是一个构建在 Deno 之上的 Web 框架,它没有构建步骤 —— 没有捆绑,没有转译 —— 这是有意为之的。当请求进入服务器时,Fresh 会动态渲染每个页面,并且只发送 HTML(除非涉及到 Island,那么也只会发送所需数量的 JavaScript)。
即时构建优于捆绑
缺少捆绑是其第一部分:即时构建。使用 Fresh 渲染页面就像加载一个普通的网页。由于所有导入都是 URL,因此你加载的页面会调用 URL 来加载它需要的其他代码(来自源,或者如果以前使用过则来自缓存)。
使用 Fresh,当用户点击帖子页面时,会加载 /routes/[slug].tsx
。此页面导入这些模块
import { Handlers, PageProps } from "$fresh/server.ts";
import { Head } from "$fresh/runtime.ts";
import { getPost, Post } from "@/utils/posts.ts";
import { CSS, render } from "$gfm";
这看起来可能很像在 Node 中导入任何内容。但那是因为我们正在使用来自我们的 导入映射 的说明符。当解析时,这些导入是
import { Handlers, PageProps } from "https://deno.land/x/fresh@1.1.0/server.ts";
import { Head } from "https://deno.land/x/fresh@1.1.0/runtime.ts";
import { getPost, Post } from "../utils/posts.ts";
import { CSS, render } from "https://deno.land/x/gfm@0.1.26/mod.ts";
我们正在从我们自己的模块 posts.ts 中导入 getPost
和 Post
。在这些组件中,我们正在 从其他 URL 导入模块
import { extract } from "https://deno.land/std@0.160.0/encoding/front_matter.ts";
import { join } from "https://deno.land/std@0.160.0/path/posix.ts";
在依赖关系图中的任何给定点,我们只是从其他 URL 调用代码。就像海龟一样,一切都是 URL。
即时转译
Fresh 也不需要任何单独的转译步骤,因为它都是在请求时即时发生的
- 使 TypeScript 和 TSX 在浏览器中工作:Deno 运行时在请求时即时开箱即用地转译 TypeScript 和 TSX。
- 服务器端渲染:通过模板传递动态数据以生成 HTML 也在请求时发生。
- 通过 islands 编写客户端 TypeScript:客户端 TypeScript 按需转译为 JavaScript,这是必要的,因为浏览器不理解 TypeScript
为了使你的 Fresh 应用程序性能更高,所有客户端 JavaScript/TypeScript 都在第一次请求后缓存,以便快速后续检索。
更好的代码,更快的速度
只要开发人员不编写原始 HTML、JS 和 CSS,并且需要为最终用户的性能优化资产,就不可避免地会有某种“构建”步骤。该步骤是需要几分钟并在 CI/CD 中发生的单独步骤,还是在请求发生时即时完成,取决于你选择的框架或堆栈。
但是删除构建步骤意味着你可以更快地行动并提高生产力。保持更长时间的流畅状态。在更改代码时,不再需要剑术休息(抱歉)或上下文切换。
你也可以更快地部署。由于没有构建步骤,尤其是在使用 Deno Deploy 的 v8 isolate cloud 时,你的全球部署只需几秒钟,因为它只是上传几个 kb 的 JavaScript。
你也在编写更好的代码,并获得更好的开发人员体验。与其学习 Node 或供应商特定的 API,同时尝试通过捆绑器的网络将 Node、ESM 和浏览器兼容的 JavaScript 连接起来,不如编写 Web 标准 JavaScript,学习可以与任何云原语重用的 API。
跳过构建步骤,尝试使用 Fresh 和 Deno Deploy 创建一些东西。