前端开发可以不构建吗?

3,039 阅读10分钟

惯例,先分享一个很有趣的图片

image.png

很明显,构建成为了如今前端开发中很痛的一个点,一个大型的Next.js站点的构建需要几分钟,这十分浪费开发者的时间。像是Vite和Turbopack很大程度的提升了构建性能,但是根本上的构建流程还是没有得到解决。

构建是如何变成规范的?

最开始的阶段,我们开发前端只需要将写好的js文件通过script引入到html文件里面即可,然后就可以完美浏览。

但是随着Node的产生,它允许开发者通过js写服务端的代码,渐渐地,开发者不再需要掌握更多门的语言来生产可扩展,可生产的应用了,他们只需要掌握js。 Interest in Node.js grew since its inception.

所谓艺高人胆大,总有人搞一些危险发言,例如这样:能不能让我在浏览器环境去写服务端Js?

事实上,Node服务端Js与浏览器Js并不兼容,两种实现机制完全是为了满足两种不同的系统:

  • Node都在围绕文件系统
  • 浏览器Js则都是通过URL来异步引入的

还有一些其他的因素促使着构建流程的产生:

  1. 浏览器没有包管理,npm就变成了node和js包管理者,前端需要一种简单的方式来管理浏览器的js依赖包
  2. npm模块以及引用方式(CommonJs)在浏览器不支持
  3. 浏览器Js在持续进化(从09年开始,已经添加了Promises,async/await,top-level await,ES Modules和classes),而NodeJs则落后了几个周期
  4. 有很多服务端Js书写的偏好,coffeeScript使用python化和类ruby风格的语言,JSX允许书写HTML标签,TS能使得类型安全。但是这些最终都需要转化为浏览器可识别的JS
  5. Node是模块化,所以从多个npm包组合起来的代码需要打包且需要压缩来减少客户端请求体积
  6. 源代码中的一些特性不兼容老的浏览器,所以polyfills需要添加进来弥补这个缺失
  7. CSS框架和预处理器是用来改善书写和维护复杂Css代码库的,需要编译成原生,浏览器可以识别的CSS
  8. 通过HTML呈现动态数据通常需要单独的步骤,然后才能将HTML部署到托管提供商。

随着时间推移,框架和一些元框架旨在通过更简单地写和管理复杂apps来提升开发体验,但是代价就是更复杂的构建步骤,例如,你可以通过HMLT写一个Blog而不需要任何构建流程,或者通过Markdown写一个blog,然后通过一次构建实现渲染。

然而,构建也并不全是意味着好的开发体验,有些是为了改善终端的用户性能,总而言之,代码集合必须要经过一次转化才能变成浏览器可以执行的代码,而这个转化就是我们熟知的构架流程。

Js构建工具的崛起

为了解决服务端JS能够在浏览器工作,一些开源构建工具落地,标志着JS构建生态的出现。

这是一份构建工具名单:

  • Browserify - 2011
  • Grunt - 2012
  • Bower - 2012
  • Gulp - 2013
  • Babel - 2014
  • Webpack - 2014
  • Rollup - 2015
  • Parcel - 2017
  • SWC - 2019
  • Vite - 2020
  • ESBuild - 2020
  • Turbopack - 2022

构建步骤在当代的web开发中不可避免的,但是在我们探索构建工具是否必要之前,我们先问问:

要使服务器端JavaScript在浏览器中运行,到底需要做些什么?

Next.js的四步构建流程

我们从Next的一个真实案例来看,让我们初始化一个blog项目

npx create-next-app --example blog-starter blog-starter-app

接着我们执行下面的命令:

npm run build

接下来将会触发四个步骤直到让你的Next项目运行浏览器中

  • Compiling
  • Minifying
  • Bundling
  • Code splitting

这里面的每一步要么是在支持开发者的开发体验要么是在改善终端的用户性能。让我们看更细节的部分

Compiling

当你构建一个web应用,你主要关心是它的生产力和体验。所以你会用像是Next.js的框架,与此同时也意味着你会用到React,ESM模块,JSX,async/await和TypeScript等等。这些代码都需要转化为浏览器可执行的原生js,将会发生如下的编译步骤:

  • 解析代码为抽象语法树(AST)
  • 转化AST为目标语言支持的描述
  • 基于描述生成新的代码

Next.js的第一步是编译你的代码成为原生代码,就拿[slug].tsx中的Post function来看:

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为正确的功能形态并最终生成新的代码,转化后的代码如下:

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,
    });
}

Minifying

这些代码并不是给人来阅读,只是提供给机器来理解。压缩的步骤是替换函数和组件的名字为一个单一字符,进而减小浏览器加载的体积,进而优化用户体验。

看一下压缩后的代码形态:

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 });
}

Bundling

所有的上述代码都会存储在一个名为[slug]-af0d50a2e56018ac.js的文件中,当你格式化这个代码,你会发现文件有447行。相较于源代码只有56行,这个变化是巨大的,那是为什么呢?

那是因为另一个关键步骤的作用:bundling

尽管[slug].tsx只有56行,但是它本身依赖了很多其他依赖和组件,这些依赖和组件又会有更多的依赖和组件。为了确保[slug].tsx能够合理运行,所有的这些模块都需要加载进来。

让我们通过一个工具来查看依赖分析,依赖关系图如下:

image.png

看起来还不是特别复杂😊

Bundlers需要基于入口文件创建一个关系依赖图,然后就从这个入口不断的找依赖,依赖的依赖。最终,它会产出一个浏览器可以执行的文件。

对于大型的项目,构建的时间大部分都花在了:遍历、创建依赖关系图中和添加一些必要的内容。

Code-splitting

没有代码分割,用户第一次请求这个网站,无论你是否用到了必要的功能,所有的文件内容都会加载到浏览器里面;而如果有了代码分割,这是个一个性能优化步骤,js代码会依据chunk的概念动态加载。代码分割某种程度就是懒加载,使得代码在需要的时候加载,而不会加载不会使用的代码。

在本文的例子里面,[slug]-af0d50a2e56018ac.js就是针对blog发布页面加载的,这里不会包含任何其他像是主页面和组件的内容。

你可以明白为什么会有这么多的构建系统和工具的激增,这些东西都让事情变得复杂。Webpack在YouTube上的课程有几个小时长,长的构建时间是个通用问题,所以你也会看到近期的Next.13的更新主题是更快的构建速度😄。

JS社区在竭力改善开发者生产app的体验,提供更好地构建工具和子任务运行来让构建无痛也是必要的。

那有没有其他方式呢?

Non-building with Deno and Fresh

上述的构建流程都归结到一个简单问题:NodeJs与浏览器侧的Js还是有差异存在的。但是如果我们能写出浏览器兼容的Js呢,这些Js通过使用web APIS和本地ESM module等。

这就是Deno,Deno采用了web JS近年来大幅改进的方法,现在是一种非常强大的脚本语言。我们应该使用它。

Fresh是一个基于Deno构建的web框架,它没有构建步骤——没有bundling,没有转化——这是经过设计的。当请求进入服务器时,Fresh会动态渲染每个页面,并只发送HTML(除非涉及Island,否则也只发送所需数量的JavaScript)。

即时构建而非bundler

缺少bundler是因为这个的存在:及时构建。使用Fresh渲染页面就像加载普通网页一样。因为所有导入都是URL,所以加载的页面调用URL来加载它所需的其他代码(从源代码,或者从缓存(如果以前使用过))。

通过Fresh,当一个用户点击post页面,/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 map中定义了一些标记符,所以当解析这些imports后:

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";

我们从自己的模块Post.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调用代码。

实时转化

Fresh也不需要任何单独的transfile步骤,因为这一切都是应要求及时发生的:

  • 使TypeScript和TSX在浏览器中工作:Deno运行时可根据请求及时开箱即用地转化TypeScript和TSX。
  • 服务器端渲染:通过模板传递动态数据以生成HTML也会根据请求进行。
  • 通过孤岛编写客户端TypeScript:客户端TypeScript按需转换为JavaScript,这是必要的,因为浏览器不理解TypeScript

为了让Fresh应用程序更具性能,所有客户端JavaScript/TypeScript都会在第一次请求后缓存,以便后续快速检索。

更好地编码,更快地编码

只要开发人员不编写原始HTML、JS和CSS,并且需要为最终用户的性能优化资源,就不可避免地会有某种“构建”步骤。该步骤是一个单独的步骤,需要几分钟时间并在CI/CD中执行,还是在请求发生时及时执行,这取决于您选择的框架或堆栈。

但去掉构建步骤意味着你可以更快地行动,更有效率。保持流畅状态更长时间。在对代码进行更改时,不再有剑拔弩张(抱歉)或上下文切换。

您还可以更快地进行部署。由于没有构建步骤,尤其是在使用Deno Deploy的v8隔离云时,您的全局部署只需几秒钟,因为它只需上传几kb的JavaScript。

您也在编写更好的代码,拥有更好的开发人员体验。您可以编写web标准JavaScript,学习可以与任何云原语重用的API,而不是在尝试通过bundler连接Node、ESM和浏览器兼容的JavaScript时学习Node或供应商特定的API。