作者简介
19组清风,携程资深前端开发工程师,负责商旅前端公共基础平台建设,关注NodeJs、研发效能领域。
今天这篇文章中和大家聊一聊号称世界上第一个 O(1) 的 JAVAScript SSR 框架:qwik。
别担心,如果你不是特别了解 SSR 也没关系,文章大概会从以下几个方面作为切入点:
诸如社区内部 SSR 框架其实已经产生了非常优秀的作品,比如大名鼎鼎的 NextJS 以及新兴势力代表的 Remix 和 isLands 架构的 Astro、Fresh 等等优秀框架。
为何 qwik 可以在众多老牌优秀框架中脱颖而出。接下来,让我们一起来一探究竟吧。
目前业内存在非常多基于 SSR 的优秀框架,比如 Next、Remix、Nuxt 等等。
针对于 Qwik 我们先来聊聊基于 Next 体系的传统 SSR 方案。
在开始 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 代码从而在客户端通过脚本进行页面渲染。
以及通常在 CSR 中当我们点击任何页面中的导航链接并不会向服务端发起请求,而是通过下载的 JS 脚本中的路由模块(比如 ReactRouter、VueRouter 这样的模块)重新执行 JS 来处理页面跳转从而进行页面重新渲染。
上面的概念是非常典型的 CSR ,浏览器仅仅接受一个用作网页容器的 HTML 页面,这样的方式通常也被称为单页面应用 (SPA)。
1)优势
那么上述我们提到的 CSR 广泛存在于目前大量页面中,必然存在它自己的优势。
在页面初始化访问后加载速度极快且响应非常迅速。在页面初始化后,网站所有的 HTML 内容都是在客户端通过执行 JS 生成,并不需要再次请求服务器即可重新渲染 HTML 。
此外,有关任何实时的数据获取都可以通过 AJAX 请求对于页面进行局部更新从而刷新页面。
2)劣势
可是,CSR 真的有那么完美吗。任何一件技术方案一定存在它的两面性,我们来看看 CSR 方式究竟存在哪些问题:
首次请求完服务器获取到 HTML 页面后,初始化的页面仍然需要在一段时间内处于白屏状态。
在初始渲染之前,浏览器必须等待 HTML 页面中的所有 Javascript 脚本加载完成并且执行完毕,此时页面才会进行真正的渲染。
当然,使用代码拆分或延迟加载等多种方案可以有效的减少上述的问题。但是这些方式始终是治标不治本,因为它并没有从本质上解决 CSR 存在的问题。
上边我们提到过,所谓 CSR 本质上首先会返回一个空的 HTML 页面,所以这也就造成了在搜索引擎对于该页面的数据爬取中会认为它是一个空页面。从而影响对应的搜索结果排名。
虽然说在最新的 google 中已经可以触发执行 JS 对于网站进行关键字排名,但是在 JS 体积足够大的时候针对于 SEO 仍然是存在一部分问题导致无法解析出正确的关键字匹配。
当然 CSR 还存在一些其他方面的缺点,比如网站强依赖于 JS 当用户禁用 JS 时网站只能是白屏展现给用户等等之类。
简单聊完客户端渲染后,我们稍微来看看所谓的服务端渲染是什么含义。
基于旧时代的类似 Java 的 JSP 页面我在这里就不赘述了,显然 JSP 的方式每个 HTML 都需要单独请求服务器返回对应的 HTML 内容严格意义上来说这也是 SSR 的方式但是很明显这已经被时代淘汰了。
目前国内各家公司广泛应用的服务端渲染技术大概的思路是这样的(Next 的 SSR 模式也是同样的思路):
当用户首次访问你的应用站点时:
当 hydration 过程完成后,会由我们的客户端框架接管网站的后续渲染。在后续的导航链接跳转和页面渲染中和服务器已经没有任何关系了,我们完全可以利用客户端的路由切换(History Api/Hash Api)利用 JS 进行页面渲染从而保证切换页面不用再次请求浏览器保证非常及时的页面交互。
1)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 也是同样的道理):
针对于第一步 Next 中存在 Automatic Static Optimization 的优化,并不一定会在每次访问时调用 renderToString 方法,有可能在构建时也会直接生成对应的 HTML 模版。
当然,在最新的 Next 版本中已经支持了Stream以及 Server Components。
整个过程就像是这张图中的样子:
2)优势
简单聊过了所谓 SSR 的原理后,如果你有认真看上述的内容。其实我相信相较于 CSR ,SSR 这种方式的好处不言而喻:
3)劣势
当然,任何技术方案在不同场景下也存在它自己的不足。
上述聊了那么多前置内置,终于要和大家切入正题了。
所谓磨刀不费砍柴功,上边和大家强调现阶段 SSR 的方案以及对应的优劣势就是为了引入下面的内容。
首先,这篇文章的目的是为了让大家在当前众多 SSR 框架中思考性能方面是否可以有所提升的,在服务器方面不会过多的深入。
我们可以稍微思考下上述服务器端渲染的过程:
第一步我们需要在服务端获取对应页面的 HTML 页面,大多数情况(非纯静态页面)就需要在服务端掉用对应渲染方法渲染出 HTML 页面。
那么,如果我们能在第一步渲染 HTML 页面时,就添加对应的事件处理。后续的 3 步是不是完全可以省略下来了对吧。
其实社区内部之前已经有非常多的方案来提升所谓 SSR 框架的性能方案。
比如 Remix 的 HTTP stale-while-revalidate 缓存指令
比如 astro 等新兴框架的 Islands 架构方案,关于 Islands 有兴趣的朋友可以参考神三元的这篇 Islands 架构原理和实践。
针对于上面的概念,我们直接来看看 qwik 中提到的 Hydration is Pure Overhead (完全多余的 Hydration)。
1)Hydration 造成的开销
首先针对于 Hydration 的过程,我们提过到首先会在服务器上进行一次静态 HTML 渲染,之后当 HTML 下发到客户端后又会再次进行 hydrate 的过程,在客户端进行重新执行脚本添加事件。
Hydration 过程的难点就在于我们需要知道需要什么事件处理程序,以及将该事件处理程序附加在哪个对应的 DOM 节点上。
这个过程中,我们需要处理:
更加复杂每个事件处理函数中的内容是一个闭包函数,这个函数内部需要处理两种状态,App_STATE 以及 FRAMEWORK_STATE。
通俗来说 Hydration 就是在客户端重新执行 JS 去修复应用程序内部的 APP_STATE 以及 FRAMEWORK_STATE。
同样还是这这张图:
在图中的前三个阶段可以被称为 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_STATE和FRAMEWORK_STATE。所有这些工作只是为了检索服务器已经拥有但丢弃的东西!!
比如这样一个例子:
export const Main = () => <>
<Greeter />
<Counter value={10}/>
</>
export const Greeter = () => {
return (
<button notallow={() => alert('Hello World!'))}>
Trip Biz
</button>
)
}
export const Counter = (props: { value: number }) => {
const store = useStore({ count: props.number || 0 });
return (
<button notallow={() => store.count++)}>
{store.count}
</button>
)
}
上边的例子中我们编写了一个 Counter 的计数器组件,在传统 SSR 过程中该组件会被渲染成为:
<button>Greet</button>
<button>10</button>
可以看到上边的两个按钮不拥有任何处理状态的能力。
要使网页具有交互性,必须要做的就是通过下载对应 HTML 页面中的 script 脚本并执行代码从而恢复按钮上的交互逻辑和状态。
为了具有交互性,客户端不得不执行代码实例化组件后重新创建状态。
当上述过程完成后,你的应用程序才会真正具有可交互性。无疑,同一个组件的渲染逻辑被执行了两遍,这是一个非常冗余且耗费性能的过程。
2)Resumability: 更加优雅的 hydartion 替代方案
所以为了消除额外的开销,我们需要思考如何避免重复的 RECOVERY 阶段。同时还要避免上面的第四步,第四步是执行脚本后给现有的 HTML 附加正确的事件处理程序。
qwik 中提出了一个全新的思路来规避 RECOVERY 带来的外开销:
我们可以看到所谓的 Resumable 对比 Hydration 明显可以省略不需要后三个阶段,直接获取 HTML 后页面其实就已经准备完毕,这无疑对于性能的提升是巨大的。
对比传统的 hydration 方案,在客户端获得服务端下发的 HTML 后会立即请求需要的 JS 脚本并执行从而为页面附加对应的交互效果。
而 qwik 提出的概念恰恰相反,获取完服务端下发的 HTML 页面后所有的交互效果实际上都是一种惰性创建的效果。
因为我们在 HTML 中的每个元素中都已经通过序列化从而在它的标签属性上记录了对应事件处理函数的位置以及脚本内容(自然内容中也包含对应的状态),所以当获得 HTML 页面后其实就可以说此时页面已经加载完毕了而不需要任何实时的 JS 执行。
这样做的好处是在 qwki 中完全可以省略 hydration 的多余步骤,甚至可以说完全抛弃了 hydration 的概念。
客户端完全不必和服务端的 HTML 进行水合,相同的渲染内容仅仅是在 Server 端进行一次渲染客户端即可拥有对应的事件处理内容。
简单来讲Qwik的工作原理就是在服务端序列化 HTML 模版,从而在客户端延迟创建事件处理程序,这也是它为什么非常快速的原因。
3)qwik 工作机制
上边我们讲到了 qwik 的原理部分,同样拿上边的计数器的例子我们来对比下:
export const Main = () => <>
<Greeter />
<Counter value={10}/>
</>
export const Greeter = () => {
return (
<button notallow={() => alert('Hello World!'))}>
Trip Biz
</button>
)
}
export const Counter = (props: { value: number }) => {
const store = useStore({ count: props.number || 0 });
return (
<button notallow={() => 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 的执行过程。
最终在用户触发事件时候达到惰性的创建事件并执行,这个过程中完全没有重复任何服务器已经完成的任何工作。
整个工作过程就像下面这张图描述的那样:
上边的这张图完美的描述了 qwik 的工作原理,相信经过上述的描述大家对于这张图中想表达的思想已经可以完美的理解了。
利用 qwik 的这个优势,在绝大多数应用中我们可以利用 qwik 保证你的 SSR 应用在保证快速的 FCP 的前提也同样拥有与之不相上下的 TTI 体验效果。
4)惰性加载脚本会影响用户交互体验吗
当然上文说过任何框架的优势和劣势都不是绝对的,在我们看来 qwik 的确会存在以下一些问题。
大多数同学看完上边的内容我相信也会存在“惰性加载脚本会影响用户交互体验吗”这样的疑问。
首先,qwik 中既然选择在触发用户行为时,再惰性加载并执行响应的 JS 脚本。那么难免需要在用户触发交互时动态生成对应的事件处理函数进行执行。
这样的方式相较于传统 hydration 的确会存在一些不足,需要额外生成事件会额外造成交互响应时间的损耗而传统 SSR 方式在页面首次加载时就已经绑定好(相当于生成了)相应的事件处理函数。
就惰性加载生成事件这点:
针对于动态加载 JS 脚本,其实已经存在诸如非常多的 prefetch 等等预加载技术。
无论是基于传统 Next 方案还是基于 qwik 这种惰性可恢复的方案,利用 prefetch 等预加载技术优先在网络空闲时加载响应重要的 JS 脚本都是非常有必要的,所以这点在我看来并不是特别重要的问题。
5)延迟加载会带来 bundle 数量的上升吗
qwik 推崇的延迟加载其实已经是一项非常成熟的构建技术了。无论是使用 webpack、rollup 又或是其他任何构建工具都存在延迟加载 & 代码分割的技术。
传统构建工具中关于代码分割会带来以下两点的困难:
当然,这一系列事情比起魔法注释。因为 qwik 本身提倡的就是所谓的延迟加载,所以在框架内部已经帮我们足够智能的去处理这个过程。
但是需要注意的是这并不意味着开发者无法自主去控制这个过程。只是在使用框架的过程中,qwik 希望开发者更加专注于他们自身的业务逻辑。
延迟加载模块的确会存在多个 small bundle 的问题,可是当我们拥有的 bundle 越多,其实我们就拥有更多的自由度去以各种各样的方式去拼装成为单个大的 bundle。
qwik 中存在足够的方式提供给我们将多个小的 chunk 自由组合成为一个从而有效的减少细碎 chunk 的数量,当然这个点在传统构建工具中也是这样。
6)动态创建事件函数会造成内存泄漏吗
qwik 的设计思想在与每次事件触发时通过 qwikloader 来动态创建事件处理函数,相信有的同学存在疑问“那么多次触发事件会造成额外的开销吗”。
qwik 的作者 miško hevery 在 Hydration is Pure Overhead 中明确的表示过 qwik 会在每次事件执行完毕后释放函数,相当于每次事件执行完毕都会进行一次“去水合”的过程。
所以,当你触发一次事件和无数次事件函数在执行过程中对于内存占用来说是相差无几的。
当然相较于传统 hydration 的方式(在页面首次渲染时在内存中记录所有状态),无疑 qwik 这种并不在内存中记录任何状态的方式恰恰对于内存的占用比 dyration 更加轻量化。
7)qwik 真的有那么快吗
说了那么多,那么 qwik 真的有那么快吗。
上图是利用 qwik 搭建的 builder.io 官方网站,我相信 builder.io 的数据已经告诉我们答案了。
固然上述的数据并不仅仅只是单纯一个 qwik 框架带给网站的优化,一定会有代码层面或者构建层面等等方面的优化配合而来的数据。但是针对于 FCP 和 TTI 时间上的一致性这在一个中型 SSR 应用程序其实可以称得上是非常优秀了,我相信这足以说明了 qwik 的确名副其实。
我们可以看出来,qwik 的核心思路还是通过更加细粒的代码控制配合惰性加载事件处理程序以及事件委托来缩短首屏 TTI。
文章中我们也讲到了 qwik 其实并不是因为使用了多么牛逼的算法导致它有多么快,而它的速度正是得益于它的设计思路,省略了传统 SSR 下首屏需要加载庞大的 JS 进行 hydration 的过程。
当然,我们对于 qwik 也仍是在学习阶段。后续会在公司里的更多项目尝试 qwik 之后也会和大家分享关于它的更多心得。
总而言之,qwik 的“无水合”设计思路目前看来的确会在框架层面带来巨大的性能提升。大家如果有机会的话也可以在项目中尝试一下 qwik ,相信会给你带来意想不到的收益效果。