JavaScript vs JavaScript。战斗!

2025-05-28

JavaScript vs JavaScript。战斗!

在软件开发中,我们经常会遇到一些看似可以完成相同功能的库和工具。每个库和工具都有各自的优势,我们会尝试权衡它们的利弊。

有时,差异化因素与我们要实现的目标无关,而与我们如何实现目标息息相关。在这种情况下,权衡利弊并不总是那么清晰。这些因素真的重要吗?

这里没有规则。我经常参与这类讨论,所以想分享一些关于 JavaScript Web 开发的内容。


1. MPA 与 SPA

替代文本

单页应用与多页应用的区分,是我迄今为止在网络上遇到的最令人困惑的问题之一。构建网站和应用的模式多种多样,以至于人们甚至很难理解这些术语的含义。

抛开历史因素,区分现代 SPA 和 MPA 最简单的方法就是 JavaScript 入口点的概念。如果所有页面的入口点都相同,那么就是 SPA。如果每个页面都有自己的最顶层入口,那么就是 MPA。

你的打包器可能会为每个页面生成不同的代码块,但如果你的应用程序无论在哪个页面都从同一点启动,那么你就拥有了一个 SPA。你可以将其预渲染成 1000 个不同的页面。你可以为每个页面预加载相应的代码块。你甚至可以关闭客户端路由。从架构上来说,它仍然是一个 SPA。单个应用程序的执行定义了所有页面的行为。

接下来GatsbyNuxtSvelteKit,凡是你能想到的都属于这一类。单页应用架构同样适用于服务端渲染页面和静态渲染页面。

那么什么是 MPA?MPA 是一个从上到下逐页编写的网站或应用程序。您可以在不同页面使用相同的组件,但入口点并非唯一。当服务器收到请求并服务于该页面时,JavaScript 执行入口是该页面独有的。

这意味着您的路由是在服务器端进行的。虽然从顶层架构角度来看,SPA 可以选择这种方式,但 MPA 必须以这种方式运行,因为它不需要立即或延迟加载代码来渲染除自身之外的任何页面。有些权衡本身就值得写一篇文章来阐述。但简而言之,MPA 不需要在浏览器中重新渲染,因此可以进行优化,以显著减少 JavaScript 的发送量。

在 JavaScript 领域,只有少数框架针对此场景进行了优化。Marko就是其中之一。最近,我们看到 Astro 和 Elder 等框架现有SPA 框架提供了包装器。尽管这些框架目前仅支持静态渲染。而Angular 创建者推出的新框架Qwik也有望解答这个问题。

值得一提的是,MPA 本身就是一个页​​面,因此它们始终可以在给定页面上托管 SPA。并且通过 iFrames 或其他 HTML 注入框架(例如Turbo),可以在单个页面上提供 MPA。

关键在于,SPA 与 MPA 的区别并不在于你服务的页面数量。这取决于初始加载性能(MPA)与未来导航体验(SPA)的重要性。两种方法都有改进其弱点的工具,但从根本上来说,每种方法都是根据其主要用途进行优化的。


2. React 与 Reactivity

替代文本

你可能在某个地方听说过React不是响应式的。也许有人觉得这是个笑话。你知道,React 的本质在于“响应式”这个词。也许你读过一篇博客文章,探讨了基于推送的可观察对象与调度的基础知识。也许你看到过某个框架宣传自己是“真正的响应式”或“纯响应式”,以此来区别于 React。

事情是这样的。人们曾多次尝试正式定义响应式编程的含义。有些尝试的范围比其他尝试更狭窄。以至于即使在响应式编程的圈子里,我们也需要区分“函数式响应式编程”和“函数式 + 响应式编程”。(来源)

所有这些解决方案的共同点在于,它们都是基于声明式数据的系统。你以一种用固定关系描述状态的风格进行编码。你可以将其想象成电子表格中的方程式。这样可以保证无论发生什么变化,一切都能保持最新。

如果这听起来和你曾经接触过的任何 Web UI 开发都差不多,那是有原因的。HTML 是声明式的,我们构建于其之上。就框架而言,响应式意味着很多事情。

有些人认为这意味着你可以控制原语来连接行为,但很难不以这种方式考虑React Hooks。

有些人认为这意味着更新会自动发生而无需调用更新函数,但像Svelte这样的库实际上会在后台调用组件更新函数。

有些人认为这意味着无需差异化即可进行细粒度更新。但实际上每个框架都会有差异化(详见下文)。或者,这意味着我们移除了调度机制,但几乎所有框架都会批量更改,并将它们安排在下一个微任务中。

因此,React 可能不是形式化的反应式编程,但对于所有有效的目的而言,同样的事情正在以几乎相同的方式完成,这也许令人惊讶。

3. VDOM 与无 VDOM

替代文本

旧的东西会变成新的吗?嗯,某种程度上是这样。JavaScript 框架中的所有渲染都归结于了解哪些内容发生了变化,并相应地更新 DOM。熟悉 DOM API 后,更新部分可以非常有效地完成。所有框架都可以使用这些工具。但是,如何了解哪些内容发生了变化呢?

信不信由你,大多数框架的这个过程都差不多。原因是从 DOM 读取值并非没有后果。在最坏的情况下,它甚至可能导致过早的布局计算/回流。那么我们该怎么做呢?我们将值存储在 DOM 之外并进行比较。如果发生了变化,则应用更新。否则,则不应用更新。所有库(无论是否使用 VDOM)都是如此。

但我们如何做到这一点,才是差异所在。解决方案主要体现在两个方面:

  1. 变化的粒度——为了响应用户的变化,我们需要重新运行多少次
  2. 我们要区分什么——数据、VDOM 抽象

对于像 React 这样的 VDOM 库,更改的粒度是每个组件。首次运行时,您提供的用于渲染函数或函数组件的代码将执行并返回一堆虚拟节点。然后,协调器会基于这些虚拟节点创建 DOM 节点。在后续运行时,新的虚拟节点与之前的虚拟节点不同,并且对现有 DOM 节点的更新将被修补。

对于非 VDOM 库(例如Svelte),更改的粒度也是按组件进行的。这次,编译器拆分了创建和更新路径。首次运行时,创建路径会创建 DOM 节点并初始化本地存储的状态。后续运行时,它会调用更新路径,比较状态值并在适用的情况下对 DOM 进行修补。

如果这些过程听起来非常相似,那是因为它们确实如此。最大的区别在于,VDOM 具有用于差异比较的中间格式,而不仅仅是简单的本地作用域对象,并且Svelte的编译器只编译所需的检查。它可以判断哪些属性发生了变化,或者子组件插入到哪个位置。

其他框架(如 Tagged Template Literal uhtmlLit)不使用编译器,但仍会像 Svelte 那样在一次传递中进行差异比较,而 React 则采用两次传递方法。

这些遍历并不需要很高的开销。你可以像我们在InfernoVue中看到的那样,使用编译机制对 VDOM 库进行类似的优化。这样,它们就可以避免重新创建 VNode,就像非 VDOM 库避免不必要的 DOM 节点创建一样。无论是 VDOM 节点、数据对象,还是通过响应式计算,这一切都与记忆化有关。

那么,有意义的区别是什么呢?差别不大。差异化操作本身成本不高。我们唯一需要考虑的就是变更的粒度。如果理想的差异化操作和更新操作的成本差不多,我们能做的就是减少差异化操作。但粒度化通常会带来更高的创建成本。值得庆幸的是,编译器还有很多优化空间可以用于解决这些创建成本问题。


4. JSX 与模板 DSL

替代文本

这可能看起来与上一个比较类似,而且肯定是相关的。有些人将 JSX 视为 JavaScript 中的 HTML,将模板 DSL 或单文件组件 (SFC) 视为 HTML 中的 JS。但事实上,这些都只是 JavaScript。最终的输出是 JavaScript,其中可能包含一些字符串 HTML。

那么,如果输出结果大致相同,它们之间又有何不同呢?嗯,它们的差异正在变得越来越小。像Svelte这样的框架可以在其 Script 标签和模板表达式中完全访问 JavaScript。而 JSX 虽然是动态的,但仍然具有可以静态分析和优化的连续代码块。

那么区别在哪里呢?主要在于可以插入的内容。属性很容易分析和优化,但 JSX 中标签之间的内容可能有很多。可以是文本,可以是 DOM 元素,可以是组件,也可以是控制流。但最终,它要么是文本,要么是 DOM 元素。

因此,模板 DSL 可以减少一些对传入内容的猜测,否则每次都需要检查。但这并没有带来很大的节省。即使使用 JSX,你仍然可以查看 JavaScript 表达式的作用。SolidJS 使用启发式方法来判断某些内容是否可以响应。

模板 DSL 最大的好处在于其控制流的显式语法,使其更容易针对不同目标进行优化。例如,循环for比 更优map。如果您在服务器端渲染时只是创建一个巨大的 HTML 字符串,那么像这样小的改动就能显著提升性能。但这只是一个简单的抽象。

但除了这些场景之外,实际上并没有什么根本区别。当然,大多数模板 DSL 没有与 React 的 Render Props 等效的功能,但它们可以。Marko就有


5. 运行时与编译时的反应性

替代文本

这个问题可能比较小众,但我经常被问到这个问题。有什么区别?

这归结于依赖项跟踪。像SolidMobXVue这样的运行时响应式系统会在计算运行时收集依赖项。它们拦截响应式原子(信号、引用、可观察对象)的读取,并将包装作用域订阅到它们。这样,当这些原子更新时,它们就可以重新运行这些计算。

关键在于,由于依赖关系图是动态构建的,因此它们是动态的。它们可以在每次运行中发生变化,因此您需要管理一组依赖关系。每次运行都可能意味着新增订阅并发布其他订阅。

编译时会提前计算出依赖关系。这样一来,由于依赖关系是固定的,因此无需管理订阅。只要依赖关系发生变化,代码就会运行。这大大减少了运行时的开销。甚至意味着无需运行计算即可了解其依赖关系。

然而,这些依赖关系并非动态的,因此总是存在过度订阅和过度执行的可能性。运行时可以从深层调用栈中拉取响应性的情况会变得更加困难,因为如果不跟踪其绑定,就无法知道某个函数是否是响应性的。

不过,这两种方式都适用。如果你把 setTimeout 放在 effect 中,由于运行时响应性,它在执行时不会在作用域内。在编译时,如果它在函数内部,则很容易注册依赖项。更新值时也有类似的考虑。Svelte查找赋值运算符,这就是为什么它list.push不能正常工作的原因。

编译器有很多功能,有些功能比其他功能更容易实现。最终,就原始性能而言,这基本上没什么用。但是,当你能够利用特定语法更好地传达意图时,编译器可以带来很多其他好处。这是比模板 DSL 更合理的下一步,我认为我们只是触及了皮毛。


6. 组件与 Web 组件

替代文本

首先我想说,如果这里有一个要点,那就是不要假设“组件”这个词对每个人来说都意味着同样的东西。

我对此两种观点都经历过。我在之前工作的初创公司使用 Web 组件在生产环境中工作了六年,也曾开发和编写过组件框架。对我来说,当人们比较这些时,他们谈论的是截然不同的事情。

从任何意义上来说,Web 组件都本质上是一个自定义元素。它是一个 DOM 节点,用通用接口封装了行为。我们可以通过属性和便捷的钩子,在创建、附加和移除 DOM 时编写自定义行为。后两个生命周期非常重要,因为它们是互补的。这意味着大多数情况下,所有副作用都与 DOM 连接性息息相关。

什么是框架组件?套用 Rich Harris 曾经说过的一句话,它们是组织我们思维的工具。这听起来很抽象,但关键就在于此。当你观察框架中的组件时,它们都是抽象的。它们可能会输出 DOM 元素,也可能不会。它们的生命周期与 DOM 无关。有些组件管理状态,它们会在服务器或移动设备上渲染。它们是框架需要的任何组件。

第一个是互操作性的故事,第二个是组织性的故事。这些目标一致吗?在某种程度上。但它们都不会在主要目的上妥协。因此,它们注定要各自为政。我的意思是,你可以为 Web 组件添加更多类似框架的行为,但这样一来,你自己就变成了一个框架,不再是标准。但一旦你将解决方案进一步延伸,比如服务器端渲染 (SSR),你就开辟了新的领域。

这可以说是新标准的基础,但我认为,标准制定并非为浏览器构建框架。观点会变,技术也会发展。在 Web 领域,DOM 或许是永恒的,但我们组织应用程序的方式却并非如此。

在框架组件方面,不乏进一步抽象的动力。事实上,一个特定的解决方案总是可以更好地适应问题。因此,React 组件在 React 应用中的表现总是比 Web 组件更好。任何框架都是如此。

人们说要重新发明轮子,这话没错,但这到底有多重要?在 JavaScript 框架这个培养皿里,不断的重复发明是进化的催化剂。理想情况下,我们谈论的是同一枚硬币的两面。但在实践中,这其中可能会存在更多摩擦。

这并不是“使用平台”还是“不使用平台”的争论。只要我们清楚地理解了平台之间的差异,Web 组件就能发挥作用。每个框架都使用平台。有些框架只是比其他框架更好而已。而有些框架甚至比 Web 组件更好。毕竟,Web 组件只是自定义元素而已。有时,增加 DOM 节点并不能解决问题。


深入探讨这些年来我收集到的一些观点,这很有趣。如果你有任何类似的比较想法,请在评论区告诉我。

文章来源:https://dev.to/this-is-learning/javascript-vs-javascript-fight-53fa
PREV
JavaScript vs JavaScript:第二轮。战斗!
NEXT
JavaScript 框架 - 迈向 2025 年