likes
comments
collection
share

JavaScript 框架历史(下)

作者站长头像
站长
· 阅读数 5

编译型框架的崛起

现今,大多数前端应用在开发时都会依赖 Node.js 并且还会有构建过程。构建过程会转换并缩小化如 JS 或 CSS 代码,形成优化的生产架构。因此,在使用 JavaScript 时需要注意很多事情,而且随着应用程序的增长,很难仅靠人工完成所有工作。所以就需要依赖于像 打包器(bundlers)插件(plugins)转译器(transilers) 等等这样的东西。在本节中,我们将研究打包和所谓的 编译型框架

打包器大战

做为 JavaScript 开发者,你可能遇到多种包格式。出现这种情况的原因是 JavaScript 起初并没有内置的模块系统。结果就是我们现在会看到各种关于 AMD, CommonJS, UMD, ES6 的代码和文章。现在让我们退一步来理解为什么会这样,以及它们与我们构建应用程序有何关联。

在模块系统出现前,我们使用全局的 scripts

<script src="moduleA.js"></script>
<script src="moduleB.js"></script>

通常我们会使用立即执行函数来确保文件中的变量不会造成全局污染。要注意,此时还没有 importexport。使用函数作用域是个很聪明办法。通过使用自执行函数,我们就能获得一个私有作用域,并且可以只导出我们想要导出的内容。这种方法叫做 暴露模块模式(revealing module pattern)

// moduleB.js
var moduleB = (function () {
  // globally importing "func" from moduleA
  var importedFunction = moduleA.func;

  var privateVariable = "secret";

  function getPrivateVariable() {
    return privateVariable;
  }

  return {
    getPrivateVariable: getPrivateVariable,
  };
})();

直接在全局作用域执行并不好,它需要我们开发者确保每个代码文件都是按正确的顺序加载的。而开发人员也等不了内置的模块系统,在语言中创建了自己的模块系统。现在,我们有多种不同的模块格式:

  • AMD: Asynchronous Module Definition 的缩写,设计用于浏览器中,异步加载模块
  • CommonJS: 缩写为 CJS,设计用于服务端(Node 默认支持),同步加载模块
  • UMD: Universal Module Definition 的缩写。名称源于它可以同时支持前端和后端
  • ES6 modules: ES6 最终实现了 JS 的内置模块系统,提供了 import 和 export 语法。比起 AMD 或 CJS,新 JavaScript 开发者肯定更熟悉这种

早期,我们依赖于第三方 模块加载器,如 RequireJS 用来在浏览器中正确加载 AMD 模块。模块加载器会动态的加载运行时需要的模块。你可以将模块加载程序视为一种垫片代码(polyfill),因为浏览器缺乏 ES 模块的原生支持。它还有助于以正确的顺序加载模块,尤其是在我们将模块分离成文件时。特别是每个模块中有一个文件,这样一来就会有数百个文件。请求这所有的文件听起来就会导致一个巨大的性能问题。然而,这就是 HTTP/2.0 和模块加载器非常适合的原因。有了 HTTP/2.0 的多路复用,模块加载器可以按需请求文件。

后来, 模块打包器 受到关注。它的核心是 模块打包器将所有文件打包成一个单一的 JavaScript 文件(bundle) 。这个包(bundle)包含我们的模块及其相应的依赖项。打包允许开发人员在开发期间处理多个文件,然后将它们打包在一起,并针对浏览器进行适配和优化。因此,开发人员可以在开发过程中使用不同的模块格式和能编译到 JS 的语言,而不需要太多的人工。这项工作是由打包器利用 加载器(loader) 充分地转换单个文件而实现的。

Browserify 打包器的发布带来了更多可能性,浏览器与服务器之间的代码分享变得更加方便。我们现在可以用 CommonJS 格式写浏览器中的代码。甚至是前端大多都使用 npm 这个包管理工具了。列举几个构建工具:

  • Browserify
  • Webpack
  • Rollup
  • Parcel
  • Snowpack
  • Rome
  • esbuild
  • Vite

基于 JavaScript 的工具,像 Webpack 是 2012 出现的,提升了开发体验,同时还有 Rollup、Parcel 等打包器。这些打包工具经大大取代了模块加载器。我们甚至可以使用 import(),高级的打包工具还提供了 代码分割,把代码分割成小份并且只在需要时再加载。它们还可以做 摇树(tree shaking),就是可以移除死代码以减少产物大小。

但是,当打包或最小化代码的时候,你可能要面对极慢的构建。原因之一就是,大多数打包工具都是用的 JavaScript 编写的。这些基于 JavaScript 的工具的性能并不令人满意,导致我们的构建时间会随着程序的增长而变慢。这似乎引发了一场打包大战,大多数打包工具都会与其他工具进行速度比较。

Figma 创始人 Evan Wallace,2020 年发布了 esbuild,他的愿景是“带来构建工具性能的新时代”。esbuild 相比其他常见的构建工具快 10-100 倍,因为 Evan 编写工具的时候就考虑了构建时间,并使用了 Go 编程语言。现在,平台、框架和打包工具都在投入更多努力来减少构建时间。 例如,Rome 于 2021 年 8 月 3 日在 Twitter 帐户上宣布,他们正在用 Rust 重写 Rome。 此外,DongYoon Kang 于 2019 年发布了 Speedy Web Compiler (SWC)。

看起来未来的 JavaScript 的工具不会再使用 JavaScript 写了,这就让我们进入下一节。

编译器是新框架吗?

HTTP 档案的网络状态报告中,可以看到页面的平均大小在 2.3MB(桌面版)和 2MB(移动端) 。我们确信是框架增加了传输体积。换言之,许多网络应用正在遭受 JavaScript 膨胀(JavaScript bloat)。很必要了解 JavaScript 的昂贵代价,浏览器在接受到字节后处理并没有结束,代码还需要解析和执行。

我们可以从 JS 框架基准(js-framework-benchmark) 了解到这会对我们的网站产生什么影响:

JavaScript 框架历史(下)

根据 Bundlephobia(一个分析 JS 包体积的网站),react@18.2.0 (6.4KB) + react-dom@18.2.0 (130.5KB) = 136.9KB。这意味着你在写代码前,已经包含了许多的 JavaScript 代码。而像 Preact 库,一个兼容 React 的轻量的库,只有 10.3KB。所以我们可以选择减少字节。然而,引出了另一个问题,有没有可能鱼和熊掌兼得?在编译器的帮助下,它将成为未来的新标准。例如,Ember 的联合创始人 Tom Dale 在 2017 年发表了一篇名为《编译器是新的框架》的文章。简言之,Dale 写到我们如何将 Web 框架视为运行时库的,而它们将来会成为编译器。

2016 年,Ractive.js 的作者 Rich Harris 发布了 Svelte。Svelte 是一个编译器,它被标榜为“消失的框架”。Svelte 会在构建时把你的项目编译成原生 JavaScript,而不是在浏览器中运行时工作。此外,作为一个编译器,Svelte 会在编译过程中将代码打包成 HTML、 CSS 和 JS,并针对浏览器进行了优化。它在坚持使用普通的 HTML、 CSS 和 JS 的同时完成所有这些工作,同时增强它们以获得更好的开发人员体验。

Svelte 并没有完全消失,因为仍然需要库。应用程序使用的 Svelte 函数将被放入到打包产物中。也就是说,代码量很小。

在「重新思考响应式」的谈话中,Rich Harris 说到,框架是你组织想法的工具,不是浏览器里运行的代码。框架应该是在你构建时的东西。Dale 和 Harris 都谈到的思想转变,向我们展示了一个新框架就等同于编译器的未来。

作为一个编译器,Svelte 不像其他大多数框架那样受到 JavaScript 限制。它将响应式移到实际的语言中,而像 React 这样的库需要我们使用一些 API 如 setState。举例,你可以通过增加 $ 来使用 响应式声明,这表示当任何被引用的值发生变化时,重新执行这段代码。

<script>
  let number = 0;
  $: squaredNumber = number * number;

  function incrementNumber() {
    number += 1;
  }
</script>

<button on:click="{incrementNumber}">Increment number</button>

<p>{number} squared is {squaredNumber}</p>

现在,Svelte 看起来非常像一种语言ーー这因为它确实是一种语言。Harris 在 Svelte 3 发布前就意识到了这一点。

所以,web 框架的未来是语言吗?

这或许看起来很奇怪,框架最后变成了语言,但是我们不是已经这么做了吗,想想,转译(transpilation)、polyfilling、JSX、缩小化、模板语言、还有打包工具。除非你还使用传统方式,那你可能使用过这些工具。另外,大多数框架已经依赖一些编译、构建的步骤。由于这些原因,像 Svelte 这样的编译器似乎是 Web 框架发展过程中非常自然的一步。

Svelte 已经做出了自己的贡献,值得赞扬,但事实证明,创建一种用于描述响应式用户界面的语言并不是什么新鲜事。 看看 Elm (2012)Marko (2014), or Imba (2015)。尽管它们的确与众不同,也有自己的卖点。但是,我们不会深入研究这个问题,因为主要的问题是,我们现在更加依赖于编译器/构建器

框架的框架

多数框架像 React、Vue 依赖于 客户端渲染(CSR, Client-Side Rendering),这就需要在浏览器中渲染应用。路由、模板、数据获取,所有逻辑都在客户端处理。这样的最大的一个缺点是——SEO。这迫使 爬虫 要执行 JavaScript 才能拿到内容,而它们往往会跳过这个步骤。另外,由于我们可能更改内容而不更新 URL,爬虫程序更难找到和索引内容。

当然,我们希望我们的网站在 Google 这样的搜索引擎上排名靠前,所以出现了一些框架,允许我们在轻松获得 服务端渲染(SSR)静态站点生成(SSG) 的好处的同时,依然使用 SPA 框架。比如 React 的 Next.js,Vue 的 Nuxt.js,Svelte 的 Sapper ,还有 Angular 的 Angular Universal 。在 SPA 上下文中,SSR 需要从相应的 SPA 获取客户端模板,并在服务器端呈现这些模板。SSR 在服务器上生成 HTML,并根据请求向浏览器响应 HTML。通过在服务器上将所需的组件从 SPA 呈现到 HTML,搜索引擎爬虫将立即看到完全呈现的页面。

然后,我们也有预渲染,预渲染的意思就是在应用构建时将初始状态渲染成静态的 HTML。这已经成为了近年来构建网站常用的方法了。这就是软件架构和 “JAMstack” 理念发挥作用的地方——它以预渲染作为其核心原则之一。JAMstack 中的 JAM 表示 JavaScript、API、和 Markup。

包含静态站点生成(SSG)的生态使构建 JAMstack 网站变得很容易,例如 Next.js, Nuxt.js, Sapper 以及 Angular Universal,还有 Gatsby(React),Gridsome(Vue), SvelteKit(Sapper 的继承者)。另外,随着 headless CMS(无头内容管理系统) 的出现,我们也可以避免将 CMS 耦合到我们的项目中,而是通过 API 公开它。一些案例如:Ghost, Strapi, Metlify CMS。当站点变化不大时,使用 SSG 是有意义的,因此可以从数据库、无头 CMS 或构建时的文件系统获取数据来存储静态输出。

这些类型的框架通常称为“元框架”,它们利用所谓的“组件框架”,例如 React 或 Svelte。 有些提供 SSR 或 SSG,有些提供两者,并且它们不断改进并提供新的解决方案。 因此,仅客户端已经不够了,未来是 混合 的,根据 Redux 的创建者、React 核心开发者 Dan Abramov 的说法。 在 JavaScript 的背景下,我们已经进入了一个时代,我们的框架已经成为利用组件框架的全栈框架。

理解水合

这些元框架在解决 SEO 问题时是使用 SSR 或 SSG 而不是依赖 CSR。这样,爬虫不必执行 JavaScript 就能获取到内容。但是,有一部分我们还没有提到。 我们必须以某种方式将 JavaScript 与发送到客户端的 HTML 关联起来。因此这就是「水合」发挥作用的时候了。

水合可以通过以下方式向服务端渲染出的 HTML 中增加交互

  1. 向 DOM 节点附加监听器,以获得交互
  2. 构建框架组件的内部状态

The peril of this approach is that the components can only be hydrated once the entire JavaScript file containing the app has been loaded, parsed, and executed. As a result, the user might see a page that looks interactive but is not until 1-2s (or even longer). So, our users receive the static HTML that is not yet hydrated and therefore see a page that deceptively looks interactive. Thus, they get an extremely frustrating experience when trying to interact with a frozen UI. This phase gets referred to as the uncanny valley.

这种方法的缺点在于,组件只有在包含应用程序的整个 JavaScript 文件被加载、解析和执行之后才能被水合。因此,用户可能会看到一个看起来是交互式的页面,但是直到1-2秒(甚至更长)之后才能交互。因此,我们的用户收到的静态 HTML 尚未水合,因此看到的页面看起来具有欺骗性的交互。因此,当他们试图与一个冻结的用户界面进行交互时,会得到一种非常令人沮丧的体验。这个阶段被称为“恐怖谷”。

事实证明,也许水合作用纯粹是开销 ,可能不是最可行的解决方案。例如一个常见的水合问题是,服务器在构建/渲染应用程序时收集有关该应用程序的信息ーー但不将其保存以供客户端重用。因此,客户端将不得不再次重新构建应用程序。

结果,所有这些问题会影响我们的 TTI(Time to Interactive, 可交互时间)。我们也看到,开发者已经开始解决这个问题了。

框架解决水合问题

缓解水合问题的技术和框架已经出现。因此,在继续我们的框架之旅之前,我们必须在基础层次上理解其中的一些技术。要注意的是,这还是写作时的一个话题,一些术语可能会被解释的有点模糊,因为它们在一些文章中被用作同义词:

  • 标准水合(Standard hydration):即 预加载全量水合
  • 渐进式水合(Progressive hydration):即 渐进式地 加载代码以及水合部分页面,以便在需要时基于事件或交互(如可视情况或点击)使页面可交互。这个方法需要将你的应用预先分割成小块才能获得这个好处。有个问题可能会出现,许多框架需要装载,即无论如何都需要加载整个应用,这个延迟就会导致更糟糕的体验。
  • 部分水合(Partial hydration):即通过服务器分析哪些部分几乎没有交互性或状态,然后只发送必要的代码。比如说一个组件只需要渲染一次,那就只发送这个部分的静态 HTML。如果是可交互的,然后也会发送称作「岛」的交互 UI 组件。
  • 选择性水合(Selective hydration, React 18):即在所有的 HTML 和 JavaScript 完全下载完之前开始水合,同时优先水合对用户有影响的组件。
  • 岛屿架构:经常被认为和部分水合是同一件事,但其实应该是使用了部分水合的一种实现方法。我们可以利用“岛屿”加载少量的 JavaScript 就能提供静态 HTML 交互性,同时也支持按需地独立获取。
  • 可恢复性(Resumability):即不在浏览器中重复执行任何已在服务端完成的工作。

希望这个列表能提供更清晰的信息。我已经看到了一些使用部分水合的新框架,它们在描述部分水合作时写到 “就本文而言,让我们假设部分水合作用是 X”。这里我并不是要批评,目的只是要说明术语 岛屿架构部分水合渐进式水合 是相对较新的,是经常在文章中出现的同义词。不然在讨论这些方法时就会变得很混乱。

抛开这些术语,它们的目标(岛屿架构、部分和渐进式)本质上是将我们的应用程序分解成块,这样我们就可以根据实际需要(如可见时,一次点击)更智能地加载部分。但是,它们仍然不是一回事,可以从不同的角度独立地探讨。因此,框架有独特的方法来解决标准水合作用的问题,我们将在本章中讨论。

岛屿架构与微前端

岛屿架构实质是在 SSR 而非 CSR 时利用部分水合将一个应用分割成几可以渐进式地加载/水合的可以交互的部分。Katie Sylor-Miller 和 Jason Miller (Preact的创造者) 在一次会议上提出了 “组件岛屿” 模式,Jason Miller 后来又写了文章 “岛屿架构(Islands Architecture)” 描述这种方法,反过来又普及了这个术语。将一个页面解构成多个入口并不是一个新的主意,已经存在有类似的方法了,例如微前端(Micro Frontends)。

根据 “微前端行动” 的作者 Michael Geers ,微前端这个术语(不是这个主意)在 2016 年底由 ThoughtWorks 团队提出。另外,在技术上说,当我们把应用看做由不同独立团队负责的功能组成的,微前端就是微服务的一个延续。下面列举了微前端的特点:

  • 技术无关: 团队可以独立的选择和升级他们的技术栈。
  • 代码隔离: 独立构建,应用间不共享状态或者变量。
  • 使用前缀: 不能隔离的东西可以利用命名约定。
  • 首选原生浏览器特性: 即使用自定义的元素或自定义的事件。
  • 弹性站点: 即使用渐进式的升级。

岛屿架构和微前端都主张讲 UI 解构成独立的部分。基础的差异在于,岛屿架构很大程度上是依赖服务器渲染静态 HTML 区域,如果需要,随后会在客户端进行水合。并且通过将内容分成两种类型:

  • 静态内容:无交互、无状态,所以不需要水合
  • 动态内容:可交互或含状态,所以需要水合

总而言之,岛屿架构利用 “岛屿” 使得我们加载少量 JavaScript 就能使部分静态 HTML 具有交互性,同时也支持按需独立获取。

岛屿框架

现在我们将研究 Astro 和 Fresh,它们都使用岛屿架构和自己的方法。

2021 年 6 月,Astro 发布了 beta 版本。Astro 是一个使用了部分水合的静态站点生成器(SSG)。它有一个固定的文件结构,因此 Astro 可以用基于文件的路由,在 /src/pages/ 文件夹生成链接(就像 Next.js)。另外它的一个特点就是它是框架无关的,即你可以使用自己的框架如 React、Preact、Svelte、Vue 等等。

为了实现能使用自己的框架,Astro 包含两种类型的组件。一个是 Astro Components(.astro) 用于构建 Astro 项目的块。另一个是 前端组件,允许你在 Astro 组件中使用其他框架的组件。

---
// src/pages/MultipleFrameworksWithAstroPage.astro
import SvelteComponent from '../components/SvelteComponent.svelte';
import ReactComponent from '../components/ReactComponent.tsx';
import VueComponent from '../components/VueComponent.vue';
---
<div>
  <SvelteComponent />
  <ReactComponent />
  <VueComponent client:visible/>
</div>

认识到框架组件可能需要在其组件代码之上从框架/库获得某种渲染器,这一点很重要。然后,你还得下载这个。因此,上面的页面可能需要从三个不同的框架下载代码。也就是说,你可以使用 客户端指令(Client Directives) 来控制框架组件是如何水合的。指令的一个例子是上面的 client:visible ,它确保当用户将组件滚动到视图中时加载 JS。尽管如此,你还是应该小心 微前端无序(Micro frontend anarchy) 。最后,你可以通过使用适配器来启用 Astro 的 SSR,而 SSG 是开箱即用的。

2022 年 6 月 28,Fresh  发布了。Fresh 一个使用了 Preact、JSX、以及 Deno 运行时的全栈框架。Fresh 通过使用 SSR 渲染全部页面,同时允许你使用 Preact 组件 开启客户端交互,实现的岛屿架构。当然,就像 Astro,一个 Fresh 项目也有约定的目录结构,如 /static, /islands, /routes

/routes 文件夹支持 文件系统路由动态路由 (像 /routes/posts/[name].tsx)。另外 /routes 文件夹可以有一个处理者和一个组件,处理者中可以调用 API,可以返回如 JSON 数据,或者再渲染 JSX 前获取数据。下面是一个简单的例子,获取数据并把数据通过函数 ctx.render 作为 props.data 传给组件,这个组件就是路由 /contact 对应渲染的组件。

// /routes/contact.tsx

/** @jsx h */
import { h } from "preact";
import { Handlers, PageProps } from "$fresh/server.ts";

interface ContactDetail {
  name: string;
  phone: string;
}

export const handler: Handlers<ContactDetail[] | null> = {
  async GET(_, ctx) {
    const res = await fetch("https://example.com/contacts");
    const data = await res.json();
    return ctx.render(data);
  },
};

export default function ContactPage({
  data,
}: PageProps<ContactDetail[] | null>) {
  if (!data) {
    return <h1>Contacts not found</h1>;
  }

  return (
    <main>
      <h1>Contact</h1>
      <p>{JSON.stringify(data)}</p>
    </main>
  );
}

然后,为了给页面增加交互,我们可以在 /island 文件夹下创建一个 Preact 组件,并在 /routes/contact.tsx 中引入。

Fresh 也非常重视渐进增强,它的表单提交基础设施是围绕着原生的 <form> 元素构建的。如果需要,可以通过使用岛屿使表单更具交互性。最后,Fresh 没有构建步骤,作为交换,它支持即时部署。但要注意 Fresh 不支持 SSG

可恢复框架 Qwik

“岛屿”并不完美,同时也伴随诸多挑战,比如如何做岛间通信,所以 岛屿架构 也不是答案。或许我们可以在浏览器中恢复执行服务器中断的操作。

Miško Hevery,Angular 创造者之一,带着一个新的被称为 Qwik 的可恢复框架回来了。Qwik 提出的 可恢复性 或许是解决水合问题的答案。 Qwik 可以通过收集 SSR 生成 HTML 过程中的信息,以让客户端恢复执行服务器中断的操作。

为了理解这个过程,让我们看一个简单的流程:

  1. 服务端将监听器、内部数据结构以及状态序列化为 HTML。
  2. 浏览器获取 HTML,其中包含了小于 1KB 并引用了 Qwikloader 的 JS 脚本。
  3. Qwikloader 注册唯一一个全局事件监听器等待代码块下载。

最初,除了微小的 Qwikloader 之外,你不需要解析任何 JavaScript。其原因是通过尽可能延迟 JavaScript 的执行来实现即时的交互状态。因此,Qwik 服务器将事件处理程序序列化到 DOM 中,类似于下面这样:

<button onClickQrl="./chunk.js#handler_symbol">click me</button>
<button onClickQrl="./chunk.js#handler_other_symbol">click me</button>

然后,Qwikloader 只会在你单击它时加载相应事件处理程序的代码。它是在服务器附加到 HTML 元素的 URL 的帮助下完成的。因此,如果你不单击它,它将不会下载不必要的字节。

Qwik 还有更多的东西,比如它使用了预装载和预取。但是,理解 Qwik 的关键点在于服务器将重要信息移动到 HTML 中,以便客户端可以做出更明智的选择。

你需要框架吗 ?

答案是视情况而定。你可能不需要框架或库,但它们可以让你的生活更轻松。没有框架来解决你确切的问题。它们也没有对你和你的项目做出任何承诺。也就是说,框架也是有好处的。但这并不意味着我们需要创建自己的框架。相反,我们应该了解背景和利弊。

问问你自己,什么对你的项目有意义? 下面是一些你可以问自己的问题:

  • 框架是否违反了 WCAG?
  • 搜索引擎优化有多重要?
  • 很容易维护吗?
  • 你有团队吗?
  • 你的团队怎么看?
  • 你的应用程序有多互动?
  • 静态的还是服务端渲染,或者两者都有?
  • 你能为你的业务对象创建一个体系结构边界吗?
  • 你能从中学到什么吗?
  • 框架是否仍然维护?
  • 如何维护框架?

所以,我们不应该问什么 JavaScript 框架是最好的,而应该问什么 JavaScript 框架最适合我们的项目。“工作的正确工具” 的观念在这里真的很有意义。例如,如果你正在构建一个内部使用的应用程序,那么搜索引擎优化可能没有交互性那么重要。而如果是电子商务,那么你可能需要两者。此外,也许你不是在构建自己的项目,你可能正在找工作,在这种情况下,“React” 可能是你最好的选择。

然而,当有这么多工具可供选择时,选择正确的工具可能并不那么容易。我们已经讨论了 jQuery、 Knokout、 Backbone、 AngularJS、 Ember、 Metaor、 React、 Vue、 Angular、 Svelte、 Astro、 Fresh 和 Qwik。然而,我们讨论的可能还没有一半,比如还有 Mithril.js,SolidJS,CanJS,Hyperapp,Scully,Remix,Elder.js,等等。

此外,什么框架好可能是非常主观的,经验丰富的开发人员在论证为什么他们的框架是最好的时候通常很有说服力。他们的说服力和流行语,加上我们对 JavaScript 的厌倦,经常让我们接受他们的说辞。尽管这并不是说他们一定是错误的,他们也可能是正确的。事实上,我们社区中的人们真正关心事物使得我们的经历更加生动和有趣。此外,大量的框架表明,人们愿意做出贡献,创新是活跃的。

你也可以更消极地看待它,并提出可能有意义的论点。本文的目的从来不是要给你一个最终的答案,什么样的框架是最好的,因为到目前为止还没有。在我们的旅程中,愿你了解关于过去和现在的系列知识,更好地探索 JavaScript 框架的海洋。