新时代的 SSR 框架破局者:qwik

引言

今天这篇文章中和大家聊一聊号称世界上第一个 O(1) 的 JavaScript SSR 框架:qwik。

别担心,如果你不是特别了解 SSR 也没关系,文章大概会从以下几个方面作为切入点:

  • ✨ 首先会围绕对比 SSR 与 SPA 各自的优劣势,从而展开 SSR 的运行机制以及 SSR 相较于 SPA 究竟为了解决什么问题。

  • ✨ 之后,会根据 NextJs 的运行机制思考针对目前主流 SSR 框架设计思路上存在的不足从而引出 qwik 为何会在众多成熟框架中脱颖而出。

  • ✨ 最后,笔者会针对于 qwik 提出自己的看法以及聊聊 qwik 存在的”问题“。

诸如社区内部 SSR 框架其实已经产生了非常优秀的作品,比如大名鼎鼎的 NextJS 以及新兴势力代表的 Remix 和 isLands 架构的 Astro、Fresh 等等优秀框架。

为何 qwik 可以在众多老牌优秀框架中脱颖而出。接下来,让我们一起来一探究竟吧。

SSR & CSR

目前业内存在非常多基于 SSR 的优秀框架,比如 Next、Remix、Nuxt 等等。

针对于 Qwik 我们先来聊聊基于 Next 体系的传统 SSR 方案。

Client Side Rendering

在开始 SSR 之前我们先来聊聊它的对立面,所谓的 CSR(Client Side Rendering)。

服务器端渲染 (SSR) 是一种在服务器中进行渲染 HTML 而不是由浏览器中执行 JS 获得网页(SPA)的技术。

目前国内社区中主流框架比如 VueJs、React 等严格意义上来说都是基于 CSR(Client Side Rendering) 的产物。

所谓 CSR 的意味着当发出一个请求时,服务器会返回一个空的 HTML 页面以及对应的 JavaScript 脚本。

比如

<html>
<head> 
   <title>携程商旅</title> 
</head> 
 
<body> 
     <div id="root"> </div> 
     <script src="./index.js"> </script> 
 </body>
 </html>

当浏览器下载完成对应的 JS 脚本后才会动态执行对应的 JS 脚本然后在返回的 HTML 页面上进行渲染页面内容。

你可以简单的理解为上述的 ./index.js 会在客户端下载完成后执行该脚本,从而执行 document.getElementById('root').innerHTML = '...' 来进行页面渲染。

这种方式并不是从服务端下发的 HTML 文件来进行渲染页面,相反而是通过浏览器获取到服务端下发 HTML 中的所有的 JS 文件后执行 JS 代码从而在客户端通过脚本进行页面渲染。

以及通常在 SCR 中当我们点击任何页面中的导航链接并不会向服务端发起请求,而是通过下载的 JS 脚本中的路由模块(比如 ReactRouter、VueRouter 这样的模块)重新执行 JS 来处理页面跳转从而进行页面重新渲染。

上面的概念是非常典型的 CSR ,浏览器仅仅接受一个用作网页容器的 HTML 页面,这样的方式通常也被称为单页面应用 (SPA)

优势

那么上述我们提到的 CSR 广泛存在于目前大量页面中,必然存在它自己的优势。

在页面初始化访问后加载速度极快且响应非常迅速。 在页面初始化后,网站所有的 HTML 内容都是在客户端通过执行 JS 生成,并不需要再次请求服务器即可重新渲染 HTML 。

此外,有关任何实时的数据获取都可以通过 AJAX 请求对于页面进行局部更新从而刷新页面。

劣势

可是,CSR 真的有那么完美吗。任何一件技术方案一定存在它的两面性,我们来看看 CSR 方式究竟存在哪些问题:

  1. 初始加载时间长。首次请求完服务器获取到 HTML 页面后,初始化的页面仍然需要在一段时间内处于白屏状态。

    在初始渲染之前,浏览器必须等待 HTML 页面中的所有 Javascript 脚本加载完成并且执行完毕,此时页面才会进行真正的渲染。

当然,使用代码拆分或延迟加载等多种方案可以有效的减少上述的问题。但是这些方式始终是治标不治本,因为它并没有从本质上解决 CSR 存在的问题。

  1. SEO(搜索引擎优化) 的负面影响。

    上边我们提到过,所谓 CSR 本质上首先会返回一个空的 HTML 页面,所以这也就造成了在搜索引擎对于该页面的数据爬取中会认为它是一个空页面。从而影响对应的搜索结果排名。

    虽然说在最新的 Google 中已经可以触发执行 JS 对于网站进行关键字排名,但是在 JS 体积足够大的时候针对于 SEO 仍然是存在一部分问题导致无法解析出正确的关键字匹配。

当然 CSR 还存在一些其他方面的缺点,比如网站强依赖于 JS 当用户仅用 JS 时网站只能是白屏展现给用户等等之类。

Server Side Render

简单聊完客户端渲染后,我们稍微来看看所谓的服务端渲染是什么含义。

基于旧时代的类似 Java 的 JSP 页面我在这里就不赘述了,显然 JSP 的方式每个 HTML 都需要单独请求服务器返回对应的 HTML 内容严格意义上来说这也是 SSR 的方式但是很明显这已经被时代淘汰了。

目前国内各家公司广泛应用的服务端渲染技术大概的思路是这样的(Next 的 SSR 模式也是同样的思路):

当用户首次访问你的应用站点时:

  1. 首先服务器会根据对应的 URL 在服务端根据对应路径渲染对应的 HTML 模版。

    注意这里渲染的 HTML 模版是具有该页面真正的内容。同时它并不具备任何交互逻辑(比如 DOM 元素的点击事件),这是一份完全的静态站点。

  2. 服务器会下发这份仅具有静态内容的 HTML 模版,同时这份模版中也会包含对应的 JavaScript 执行脚本。

    第一时间会展示给用户对应的 HTML 页面,此时对于访问站点的用户来说首屏渲染相较于 SPA 应用来说会非常快。因为它并不需要在客户端浏览器上再次下载和执行 JavaScript 脚本来进行页面渲染。

    其次,针对于 SEO 的优化也会非常良好,因为服务器上下发的 HTML 页面是包含当前站点的真实 HTML 结构,对于搜索引擎的爬虫来说会非常容易的匹配到当前关键字。

  3. 之后,浏览器会下载当前这份 HTML 的 JS 脚本。

    因为首先呈现给用户的一份静态的 HTML 页面,并不具备任何交互效果。我们需要为页面上的元素增加对应交互,HTML 页面中的 JS 脚本中会包含网站的交互逻辑。

  4. 最后,当下载完 HTML 脚本中的 JS 脚本后,自然会执行这些 script 脚本。从而发生一种被称为 # hydrate(水合) 的方式,从而为页面上静态 HTML 元素再次添加对应的事件处理从而保证页面具有交互性。

当 hydration 过程完成后,会由我们的客户端框架接管网站的后续渲染。
在后续的导航链接跳转和页面渲染中和服务器已经没有任何关系了,我们完全可以利用客户端的路由切换(History Api/Hash Api)利用 JS 进行页面渲染从而保证切换页面不用再次请求浏览器保证非常及时的页面交互。

hydration

上述过程中有一个非常重要的关键字 hydration(水合)。

首次访问页面时,页面的静态 HTML 是在服务端生成的。 在服务端我们将生成的静态 HTML 以及 HTML 中携带的 JS 脚本发送到客户端。

此时静态 HTML 会立即显示在用户视野中,然后浏览器会利用网络进程下载当前 HTML 脚本中的 JS 脚本。

当 JS 脚本下载完成后,会立即执行同时发生一种被称为 hydration 的过程。

所谓的 hydration 简单来说,也就是客户端下载完成 JS 脚本后,浏览器会执行下载的 JS 脚本这些脚本中有部分内容会将已经存在的 HTML 内容通过执行下载的 JS 脚本添加上对应的事件监听器 从而保证页面的交互。

注意,在 React、Vue 中 hydration 并不意味这重新渲染。因为在 Server 端已经渲染了和 Client 完全相同的 DOM 结构所以完全没有必要在此重新渲染。

所以 hydration 的过程是给当前页面中已经生成的 HTML 页面添加上对应的事件监听器。

这也是为什么在 Next 等框架中为什么必须要保证 Server 端和 Client 的渲染 HTML 结构必须一致的原因。

比如我们以 Next 举例来说(Vue 也是同样的道理):

  1. 当用户访问 www.trip.biz.com 时,服务端接收到请求调用 ReactDOMServer.renderToString() 生成当前页面的 HTML 静态结构。

  2. 服务器会下发这个 HTML 页面给客户端,同时这个 HTML 页面上也会携带一部分 JS 脚本 script 标签。

  3. 用户的浏览器中会立即展现到该 HTML 页面,同时也会下载对应 JS 脚本并执行。

  4. 当 JS 脚本执行完毕后,客户端会调用 ReactDOM.hydrate() 发生水合为当前页面的 HTML 页面添加事件交互处理,同时后续由 JS 接管页面的跳转渲染。

针对于第一步 Next 中存在 Automatic Static Optimization 的优化,并不一定会在每次访问时调用 renderToString 方法,有可能在构建时也会直接生成对应的 HTML 模版。

整个过程就像是这张图中的样子:

image.png

优势

简单聊过了所谓 SSR 的原理后,如果你有认真看上述的内容。其实我相信相较于 CSR ,SSR 这种方式的好处不言而喻:

  1. 更好的搜索引擎优化 SEO 方式,HTML 模板是从服务端直接下发这也就导致搜索引擎爬虫中更多的关键字匹配。

  2. 更快的首屏渲染,因为相较于 SPA 它少了在 Client 中下载和执行 JS 脚本后渲染的过程。

  3. 页面不需要 JS 也可以正常渲染,虽然没有 JS 意味着页面失去了可交互性。但对于禁用 JS 的用户来说,展示一些静态内容总比 SPA 应用的白屏来的更加友好一些对吧。

劣势

当然,任何技术方案在不同场景下也存在它自己的不足。

  1. 强依赖于服务。

    针对于 CSR 的方式它是一种纯静态资源。我们可以直接将它放在 CDN 上就可以良好的用户访问到,而 SSR 的方式必须依赖于一个服务器进行服务端预渲染。(当然纯 SSG 应用我们不在这个讨论范围之内)

    同时,有服务的地方就存在并发压力。当你需要为你的应用考虑服务端渲染的方式时,一定不要忘记为你的服务器进行压测。

  2. Time to Interactive 可交互时间 (TTI) 的增长,虽然说 SSR 的方式有效的缩短了首屏加载的方式,但是会增加所谓的TTI(可交互时间)。

    所谓的 TTI 指标测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。

    因为 SSR 的方式在用户访问时会下发当前页面中静态的 HTML 内容,也就是所谓的 First Contentful Paint 首次内容绘制 (FCP) 会非常快速,但是页面需要用户交互效果缺又需要下载和执行完成 JS 脚本发生 hydatrion 后才具有交互性。

    这也就造成页面的 TTI 相较于 CSR 方式会有所差劲,因为 CSR 在渲染完成后就会立即具有交互性(不需要其他任何多余步骤)。

qwik

上述聊了那么多前置内置,终于要和大家切入正题了。

所谓磨刀不费砍柴功,上边和大家强调现阶段 SSR 的方案以及对应的优劣势就是为了引入下面的内容。

首先,这篇文章的目的是为了让大家在当前众多 SSR 框架中思考性能方面是否可以有所提升的,在服务器方面不会过多的深入。

我们可以稍微思考下上述服务器端渲染的过程:

image.png

第一步我们需要在服务端获取对应页面的 HTML 页面,大多数情况(非纯静态页面)就需要在服务端掉用对应渲染方法渲染出 HTML 页面。

那么,如果我们能在第一步渲染 HTML 页面时,就添加对应的事件处理。后续的 3 步是不是完全可以省略下来了对吧。

其实社区内部之前已经有非常多的方案来提升所谓 SSR 框架的性能方案。

比如 Remix 的 HTTP stale-while-revalidate 缓存指令

比如 astro 等新兴框架的 Islands 架构方案,关于 Islands 有兴趣的朋友可以参考神三元的这篇 Islands 架构原理和实践

针对于上面的概念,我们直接来看看 qwik 中提到的 Hydration is Pure Overhead (完全多余的 Hydration)。

Hydration 造成的开销

首先针对于 Hydration 的过程,我们提过到首先会在服务器上进行一次静态 HTML 渲染,之后当 HTML 下发到客户端后又会再次进行 hydrate 的过程,在客户端进行重新执行脚本添加事件。

Hydration 过程的难点就在于我们需要知道需要什么事件处理程序,以及将该事件处理程序附加在哪个对应的 DOM 节点上。

这个过程中,我们需要处理:

  • 每一个事件处理程序中的内容,绝大多数框架中的状态都做为闭包函数保存在内容中。所以需要 hydration 的过程来重新获取状态。

  • 其次,在搞清楚了每个事件处理函数的内容后。我们也需要将对应的事件处理函数附加到对应的 DOM 节点上,同时还要确保该监听器的正确事件类型。

更加复杂每个事件处理函数中的内容是一个闭包函数,这个函数内部需要处理两种状态,APP_STATE 以及 FRAMEWORK_STATE。

  • APP_STATE:应用程序的状态。简单来说应用程序的状态就是 HTML 事件中的各个状态事件,如果不存在这些事件状态那么所有的内容都是没有任何交互效果的。

  • FRAMEWORK_STATE:框架内部状态。通常我们会利用诸如 React 或者 Vue 等框架进行接替渲染。如果没有 FRAMETER_STATE,框架内部就不知道应该更新哪些DOM节点,也不知道应该在什么时候更新它们。

通俗来说 Hydration 就是在客户端重新执行 JS 去修复应用程序内部的 APP_STATE 以及 FRAMEWORK_STATE。

同样还是这这张图

image.png

在图中的前三个阶段可以被称为 RECOVERY 阶段,这三个阶段主要是在重建你的应用程序。

当从 Server 端下发的 HTML 静态页面后,我们希望它是具有交互效果的 HTML 正常应用程序。

那么此时 hydartion 的过程必须经历下载 HTML 、下载所有相关 JS 脚本、解析并且执行下载的 JS 脚本。

RECOVERY 阶段是和 hydartion 的页面的复杂性成正比,在移动设备上很容易花费 10 秒。

由于RECOVERY是昂贵的部分,大多数应用程序的启动性能都不是最佳的,尤其是在移动设备上。

前三个阶段被称为 RECOVERY 的阶段其实是完全没有必要的,因为在服务端我们已然渲染过对应的 HTML ,但是为了应用程序的可交互性以及服务端仅保留了静态的 HTML 模版导致不得不在 Client 上继续执行一次 Server 端的逻辑。

总而言之,hydration 其实是通过下载并重新执行 SSR/SSG 呈现的 HTML 中的所有 JS 脚本并执行来恢复组建中的事件处理程序。

同一个应用程序,会被发送到客户端两次,一次作为 HTML,另一次作为 JavaScript。

此外,框架必须立即执行 JavaScript 以恢复在服务器上被丢掉的 APP_STATEFRAMEWORK_STATE。所有这些工作只是为了检索服务器已经拥有但丢弃的东西!!

比如这样一个例子:

export const Main = () => <>
   <Greeter />
   <Counter value={10}/>
</>

export const Greeter = () => {
  return (
    <button onClick={() => alert('Hello World!'))}>
      Trip Biz
    </button>
  )
}

export const Counter = (props: { value: number }) => {
  const store = useStore({ count: props.number || 0 });
  return (
    <button onClick={() => store.count++)}>
      {store.count}
    </button>
  )
}

上边的例子中我们编写了一个 Counter 的计数器组件,在传统 SSR 过程中该组件会被渲染成为:

<button>Greet</button>
<button>10</button>

可以看到上边的两个按钮不拥有任何处理状态的能力。

要使网页具有交互性,必须要做的就是通过下载对应 HTML 页面中的 script 脚本并执行代码从而恢复按钮上的交互逻辑和状态。

为了具有交互性,客户端不得不执行代码实例化组件后重新创建状态。

当上述过程完成后,你的应用程序才会真正具有可交互性。无疑,同一个组件的渲染逻辑被执行了两遍,这是一个非常冗余且耗费性能的过程。

Resumability: 更加优雅的 hydartion 替代方案

所以为了消除额外的开销,我们需要思考如何避免重复的 RECOVERY 阶段。同时还要避免上面的第四步,第四步是执行脚本后给现有的 HTML 附加正确的事件处理程序。

qwik 中提出了一个全新的思路来规避 RECOVERY 带来的外开销:

  1. 将所有必需的信息序列化为 HTML 的一部分。

    qwik 将需要的状态以及事件序列化保存在 Server 端下发的 HTML 模版中,需要序列化信息需要包括WHAT(事件处理函数内容), WHERE(哪些节点需要哪些类型的事件处理函数), APP_STATE(应用状态), 和FRAMEWORK_STATE(框架状态)。

  2. 依赖于事件冒泡来拦截所有事件的全局事件处理程序。

    qwik 中事件处理程序是在全局处理的,这样我们就不必在在特定的 DOM 元素上单独注册所有事件。

  3. qwki 内部存在一个可以延迟恢复事件处理程序的工厂函数。

    该工厂函数主要用于处理 WHAT 阶段,也就是用来识别某个事件处理函数中应该存在什么脚本逻辑。

image.png

我们可以看到所谓的 Resumable 对比 Hydration 明显可以省略不需要后三个阶段,直接获取 HTML 后页面其实就已经准备完毕,这无疑对于性能的提升是巨大的。

对比传统的 hydration 方案,在客户端获得服务端下发的 HTML 后会立即请求需要的 JS 脚本并执行从而为页面附加对应的交互效果。

而 qwik 提出的概念恰恰相反,获取完服务端下发的 HTML 页面后所有的交互效果实际上都是一种惰性创建的效果。

因为我们在 HTML 中的每个元素中都已经通过序列化从而在它的标签属性上记录了对应事件处理函数的位置以及脚本内容(自然内容中也包含对应的状态),所以当获得 HTML 页面后其实就可以说此时页面已经加载完毕了而不需要任何实时的 JS 执行。

这样做的好处是在 qwki 中完全可以省略 hydration 的多余步骤,甚至可以说完全抛弃了 hydration 的概念。

客户端完全不必和服务端的 HTML 进行水合,相同的渲染内容仅仅是在 Server 端进行一次渲染客户端即可拥有对应的事件处理内容。

简单来讲Qwik的工作原理就是在服务端序列化 HTML 模版,从而在客户端延迟创建事件处理程序,这也是它为什么非常快速的原因。

qwik 工作机制

上边我们讲到了 qwik 的原理部分,同样拿上边的计数器的例子我们来对比下:

export const Main = () => <>
   <Greeter />
   <Counter value={10}/>
</>

export const Greeter = () => {
  return (
    <button onClick={() => alert('Hello World!'))}>
      Trip Biz
    </button>
  )
}

export const Counter = (props: { value: number }) => {
  const store = useStore({ count: props.number || 0 });
  return (
    <button onClick={() => store.count++)}>
      {store.count}
    </button>
  )
}

在 qwik 编译后,服务端会序列化对应组件的 HTML 结构从而下发如下的模板:

<div q:host>
  <div q:host>
    <button on:click="./chunk-a.js#button">Trip Biz</button>
  </div>
  <div q:host>
    <button q:obj="1" on:click="./chunk-b.js#count[0]">10</button>
  </div>
</div>
<script id="qwikloader">/* qwik 中设置全局事件监听器的代码 */</script>
<script id="qwik/json">/* 用于反序列化的 JSON 相关信息 */</script>

我们可以看到经过 qwik 编译后的 html 结构并不单单只有 DOM 元素,同时会在对应需要状态 & 事件的 DOM 元素上通过 HTML 元素属性来记录当前元素的事件和状态信息,这既是 qwik 中的序列化。

比如上边 button 的 on:click 属性记录了该元素后续需要恢复的所有信息。

需要注意的是序列化这一步是在服务端渲染时完成的,这也就意味着后续客户端可以通过服务端序列化的属性信息进行反序列化从而达到所谓的可恢复性而不需要重复执行组件。

当然你可能会好奇 qwik 是如何进行这些事件 & 状态的恢复,qwik 正是通过在返回的 HTML 页面中内嵌的所谓 qwikloader 的 script 脚本(这段脚本的大小不超过 1kb)配合 qwikjson 映射表,从而在全局进行恢复事件和状态的逻辑。

正因为这个原因,使得 qwik相较于传统 SSR 的 hydration 在 Client 中再次执行渲染从而水合页面状态和事件处理程序,这简直可以说是接近零 JS 的执行过程。

最终在用户触发事件时候达到惰性的创建事件并执行,这个过程中完全没有重复任何服务器已经完成的任何工作。

整个工作过程就像下面这张图描述的那样:

image.png

上边的这张图完美的描述了 qwik 的工作原理,相信经过上述的描述大家对于这张图中想表达的思想已经可以完美的理解了。

利用 qwik 的这个优势,在绝大多数应用中我们可以利用 qwik 保证你的 SSR 应用在保证快速的 FCP 的前提也同样拥有与之不相上下的 TTI 体验效果。

惰性加载脚本会影响用户交互体验吗

当然上文说过任何框架的优势和劣势都不是绝对的,在笔者看来 qwik 的确会存在以下一些问题。

大多数同学看完上边的内容我相信也会存在“惰性加载脚本会影响用户交互体验吗”这样的疑问。

首先,qwik 中既然选择在触发用户行为时,再惰性加载并执行响应的 JS 脚本。那么难免需要在用户触发交互时动态生成对应的事件处理函数进行执行。

这样的方式相较于传统 hydration 的确会存在一些不足,需要额外生成事件会额外造成交互响应时间的损耗而传统 SSR 方式在页面首次加载时就已经绑定好(相当于生成了)相应的事件处理函数。

就惰性加载生成事件这点在我看来:

针对于动态加载 JS 脚本,其实已经存在诸如非常多的 prefetch 等等预加载技术。

无论是基于传统 Next 方案还是基于 qwik 这种惰性可恢复的方案,利用 prefetch 等预加载技术优先在网络空闲时加载响应重要的 JS 脚本都是非常有必要的,所以这点在我看来并不是特别重要的问题。

动态创建事件函数会造成内存泄漏吗

qwik 的设计思想在与每次事件触发时通过 qwikloader 来动态创建事件处理函数,相信有的同学存在疑问“那么多次触发事件会造成额外的开销吗”。

qwik 的作者 miško heveryHydration is Pure Overhead 中明确的表示过 qwik 会在每次事件执行完毕后释放函数,相当于每次事件执行完毕都会进行一次“去水合”的过程。

所以,当你触发一次事件和无数次事件函数在执行过程中对于内存占用来说是相差无几的。

当然相较于传统 hydration 的方式(在页面首次渲染时在内存中记录所有状态),无疑 qwik 这种并不在内存中记录任何状态的方式恰恰对于内存的占用比 dyration 更加轻量化。

qwik 真的有那么快吗

说了那么多,那么 qwik 真的有那么快吗。

WechatIMG144.png

上图是利用 qwik 搭建的 builder.io 官方网站,我相信 builder.io 的数据已经告诉我们答案了。

固然上述的数据并不仅仅只是框架带给我们的优化,一定会有相应开发者各种对于代码以及构建层面的优化。但是 FCP 和 TTI 保持一致的中型 SSR 应用程序其实已经说明了 qwik 的确名副其实。

结语

我们可以看出来,qwik 的核心思路还是通过更加细粒的代码控制配合惰性加载事件处理程序以及事件委托来缩短首屏 TTI。

文章中我们也讲到了 qwik 其实并不是因为使用了多么牛逼的算法导致它有多么快,而它的速度正是得益于它的设计思路,省略了传统 SSR 下首屏需要加载庞大的 JS 进行 hydration 的过程。

当然,笔者对于 qwik 也仍是在学习阶段。后续会在公司里的更多项目尝试 qwik 之后也会和大家分享关于它的更多心得。

总而言之,qwik 的”无水合“设计思路目前看来的确会在框架层面带来巨大的性能提升。大家如果有机会的话也可以在项目中尝试一下 qwik ,相信会给你带来意想不到的收益效果。

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容