在前端流行的SSR(服务端渲染)框架中,Remix和Qwik绝对是最引人瞩目的两个佼佼者。作为React-Router的开发团队,Remix从一开始的收费模式,到前段时间宣布开源,之后便迅速冲到了Github热搜榜的第一名。而另一边,Qwik则是顶着第一个实现O(1)复杂度的框架,让所有开发者耳目一新,引起了广泛的关注。
怀着对两个框架的敬意,以及争做第一个吃螃蟹的人的心态,笔者所在的团队分别在不同的项目中,利用两个框架对业务进行了重构,并最终成功落地。在上线一段时间之后,带着两个框架实践和踩坑的经验,本文会在各个方面对两者进行逐一对比,希望能够让大家更清晰的看到两者的异同点及优劣势。
Routes
Remix和Qwik都采用了基于文件结构的路由形式,Remix在app/routes下定义的文件或者文件夹, 其命名会自动转换成页面的路由。而Qwik也是基本保持一致,唯一的差异点在于,Qwik额外定义了一个Layout.tsx,让开发者可以更加简单直接的去描述所有页面的容器组件。但是总体来说,两者的路由设计理念和结构是大差不差的。
Qwik:
Remix:
Loader
通常来说,大部分的页面渲染会依赖于某些数据,这些数据需要请求服务端或者数据库才能拿到。对于CSR 来说,我们需要在客户端进行Ajax请求,等到数据返回后,改变页面。而对于SSR来说,我们可以将这个步骤放在服务端层面去做,这样不仅可以有效避免用户的网络波动,也可以得益于页面服务和数据服务部署在同一个集群里而带来的快捷性。
针对于这个场景,Qwik和Remix都设计了一个类似于数据Loader的机制,让页面请求到来之后,在服务端去做数据的获取和处理,最终返回到组件中去使用。
让我们来思考一种请求瀑布场景,
js
function Root() {
const common = useRequest(url)
return (
<Layout>
{common ? <User /> : <Loading /> }
</Layout>
)
}
function User() {
const detail = useRequest(url)
return (
<Layout>
{detail ? <OtherComonents /> : <Loading /> }
</Layout>
)
}
如上述代码所示,我们在父子组件嵌套的情况下,每个组件的请求都必须等到父组件的请求结束后才能开始发出,如果嵌套的场景非常多,请求的速度和数据处理的场景很麻烦的话,那么这个请求瀑布的耗时就会非常的长。 有什么方法可以解决呢?可能很多人的第一反应是,将所有的请求都放在根组件,拿到数据之后都以props的形式传递下去。
js
function Root() {
const common = useRequest(url)
const detail = useRequest(url)
return (
<Layout>
{common ? <User userData={detail} /> : <Loading /> }
</Layout>
)
}
这样理论上是可以解决请求的瀑布问题,但全部请求结果的读取都需要在根组件中维护,随着组件的复杂度的增高,根组件就越发臃肿,也越来越难以维护。
Remix和Qwik是怎么解决这个问题的呢?
让我们回到问题本身,请求瀑布的出现是由于组件嵌套带来的串行请求导致的。当我们遇到数量较多却没有彼此依赖的请求时,我们只需要让所有的请求在渲染之前并行发出,并利用Suspense来做请求期间的fallback机制即可。
Remix和Qwik就是这么做的。他们认为数据请求是否应该发出只决定于用户的请求Url。换句话来说,当你的路由层级是以下结构时,当用户访问了/blog/categories页面时,我们就可以知道blog/index,blog/categories里的数据请求需要发出,那么此时所有的数据请求就会并行进行,提高了页面的访问速度
bash
app/
├── routes/
│ ├── blog/
│ │ ├── $postId.tsx
│ │ ├── categories.tsx
│ │ └── index.tsx
│ ├── about.tsx
│ ├── blog.tsx
│ └── index.tsx
└── root.tsx
在Qwik中,我们可以这么来写,
js
// layout.tsx
const delay = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`layout ${Date.now()}`)
}, 3000)
})
}
export const useCommon = routeLoader$(async (requestEvent) => {
const res = await delay()
console.log(res)
return res;
});
export default component$(() => {
cosnt res = useCommon()
return (
<div>
<Slot />
</div>
)
})
js
// routes/blog/index.tsx
const delay = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`blog ${Date.now()}`)
}, 3000)
})
}
export const useDetail = routeLoader$(async (requestEvent) => {
const res = await delay()
console.log(res)
return res;
});
export default component$(() => {
cosnt res = useDetail()
return (
<div>
Blog
</div>
)
})
当我们刷新页面时,可以看到两个请求的结果是同时打印的。
而在Remix中的使用,其实也是大同小异。
乍一看,好像在Loader这方面,这两个框架还是基本保持一致,同样都支持嵌套路由的并行请求,也同样都支持不同路由之间的loader交叉使用。但其实不然,在Remix的Loader种,除了做了上述事情以外,也充分结合了React 18中的Streaming机制,也就是流式渲染。
那么什么是流式渲染呢?通俗点来说,就是将Html等脚本文件,分块传输到客户端,客户端可以对接收到的每一块内容进行分批渲染。
我们再次回到上述的代码, 虽然我们的嵌套路由实现了并行请求,但是在请求期间的3s内,用户看到的页面实际上是一个白屏,浏览器必须等到该delay
请求结束后,才能接收到页面的文档。
js
const delay = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`blog ${Date.now()}`)
}, 3000)
})
}
export const useDetail = routeLoader$(async (requestEvent) => {
const res = await delay()
console.log(res)
return res;
});
而在Remix中,官方提供的 defer API
可以帮助我们以很简单的方式开启 Streaming 模式,并通过 await
让开发者控制页面中内容是直出还是渲染 fallback组件。
我们看如下代码:
我们同样模拟了一个三秒的请求,但是我们在LoaderFunction中开启了steaming模式,页面会直接渲染标题部分和suspense包裹的fallback组件(也就是loading),等到请求结束后,动态替换loading组件为我们的响应数据。
js
import type { LoaderFunction } from '@remix-run/node'
import { useLoaderData, Await } from '@remix-run/react'
import { defer } from '@remix-run/node';
import { Suspense } from 'react';
const delay = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`响应数据`)
}, 3000)
})
}
export const loader: LoaderFunction = async ({ request, context }) => {
const res = delay()
return defer({
res
});
}
export default function Button() {
const { res } = useLoaderData();
return (
<>
<p>标题</p>
<Suspense fallback={<div>Loading...</div>}>
<Await<string> resolve={res}>
{(res) => {
return (
<p>{res}</p>
);
}}
</Await>
</Suspense>
</>
);
}
实际的效果:
通过上面的概述,我们可以很容易推测到,由于steaming
的这种机制,Remix在生产场景下,可以无视数据请求耗时的阻塞困扰,从而让页面的主体部分以最快的速度返回到用户的面前,换句话来说,Remix的FCP
的数据可以很轻松的将其他框架甩开。所以在Loader层面上,我认为Remix想对于Qwik,更胜一筹。
Render
众做周知,服务端渲染主要分为三个步骤:
- 用户访问页面URL,页面服务根据URL渲染出
HTML模版
(实际只是一个字符串)。 - 客户端拿到HTML模版,渲染出页面,但此时页面只是一个
静态页面
,不具备交互性
。 - 下载并执行HTML引入的JS文件,为页面上静态HTML元素再次添加对应的事件处理以及相对应的状态从而恢复页面的交互性。
第三个步骤我们通常称之为hydrate
,也就是水合,意思是将交互和静态HTML元素重新缝合在一起。只有当这个过程结束之后,我们的页面才具备交互性,换句话来说,SSR实际上是在牺牲了TTI的基础之上,换来了更快的FCP。
让我们回到上述过程,在服务端接受到用户的请求之后,会根据路径创建出组件树,也就是静态的进行一次页面渲染,然后将渲染后的组件通过字符串拼接的方式返回到客户端。
客户端接到 SSR 响应之后,为了支持恢复页面的交互功能,仍然需要创建出组件树,与 SSR 渲染的 HTML 关联起来,并绑定相关的 DOM 事件,让页面变得可交互,这整个过程被称为 hydration。
hydration具体做了什么呢?他主要解决了两个问题:
- What: 我们需要哪些事件处理程序?事件处理程序是一个包含事件行为的闭包。如果用户触发此事件,则应该发生这种情况。
- Where: 我们需要将这些事件处理方法添加到哪个Dom节点?
而对于事件处理程序来说,通常内部都会对整个应用的状态 进行处理,hydration的过程实际上也就是利用重新执行组件代码的方法,来让静态的HTML字符串恢复这个状态。
所以,我们需要注意到,恢复HTML的交互性,或者说恢复HTML的状态实际上是一种纯粹开销的动作。 因为执行组件代码,生成组件树和对应的状态这个流程实际上在服务端已经走过一遍,只不过在下发到客户端的时候,这些信息被丢失了而已。如果服务器将组件树的状态和交互信息以序列化的形式,将其与 HTML 一起发送给客户端,就可以避免发生或者延迟这个hydration的过程,新框架Qwik实际上就是这么做的。
Qwik在页面下发的到客户端之前,做了三件事情:
- 将页面所需的状态,以及每个HTML的元素需要进行的交互事件,都序列化的形式保存在服务端下发的HTML模版中。
-
将所有的交互事件进行冒泡,在顶层进行统一处理,而这个处理的方法函数也以内敛脚本的形式,跟随HTML模版中一起返回。
-
将所有的事件处理逻辑,如果不需要马上执行的,都会被切割成一个个体积很小的js文件,进行延迟加载,并且这些js文件一旦加载过,后续便可以进行缓存,避免频繁触发事件导致重复加载。
根据上述的Gif图,我们可以得知,一个利用Qwik编写的动态页面,是完全有可能做到页面初始化时,不需要加载任何JS文件的。这样不仅可以极大的减少网络传输的包体积,也完全不需要担心hydrate带来的困扰,因为Qwik完全不需要hydrate。
这也就是为什么Qwik在推行时,宣称自己是一个复杂度为O(1)的框架,因为理论上页面所有的执行逻辑都可以延后至用户需要的那一刻才进行加载和执行。
但是为了做到这一点,Qwik实际上也是付出了一些代价的。
- 首先,Qwik并不是一个基于React的上层框架,他内置的JSX也是一个
阉割版
的JSX,即使他提供了qwikify$
这样的方法来让你去实现React组件到Qwik组件的转换,但是这些转换实际上是有诸多限制和心智负担的。并且,当你使用了qwikify$
时,所转换的组件仍然是需要hydrate的,而这一点实际上是和Qwik的设计理念相违背的。
js
import { qwikify$ } from '@builder.io/qwik-react';
function Greetings() {
return <div>Hello from React</div>
}
export const QGreetings = qwikify$(Greetings);
- 第二点,通过巧妙的切割方法,将逻辑尽可能的分散到每一个体积极小的JS文件中,然后以懒加载的形式去响应用户的交互,这听上去是貌似是一个非常取巧的方法。但是在实际情况下,无论JS文件有多么小,在响应用户之前,都必须经过网络加载和执行JS代码这个过程,而这个过程都会给用户带来明显的延迟感。当然,通过注入prefetch或者modulePrefetch等手段,也可以从侧面去缓解这个问题。
但是,如果我们把重心放在页面的渲染层面上,Qwik这种去水合的理念,实际上是给社区提供了一种更为大胆和激进的SSR新模式。并且在这种模式下,页面的初始加载JS大小会被极致的压缩。同时在性能上,相比于其他传统的SSR模式,Qwik下的FCP
和TTI
会无限的接近,但从这一点上来看,我认为Qwik会比Remix略胜一筹。
Ecosystem
在对一个框架进行技术选型时,社区生态
对其的支持度必然是一个非常重要的参考因素。在笔者看来,衡量一个框架的生态是否成熟,主要取决于两个方面:
- 一些常见的问题和相关轮子,是否已经具备现有的成熟的解决方案
- 框架团队对于解决问题的态度
对于Remix来说,其本身就是一个基于React的上层框架,开发者自然可以利用到庞大的React基础生态,包括但不限于UI组件库
,React本身的不断迭代以及React相关的各种轮子等等。
而对于Qwik来说,React的生态对其是一个很暧昧的存在,一方面利用qwikify$
,基本也能够使用,但是随之而来的心智负担和别扭的导入方法,开发体验是绝对不如Remix来的舒服的。另一方面,接入React生态便意味着重新捡起 hydrate的老路子,这相当于自废武功。
当然,Qwik在提供基础框架的同时,也以配套NPM包的形式给开发者带来很多方便快捷的Api或者插件 例如,在@builder.io/qwik-city
中,提供了server$()
的方法,可以让你创建始终在服务器上执行的函数,使其成为访问数据库或执行仅服务器操作的好地方,咋一听好像跟之前提到的Loader差不多,但这实际上是提供了一种客户端和服务器之间的一种RPC(远程过程调用)机制
,换句话来说,利用server$()
可以让用户在客户端去调用一个存储在服务端的一个方法,并且得到的数据会以流的形式放回到客户端。
js
import { component$, useSignal } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';
const stream = server$(async function* () {
for (let i = 0; i < 10; i++) {
yield i;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
});
export default component$(() => {
const message = useSignal('');
return (
<div>
<button
onClick$={async () => {
const response = await stream();
for await (const i of response) {
message.value += ` ${i}`;
}
}}
>
start
</button>
<div>{message.value}</div>
</div>
);
});
综合分析来说,Remix得益于React庞大且成熟的生态系统基础,在社区生态
这一方面来说,还是比Qwik更胜一筹。
Build
在前端工程化日益成熟的今天,代码的构建打包往往成为我们开发的最后一个环节,当然也是最重要的环节之一。 社区上的主流构建工具主要为Webpack,Rollup,Esbuild,他们针对不同的场景,都有自己独特的优势,至少很难说有哪一个工具能够完全取代或者碾压另外一个,当然这也是前端领域的一个显著特点。
回到主题本身,我们来聊聊Remix和Qwik各自的构建打包选择。
在Remix中,官方选择了Esbuild来作为整个项目打包构建的工具,诚然,Esbuild作为一款,基于Go编写的打包工具,得益于编译型语言和多线程并行的优势,他的编译速度可以说是碾压其他一众工具的。
但是Esbuild并不是另一个Webpack,它仅仅提供了构建一个现代 Web 应用所需的最小功能集合,未来也不会大规模加入我们所熟悉的各类构建特性。另外,由于Esbuild的设计初衷就是要做一个性能极致的前端构建工具,他在代码编写的时候,几乎所有的编译过程都会为了性能做出让步,换言之,Esbuild各个编译过程并不像Rollup和Webpack那样做到高内聚,低耦合
,而是环环相扣,紧密结合,这也就意味着你不可能在Esbuild中去自由的编写各种插件,实现各种额外的功能。
相对Webpack和Rollup来说,Esbuild更像是一个相对封闭的工具,在做到性能极致的同时,也意味着需要失去灵活的插件系统。
而反观Qwik,则正好相反。Qwik在一众的构建工具中,选择了近年来最为火热的构建新秀 - Vite。
在Vite2.0之后,开发环境下,会利用Esbuild去做第三方依赖的预编译。另外,得益于浏览器提供的ESM解析能力,Vite具备极快的冷启动速度和毫秒级别的热更新速度。换句话来说,Vite把依赖寻找和代码解析的工作交付给了浏览器去做,当开发者本地代码更新时,只需要浏览器按需重新请求相关文件即可,也就省掉了Webpack的那些依赖查找和编译的耗时了。
而在生产环境,Vite则采用了Rollup去构建出最终的代码和静态资源,这样一来,Vite既可以在本地享受了Esbuild带来的极速编译体验,又可以利用到Rollup成熟且丰富的插件生态去解决各种各样的疑难杂症,可以说是采众家之所长了。
举一个开发的时候遇到一个小坑,在打包出最后的静态资源之后,即使是作为SSR的渲染模式,我们也需要把静态资源上传到CDN,以便享受CDN带来的相关优势。但是,CDN的域名前缀并不是固定的,我们需要做到动态分发静态资源的CDN前缀。例如当百度CDN挂了,我们需要快速切换到亚马逊的CDN域名,避免发生大面积的页面瘫痪。
换言之,我们在node返回HTML模版之前,需要调用一个接口,去拿到当前最适合的CDN域名,然后给拼到我们要加载的静态资源链接前面,也就是Webpack里常说的publicPath
。
在Qwik中,实际上暴露了一个API,base
,我们可以传入一个域名前缀,然后Qwik在拼接HTML模版时,就会在html加上一个base属性,这个base属性的值就是我们传入的域名前缀。然后模版里的JS等静态资源的域名也会同步加上这个前缀。
js
import {
renderToStream,
type RenderToStreamOptions,
} from "@builder.io/qwik/server";
import { manifest } from "@qwik-client-manifest";
import Root from "./root";
export default function (opts: RenderToStreamOptions) {
return renderToStream(<Root />, {
manifest,
...opts,
base: 'https://www.baidu.cdn.com/',
containerAttributes: {
lang: 'zh-cn',
...opts.containerAttributes,
},
});
}
这样好像就简单的实现了我们的需求,但其实这里Qwik的处理是有Bug的。当我们的代码里有较多的JS逻辑时,Qwik会对并需要在页面初始化时执行的逻辑进行代码分割。最终利用Vite构建提供的预加载能力,进行模块的预加载,但是这个预加载的文件链接并不会加上我们传入的base
属性,在生产环境下就会报错(顺便给Qwik提了一个issue, github.com/BuilderIO/q...
在面对这个问题的时候,Vite或者说Rollup强大的插件机制的优势就会体现出来,我们可以编写一个很简单的Rollup插件去解决这个问题(当然,社区上也有相关的插件),思路就是在遇到Vite预加载时,在添加script脚本之前,把域名加上当前页面的q:base
属性。 实现如下:
js
import type { Plugin } from 'vite'
export function preloadDynamicBase(): Plugin {
return {
name: 'vite-plugin-dynamic-base',
enforce: 'post',
apply: 'build',
transform(code, id) {
// 替换preload中的base
if(code.indexOf('modulepreload') !== -1 && code.indexOf('__vitePreload') !== -1){
const parseCode = code.replace('link.href = dep', `
const _qBase = document.getElementsByTagName('html')[0].getAttribute('q:base');
const _href = _qBase ? _qBase.replace('/build/', '') : '';
link.href = _href + dep;`
)
return {
code: parseCode
}
}
},
}
}
而同样的问题如果发生在Remix中,其实是没办法像我们一样去利用插件机制去优雅的实现的(真实场景下也确实发生了,最终只能是通过修改源码,打patches的形式去解决)。
所以,在构建打包这个环节上,我认为得益于Vite的底层机制,Rollup开放的插件机制及生态,Qwik是更胜一筹的。
总结
最后,无论是从设计理念,生态支持还是构建打包等等各个方面来说,Qwik和Remix都具备自己的优势和劣势,作为开发者的我们,其实只需要将这些特点融合到自身的业务场景里,然后做出最优的选择即可。
有人说前端是最卷的一个领域,几乎每天都有新的轮子出现,也有人说前端已死,现在学前端无异于49年入国军。而在我看来,开发者或许真的不需要用一种极端的眼光去看待这些新兴的事物,无论是Qwik和Remix,还是Webpack和Vite,这些工具的初衷往往只是为了解决某一个领域的某一个问题,而不是来和别人争个你死我活。