出品:西瓜视频前端技术团队
作者:张明远
Qwik 是什么
Qwik 是一个前端框架,语法类似 React 使用 JSX 和 Hooks,不过 Qwik 是全栈SSR框架,而且 Qwik 采用了一系列策略优化页面的首屏性能,做的无论应用体积多大,首屏性能 PageSpeed 测试基本都能达到满分 Framework Benchmarks。
Misko Hevery
Misko Hevery,Qwik 的作者,更为知名的Title是Angular.js&Angular的作者。
他在圣克拉拉大学毕业后先后去了硅谷很多公司,Intel、Xerox(施乐)、Sun和Adobe公司他都有工作过,在这些公司他主要从事数据库/后端方面的工作。
2005年加入了谷歌,2009年和Adam Abrons一起开发了Angular.js,后来成为谷歌的项目。
2021 年从谷歌离职加入builder.io成为CTO并开始了 Qwik 项目。
一个问题
为什么前端框架层出不穷,Svelte、SolidJS、Astro、Fresh、Marko、Qwik?
Big Runtime
React 和 Vue 都是基于 Runtime 的框架,即框架本身有很多的代码,且都会打包到最终的产物中被发送到用户的浏览器里执行,当用户需要在页面上执行操作改变组件的状态时,框架的 Runtime 会根据新的组件状态(State)计算(Diff)出需要更新的DOM节点从而更新View。
从上图可以看出,像 Angular、React、Vue 等 Big Runtime 的框架体积都比较大,在首屏加载的时候就需要加载框架的JS文件并且执行相关的代码,那么就会产生一定的性能开销,尤其是弱网和低性能手机,性能影响就会越大。
Virtual DOM is Pure Overhead
Virtual DOM 并不高效。说Virtual DOM高效是因为它不会直接操作原生DOM,操作DOM比较消耗性能。应用状态变化时框架会生成新的 Virtual DOM,并通过diff算法去计算出本次数据更新真实的视图变化,然后只改变“需要改变”的DOM节点。
React 顶层组件 state
更新,如果不进行任何优化,则所有子组件都会重渲染(re-render)。所谓的 re-render
是你 Class Component 的 render
方法被重新执行,或者函数组件被重新执行。组件被重渲染是因为Vitual DOM的高效是建立在 Diff 算法上的,而要有 Diff 一定要将组件重渲染才能知道组件的新状态和旧状态有没有发生改变,从而才能计算出哪些DOM需要被更新。
正是因为框架本身很难避免无用的渲染,React才允许使用一些诸如shouldComponentUpdate
、PureComponent
、memo
和useMemo
的API去告诉框架哪些组件不需要被重渲染。
在 React 16 版本之前,Virtual DOM 的对比是通过递归实现,如果组件树嵌套很深,那性能势必降低;React 16 之后,推出 Fiber 架构,虽然省不掉必要的 render,但把递归 Diff 改为可打断的循环,并且花费精力解决任务优先级调度问题,优化了用户体验。
Virtual DOM 还有个最大的问题——额外的内存占用,以 Vue 的 Virtual DOM 对象为例,100W 个空的 Virtual DOM(Vue) 会占用 110M 内存。
我认为最重要的问题是组件状态到页面元素是有映射关系的,而是用 Virtual DOM 则丢失了这个映射关系,需要 DOM Diff 来重新构建这个关系,纯粹是多余的消耗(Pure Overhead)。
SSR Hydration is Pure Overhead
Hydration 是为了对服务端进行渲染的HTML提供交互能力的方案。Hydration 的过程一般是运行框架和组件代码,还原应用状态和构建Virtual DOM,并将事件监听添加到HTML元素上。
Misko Hevery认为Hydration是很低效的解决方案。
从上图可以看出,Hydration 需要较长的时间来进行应用的状态恢复,主要因为以下两点:
1、框架必须下载与当前页面相关的所有组件代码并且由浏览器引擎进行解析和执行。
2、框架必须执行与页面上的组件关联的代码重新构建整个应用程序,以重建事件监听和内部组件树,即使实际上没有创建任何新的DOM。
所以 Hydration 是纯开销,因为整个应用的构建过程在 Node 上都已经运行过了,但是这部分信息没有同步到浏览器,而是丢弃掉了,所以客户端需要重新执行一遍代码进行 Hydration 来重新恢复整个应用。而如果服务端将所有的应用所需要的信息序列化传输到浏览器,那么 Hydration 的过程完全可以省略。
而且 Hydration 是与应用的复杂度成正比的。所以即使用了 SSR,页面的 TTI 也可能不是很好。
社区的探索
Precompile
如今很多新的框架都没有 VDOM,反而是通过预编译然后直接进行细粒度的 DOM操作来达到比 VDOM 更好的性能。
Svelte 是 Precompile 的先行者,其通过静态编译减少框架运行时的代码量,一个 Svelte2 组件编译了以后,所有需要的运行时代码都包含在里面了,除了引入这个组件本身,你不需要再额外引入一个所谓的框架运行时!
<a>{{ msg }}</a>
会被编译成如下代码:
function renderMainFragment(root, component, target) { var a = document . createElement('a'); var text = document . createTextNode(root . msg); a . appendChild(text); target . appendChild(a) return { update: function (changed, root) { text . data = root . msg; }, teardown: function (detach) { if (detach) a . parentNode . removeChild(a); } }; }
可以看到,跟基于 Virtual DOM 的框架相比,这样的输出不需要 Virtual DOM 的 diff/patch 操作,自然可以省去大量代码,同时,性能上也和 vanilla JS 相差无几(仅就这个简单示例而言),内存占用更是极佳。这个思路其实并不是它首创,之前有一个性能爆表的模板引擎 Monkberry.js 也是这样实现的,ng2 的模板编译其实也跟这个很类似(但是中间加了一层渲染抽象层)。
如何看待 svelte 这个前端框架? - 尤雨溪的回答 - 知乎 www.zhihu.com/question/53…
SolidJS 也是 Precompile,和 Svelte2 相比有少量的运行时,目前 Svelte 也改为有少量运行时的方案来减少代码体积,而且支持 Tree Shaking。
SolidJS
Svelte 和 SolidJS 等预编译框架解决了 Runtime 和 VDOM 的问题,没有解决了 Hydration 的问题。
Islands Architecture
Islands 架构模型早在 2019 年就被提出来了,并在 2021 年被 Preact 作者
Json Miller
在 Islands Architecture 一文中得到推广。这个模型主要用于 SSR (也包括 SSG) 应用,我们知道,在传统的 SSR 应用中,服务端会给浏览器响应完整的 HTML 内容,并在 HTML 中注入一段完整的 JS 脚本用于完成事件的绑定,也就是完成 hydration (注水) 的过程。当注水的过程完成之后,页面也才能真正地能够进行交互。当一个页面中只有部分的组件交互,那么对于这些可交互的组件,我们可以执行 hydration 过程,因为组件之间是互相独立的。而对于静态组件,即不可交互的组件,我们可以让其不参与 hydration 过程,直接复用服务端下发的 HTML 内容。可交互的组件就犹如整个页面中的孤岛(Island),因此这种模式叫做 Islands 架构。
摘录自 Islands 架构原理和实践
Islands Architecture 没有解决 Runtime 的问题,部分解决了 Hydration 和 VDOM 的问题。
React Server Component(RSC)
React 在 2020年12月发布了 RSC 的 Demo,可以在Node.js上运行 RSC,然后生成 DSL 下发到浏览器,最后由框架层解析 DSL 并更新到 DOM 上。
RSC 可以将一些组件的渲染放到服务端,前端做纯展示,而且仅RSC的依赖不会被打包到客户端,这样如果某个组件有一个较大的第三方依赖,就可以把第三方依赖放到RSC里,在服务端运行组件并将产生的结果传输到浏览器端进行展示。
用官方给出的demo来举例子,为了渲染一个用markdown写的笔记,我们需要用到240kb的js代码(gzip之后是74kb)充当运行时:
// NoteWithMarkdown.js // NOTE: *before* Server Components import marked from 'marked'; // 35.9K (11.2K gzipped) import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped) function NoteWithMarkdown({text}) { const html = sanitizeHtml(marked(text)); return (/* render */); }
笔记只是用于查看,此时此刻它是纯静态的(不需要用户与之交互)。那么如果我们能够在服务器上把它渲染成静态内容,我们是不是省掉把大量js代码传输到客户端,解析和执行的成本了呢?有了React Server Component,我们能够做到这一点。
RSC 没有解决 Runtime 问题,部分解决了 Hydration 和 VDOM 的问题。
Resumable
Resumable (可恢复性)是 Qwik 提出的一个概念,是指SSR时应用在服务端执行后可以在客户端恢复执行,而不用重新构建和下载所有的应用代码,也不需要对整个应用进行 Hydration。
Qwik 是如何实现 Resumable 的?
Precompile
Qwik 提供了 optimizer 对代码进行预编译,底层由 SWC 进行驱动。
上面的代码经过 optimizer.transformModules
编译后会生成一个JSON,然后对其处理成 js 文件
可以看到,Qwik 把一个组件便后成了两个文件,一个处理 DOM 逻辑,一个处理事件逻辑。
最后通过 renderToString 生成的 SSR HTML 如下:
<!DOCTYPE html>
<html q:container="paused" q:version="0.11.1" q:render="ssr" q:base="/build/">
<!--qv q:id=0 q:key=shY4sSSi6wY:hello--> <p on:click= "s_9rnzdakbxj8.js#s_9rnZDAkBxj8[0]" q:id= "1" > Hello Qwik </p>
<!--/qv--> < script type= "qwik/json" > { "ctx" :{ "#1" :{ "r" : "0" }}, "objs" :[ "Qwik" ], "subs" :[]} </ script >
< script > window . qwikevents ||= []; window . qwikevents . push ( "click" ) </ script >
</html>
Interactive
当在 SSR 阶段生成 HTML,第二部就是要在浏览器处理事件了,Big Runtime框架是通过 Hydration 来进行事件监听,让页面进行事件响应的,而Qwik则采取了完全不同的方案。
Qwik 的事件监听全部由页面上的 qwikevents ******来处理 :
< script > window . qwikevents ||= []; window . qwikevents . push ( "click" ) </ script >
- Qwikevents 会监听 document/window 的全局事件,而不是针对每个DOM单独监听其事件,这可以在不存在应用代码的情况下实现事件监听。
- 对触发事件的 DOM 节点获取其
on:event
属性,并从中解析出 Qrl ,调用Qwikloader
加载对应的JS文件并执行对应的方法,而 DOM 上的q:id
和 事件上的[]
内的数字则表示其方法的参数。
Resumable
Component tree
通常框架需要在浏览器中构建组件树实现对页面的更新(Hydration),Qwik 则通过在 SSR renderToString
的过程中收集组件信息并将其序列化到 HTML 上,所以其不需要在运行时构建组件树,而且可以实现以下能力:
- 在组件代码不存在的情况下重建组件层次结构信息,组件代码可以保持惰性。
- Qwik 可以实现懒加载,只为需要重新渲染的组件重建组件层次结构信息,而不是整个应用。
- Qwik 收集store和组件之间的关系信息,并创建一个订阅模型,通知 Qwik 哪些组件由于状态更改而需要重新渲染。订阅信息也被序列化到 HTML。
Application state
所有框架都需要保持状态。大多数框架以引用和闭包的形式将此状态保存在代码中,这样就导致初始化时候需要下载所有代码,做好关联,也就是Hydration,但是这样通常会有个问题,就是如果需要恢复子组件,那父组件也需要恢复。Qwik的独特之处在于状态以属性的形式保存在 DOM 中,这使得Qwik组件可以独立进行恢复。
在 DOM 中保持状态的后果有许多独特的好处,包括:
1、通过以字符串属性的形式在 DOM 中保持状态,应用程序可以随时序列化为 HTML。
HTML 可以通过网络发送并反序列化为不同客户端上的 DOM。然后可以恢复反序列化的 DOM。
2、每个组件都可以独立于任何其他组件来恢复。这种只允许对整个应用程序的一个子集进行Hydrate 且不需要时序,并仅仅下载需要响应用户操作的代码,这与传统框架有很大不同。
3、Qwik 是一个无状态框架(所有应用程序状态都以字符串的形式存在于 DOM 中)。无状态代码易于序列化、传输和恢复。这也是允许组件彼此独立Hydration的原因。
4、应用程序可以在任何时间点进行序列化(不仅仅是在初始渲染时),并且可以多次序列化。
优化
大家也会有个疑问:如果网络延迟,点击事件会不会卡顿呢?
Prefetching
Qwik 提供了 prefetchStrategy
方法来进行 JS 的预取:
export default function (opts: RenderToStreamOptions) {
return renderToStream(<Root />, {
manifest,
prefetchStrategy: {
// custom prefetching config
},
...opts,
});
}
默认情况下,Qwik 会预取页面上所有可见节点的监听器,也可以自己配置预取策略:
One More Thing
Partytown,是一个轻量级的减少第三方JS脚本运行导致页面加载问题的开源工具,由 Builder.io 维护,目前处于测试阶段。通过将第三方JS在WebWorker运行,减少了由于第三方JavaScript导致的执行延迟。其想解决以下问题:
- 释放主线程资源以仅用于主应用执行;
- 对第三方脚本进行沙盒处理,并允许或拒绝其访问主线程 API;
- 在 Web Worker 中隔离长时间运行的任务;
- 通过将DOM setters/getters 批处理到组更新中来减少来自第三方脚本的布局问题;
- 限制第三方脚本对主线程的访问;
- 允许第三方脚本完全按照它们的编码方式运行,而无需任何更改;
-
从 Web Worker 中同步操作(读取/写入)主线程 DOM。
Web Worker 的主要问题是无法直接访问可从主线程访问的 DOM API,例如 window, document
或 localStorage
。虽然可以在两个线程之间创建消息传递系统来代理 DOM 操作,但用于 Web Worker/主线程通信的postMessage
API 是异步的。这意味着依赖于同步 DOM 操作的第三方脚本将无法按照预期运行。
Partytown 使用 JavaScript Proxy、Service Worker 和同步 XHR 请求,从 Web Worker 内部提供对 DOM API 的同步访问。
例如在 Web Worker 中获取 document.title
会经过以下步骤:
- 先对
document
使用 Proxy 进行拦截
- 再使用同步的 XHR 发起请求
- 然后使用 Service Worker 拦截请求
- 最后通过
postMessage
异步发送到主线程。
整个流程虽然比较繁琐,但是其好处就是在Web Worker 运行的 JS 来说,其访问 DOM API 是同步的,完全和主线程一样,就不必重写 JS 来处理 DOM API了。
此外,通过 Proxy 来代理 DOM API,可以记录所有的 JS 访问 DOM 记录,并进行拦截限制。
结语
本文对现今(2022/11)大部分框架进行一个简单的分析,也探究了一些社区解决方案,Qwik 算是这些方案的集大成者,Qwik 提出的 Resumable 思想是对现今框架的一个颠覆,我认为其对前端的意义不亚于 VDOM 和 JSX,未来几年应该会更多的看到社区对 Resumable 探索和应用,甚至最终取代 React 也未可知。
虽然理念已经比较成熟,但 Qwik 框架本身目前还处于非常初期的版本,框架本身还有较多的问题,建议大家可以进行学习研究,持续关注其发展,但短期不要在正式项目中使用。
参考
关于我们
我们来自字节跳动,是旗下西瓜视频前端部门,负责西瓜视频的产品研发工作。
我们致力于分享产品内的业务实践,为业界提供经验价值。包括但不限于营销搭建、互动玩法、工程能力、稳定性、Nodejs、中后台等方向。
欢迎关注我们的公众号:xiguafe,阅读更多精品文章。
我们在招的岗位:job.toutiao.com/s/rDoHAqH。 招聘的城市:北京/上海/厦门。
欢迎大家加入我们,一起做有挑战的事情!
谢谢你的阅读,希望能对你有所帮助,欢迎关注、点赞~