跳到主要内容
A fossililzed dinosaur skeleton in a museum with a plaque that reads 'npm run build'.

你不需要构建步骤

最早传播开来的 XKCD 漫画之一是这篇 #303

XKCD comic where the developers are playing with swords since their code is compiling.

今天,Web 开发人员的版本会是“我的网站正在构建”,而他们会在 VR 中玩剑术游戏。

现在构建网站需要时间。一个大型的 Next.js 11 网站需要几分钟才能构建完成。这在开发周期中是浪费的时间。诸如 ViteTurbopack 等构建工具突出了它们缩短这个数字的能力。

但是更深层次的问题尚未被考虑

我们为什么甚至需要构建步骤?

构建如何成为常态

在更简单的时代,你在你的 index.html 中添加几个 <script src="my_jquery_script.js"></script> 标签,一切都很完美。

然后 Node 被创建出来,允许开发者用 JavaScript 编写服务器和后端。很快,开发者不再需要学习多种语言来构建可扩展的、生产就绪的应用程序。他们只需要懂 JavaScript 就行了。

Interest in Node on Google over time
自从 Node.js 诞生以来,人们对它的兴趣日益增长。

如果我们就此止步,一切都会很好。但在某个时候,有人提出了一个危险的问题

如果我可以在浏览器中编写服务器端 JS 会怎么样?

Node 的服务器端 JavaScript 与浏览器 JavaScript 不兼容,因为每种实现都满足两个完全不同的系统

  • Node 是围绕文件系统构建的。 服务器有 HTTP 驱动的 IO,但其内部机制都是关于在文件系统中找到正确的文件。
  • JavaScript 是为浏览器创建的,在浏览器中,脚本/资源通过 URL 异步导入。

推动构建步骤必要性的其他关键问题包括

  1. 浏览器没有“包管理器”,而 npm 正在迅速成为 Node 和大型 JavaScript 的事实上的包管理器。前端开发者想要一种在浏览器中轻松管理 JavaScript 依赖项的方法。
  2. npm 模块及其导入方法 (CommonJS) 在浏览器中不受支持。
  3. 浏览器 JavaScript 持续发展(自 2009 年以来,它添加了 Promises、async/await、顶层 await、ES 模块和类),而 Node 的 JavaScript 则落后几个周期。
  4. 服务器上使用的 JavaScript 有不同的风格。CoffeeScript 为该语言带来了 Pythonic 和 Ruby 风格,JSX 允许编写 HTML 标记,而 Typescript 启用了类型安全。但所有这些都需要转换为常规 JavaScript 才能在浏览器中使用。
  5. Node 是模块化的,因此来自不同 npm 模块的代码需要捆绑和压缩,以减少发送到客户端的代码量。
  6. 原始代码中使用的一些功能可能在旧版本的浏览器中不可用,因此需要添加 polyfills 来弥合差距。
  7. CSS 框架和预处理器(例如 LESSSASS)的创建是为了改善编写和维护复杂 CSS 代码库的体验,它们需要被转译成原始的、浏览器可解析的 CSS。
  8. 通过 HTML 渲染动态数据(类似于静态站点生成器)通常需要在 HTML 部署到托管提供商之前进行单独的步骤。

随着时间的推移,框架和元框架通过使编写和管理复杂应用程序变得更容易,从而改善了开发人员的体验。但是,为了更好的开发人员体验而付出的代价是更复杂的构建步骤。例如,你可以制作一个零构建博客并用 HTML 编写。或者,你可以用 markdown 编写你的博客,并通过 HTML 渲染,这需要一个构建步骤。

Developer experience vs. build complexity chart

但并非所有的构建步骤都是为了获得良好的开发人员体验。其他构建步骤是为了提高最终用户的性能(例如,优化步骤,如创建多种图像尺寸并将它们转换为最佳格式)。

总而言之,为了使代码在浏览器中运行,不可避免地必须应用一组代码转换,今天我们都知道这组代码转换就是……构建步骤

JavaScript 构建工具的兴起

为了满足让服务器端 JavaScript 在浏览器中工作的日益增长的兴趣,几个开源构建工具应运而生,标志着 JavaScript “构建工具生态系统” 的到来。

2011 年,Browserify 发布,用于为浏览器捆绑 Node/npm。随后出现了 Gulp (2013) 和其他构建工具、任务运行器等,以管理各种构建任务,从而使开发人员能够继续编写 Node 代码,但用于浏览器。越来越多的构建工具涌现出来。

以下是随时间推移的构建工具的不完全列表

到 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

这是依赖关系图

A depedency graph of a Next.js app

还不错。但这些中的每一个都有 node 模块依赖项。让我们删除 --exclude "^node_modules" 以查看此项目中的所有内容

npx depcruise --output-type dot pages | dot -T svg > dependencygraph.svg

当我们考虑这些因素时,图表变得更大。就像,巨大。它太大了,以至于为了保持图像的趣味性,我们将其注释为好像它是一张有趣的、中世纪时代的地图。(此外,这是一个 svg 文件,所以请随意在新标签页中打开它,以便你可以放大并沉浸在所有有趣的细节中。)

A map of dependencies of a basic Next.js app

谁知道 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 中导入 getPostPost。在这些组件中,我们正在 从其他 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。

跳过构建步骤,尝试使用 FreshDeno Deploy 创建一些东西。