您不需要构建步骤
最早爆红的 XKCD 漫画之一是这一篇,#303
如今,Web 开发人员的版本将是“我的网站正在构建”,他们将戴上 VR 眼镜玩虚拟剑术。
现在网站构建需要花费时间。一个大型 Next.js 11 网站需要几分钟才能构建完成。这在开发周期中是浪费时间。构建工具如 Vite 或 Turbopack 强调了它们缩短构建时间的能力。
但更深层次的问题尚未得到考虑
我们为什么需要构建步骤?
构建如何成为常态
在过去,您只需在您的 index.html
文件中添加几个 <script src="my_jquery_script.js"></script>
标签,一切就都完美了。
然后 Node 诞生了,允许开发者用 JavaScript 编写服务器和后端。很快,开发者不再需要学习多种语言来构建可扩展的、可生产环境的应用。他们只需要了解 JavaScript。
如果我们一直保持这种状态,一切都会很好。但不知何时,有人提出了一个危险的问题
如果我可以用 JavaScript 编写服务器端代码,但在浏览器中呢?
Node 的服务器端 JavaScript 与浏览器端 JavaScript 不兼容,因为每个实现都满足了两个完全不同的系统
- Node 是围绕文件系统构建的。服务器有 HTTP 驱动的 I/O,但内部机制都是关于在文件系统中找到正确文件。
- 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、SVGs 和 Bootstrap 等提供了各种 加载器。这允许开发者选择自己的构建栈:他们可以使用 webpack 作为模块捆绑器、babel 作为 TS 转译器,并使用 postcss 加载器来加载 Tailwind。
构建步骤在现代 Web 开发中是不可避免的。但在我们质疑是否需要构建工具之前,让我们先问问
究竟需要做些什么才能使服务器端 JavaScript 在浏览器中运行?
Next.js 的四步构建过程
让我们看一个 Next.js 的实际例子。我们不会启动他们的基本应用程序,而是使用 博客入门,作为您可能用此框架构建的内容
npx create-next-app --example blog-starter blog-starter-app
无需更改任何内容,我们将运行
npm run build
这将启动一个四步过程,使您的 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
正常工作。
让我们使用 依赖项巡航器 来可视化这一点。首先,我们只关注组件
npx depcruise --exclude "^node_modules" --output-type dot pages | dot -T svg > dependencygraph.svg
这是依赖关系图
还不错。但它们中的每一个都有节点模块依赖项。让我们删除那个 --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 是 Webby(第 2 部分)
上面的构建步骤都源于一个简单的问题——Node 的 JavaScript 与浏览器的 JavaScript 已经分道扬镳。但如果我们可以编写从一开始就使用 Web API(如 fetch
和本机 ESM 导入)的浏览器兼容 JavaScript 呢?
这就是 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/[email protected]/server.ts";
import { Head } from "https://deno.land/x/[email protected]/runtime.ts";
import { getPost, Post } from "../utils/posts.ts";
import { CSS, render } from "https://deno.land/x/[email protected]/mod.ts";
我们从我们自己的模块 posts.ts 中导入 getPost
和 Post
。在这些组件中,我们正在 从其他 URL 导入模块
import { extract } from "https://deno.land/[email protected]/encoding/front_matter.ts";
import { join } from "https://deno.land/[email protected]/path/posix.ts";
在依赖关系图中的任何一个点,我们都只是从其他 URL 调用代码。就像乌龟,它一直都是 URL。
即时转译
Fresh 也不需要任何单独的转译步骤,因为所有这些都在请求时即时发生
- 在浏览器中使用 TypeScript 和 TSX:Deno 运行时可以即时地将 TypeScript 和 TSX 按需转译为 JavaScript。
- 服务器端渲染:通过模板传递动态数据以生成 HTML 也在请求时发生。
- 通过 Island 编写客户端 TypeScript:客户端 TypeScript 按需转译为 JavaScript,这是必要的,因为浏览器不理解 TypeScript
为了使你的 Fresh 应用程序性能更佳,所有客户端 JavaScript/TypeScript 在第一个请求后都会被缓存,以便快速检索后续请求。
更好的代码,更快
只要开发人员没有编写原始的 HTML、JS 和 CSS,并且需要优化资产以提高最终用户的性能,那么就不可避免地会有一些“构建”步骤。这一步是单独进行,花费几分钟时间,并在 CI/CD 中进行,还是在请求发生时即时进行,这取决于你选择的框架或堆栈。
但移除构建步骤意味着你可以更快地工作,提高生产力。更长时间地保持流动状态。修改代码时不再需要进行剑术战斗休息(抱歉)或上下文切换。
你也可以更快地部署。由于没有构建步骤,特别是在使用 Deno Deploy 的 v8 隔离云 时,你的全局部署只需几秒钟,因为它只是上传了几 kb 的 JavaScript。
你编写的代码也更好,开发人员体验也更好。与其在尝试通过捆绑器网络将 Node、ESM 和浏览器兼容 JavaScript 联系在一起时学习 Node 或供应商特定的 API,不如编写 Web 标准 JavaScript,学习你可以与任何云原语一起重复使用的 API。
跳过构建步骤,尝试使用 Fresh 和 Deno Deploy 制作一些东西。