背景、
Qwik
是一款语法"接近"react
的前端ssr
框架, 前段时间看了两篇Qwik
相关的文章, 对这个框架有了些兴趣, 但是去网上搜了一下, 发现相关的中文文章几乎没有了, 所以决定对其好好研究一番, 并且写一篇关于Qwik
的特点、基础用法、设计概念, 再加上Qwik
对我的一些启发, 接下来就一起看看这款黑科技是何方神圣吧。
一、前提知识:ssr (懂了这里才能看懂Qwik)
从入门学习前端开发开始, 我们不断学习到各种前端的优化方式来提高前端代码的性能, 其中"服务端渲染(ssr)"这种模式帮我们大幅提高了使用前端框架开发的项目的首屏性能, 那么ssr的工作流程是什么样的? 下面我们一起简单梳理一下。
第一步: 服务端拼接html
当用户请求某个页面的时候, server端会拼接好一个页面的html结构返回给客户端, 例如下面的结构:
<!DOCTYPE html>
<head>
<title>Document</title>
</head>
<body>
<div id="App">
<button>点击弹出: hello</button>
<ul>
<li>1</li>
<li>2</li>
</ul>
</div>
<script src="/_ssr/2046328.js" defer></script>
</body>
</html>
第二步: 客户端加载好的html展示出来了
上面的代码可以看出, html
结构加载完就可以展示出来了, 但是比如点击事假, 这类交互事件还是没有的, 需要加载/_ssr/2046328.js
后页面才能有交互(活起来), 所以我们还是要请求一堆js
文件到本地。
第三步: js执行hydration阶段完毕才可交互
hydration
字面意思类似'注水', 也就是通过js
代码的执行, 动态的为当前页面上的dom绑定事件, 你可把当前获取到的html
代码当做一根干货海参, js
代码理解成水, 而hydration
过程就是用水把海参泡发, 达到可以食用的状态, 也就是页面可正常交互的状态。
二、ssr流程有什么可优化的点
看完上述ssr的流程后你有什么感觉? 有没有感觉ssr可能是个"视觉骗子", 我们简单罗列几个可优化的点:
- 虽然首屏展示的速度快了, 但是不可交互, 所以他的tti(页面可交互时间)并没有太大的优化, 但不可否认也是有提升的只是不太多。
- 下载的js仍然是比较全量的js代码。
- js代码执行的时候, 仍然需要处理大量的逻辑, 还要重新处理一遍页面上的dom。
2020年的时候我负责的项目就是使用的ssr
技术搭建的, 首屏速度的确有提高, 但是缺点就是比较消耗服务器资源, 并且维护成本上去了, 比如偶尔的内存泄漏, 还有每次更新代码都需要去服务器上手动执行一些命令(当时的团队流水线还不完善), 给当时的我的直接感觉就是阵仗挺大收益有点小。
三、Qwik是什么
可以将Qwik
理解成一款语法接近react
的前端ssr
框架, 但是比传统的ssr
框架做的更激进:
- 大幅优化甚至取消了hydration的过程
- 不光是延迟加载组件, 还可以延迟加载点击事件等代码
- 几乎可以做到, 只加载当前用到的js代码与css代码
- dom元素没有出现在屏幕的可视区, 则不执行组件内部方法
Qwik
的目标是延迟加载所有的代码, 比如一个按钮你没有点击它之前, 那么Qwik
就不会去加载点击相关逻辑, 甚至他都不会去加载react相关的代码, 毕竟有的时候用户进入页面后也确实没有进行任何操作, 那么我们没必要去加载所有资源。
当然了看完上述的描述你会感觉到使用Qwik
会不会操作起来卡顿啊, 带着疑问拿好车票我们一点点深入研究。
四、初始化项目
把安装与基本用法简单说下, 这样大家脑海里就有比较清晰的概念了, 但我感觉官网写的不错, 所以详细的用法请移步 qwik官网
初始化项目
下面这条命令就是创建项目的命令:
npm init qwik@latest
第一次使用这个命令我竟然愣住了, 因为我只用npm init
初始化一个空项目, 充其量使用npm init -y
这种方式, 但是我去查了官网才发现原来还可以这样用:
// 原命令
npm init qwik@latest
// 相当于
npx create-qwik
// 要注意, 不是
npx qwik@latest/create
所以由此可知, 我们直接npm install create-qwik -g
然后再create-qwik
就可以同样的初始化项目啦:
选项的具体能力不是本篇文章的重点, 本次我们先从整体上体验Qwik
以后有机会再扣扣细节。
启动
开发时的启动
npm install
npm start
打包后的启动
npm run build
npm run serve
点击事件
由于整体上与react
几乎一样, 咱们就开门见山, 先来看看如何定义一个组件并且定义它的点击事件:
import { component$, useStore } from "@builder.io/qwik";
export const Home = component$(() => {
const state = useStore({
count: 0,
});
return (
<button onClick$={() => (state.count += 1)}>home组件: {state.count}</button>
);
});
我们可以发现, 组件是由一个component$
函数生成的, 有了这个函数组件就可以是一个异步的组件, 也就是当用户的屏幕上没有使用该组件时, 这个组件的相关代码就不会被加载。
onClick$
这个名字也有一个$
, 意思差不多, 就是当我们没有触发这个点击事件的时候, 不会去下载点击事件的代码, 这个就很细节了。
五、hooks
useStore 定义变量
与react
不一样的定义变量的写法:
const state = useStore({
count: 0,
name: '金毛cc'
});
修改值直接在, state
身上修改即可
state.name = '被修改啦'
突然有一种写vue
的感觉。
useServerMount$
注册一个服务器挂载钩子,该钩子仅在首次挂载组件时在服务器中运行。
这个hooks只在服务端运行, 写法如下:
useServerMount$(async () => {
console.log("什么时候执行: useServerMount$");
const n: number = await new Promise((resolve) => {
setTimeout(() => {
resolve(9);
}, 3000);
});
state.count = n;
});
打印的文字在浏览器看不到, 开发时可以在vscode的控制台里面查看:
useClientEffect$对元素的可见性的监控
仅在客户端渲染时, 当然也有对应的生命周期方法:
useClientEffect$(() => {
console.log("初始化: useClientEffect$");
});
这个钩子可以监控组件是否展示在屏幕上, 也就是说只有当组件可以被用户看到时才执行, 那么我们就来实验一下, 我们把home组件顶出屏幕外, 观察useClientEffect$是否执行:
<Host>
<h1 style={{ marginBottom:'1200px'}}>Welcome to QwikCity</h1>
<Home></Home>
</Host>
但是当我们滚动屏幕后展示出home组件:
其实他是利用了IntersectionObserver
这个方法监听了dom
的状态, 所以如果我们的某些组件里面需要展示请求到的数据, 那么我们可以当这个组件出现在屏幕上时再请求。
之所以他可以提供这样的方法是因为Qwik
框架的特性, 后续讲到Host
组件的时候大家就明白了。
useWatch$订阅值的变化 (有大坑)
下面我们写一下, 每当count
变化的时候, 就会触发这个watch
:
useWatch$((track) => {
const count = track(store, 'count');
store.doubleCount = 2 * count;
});
这里有个大坑, 就是当你的组件代码里面有useServerMount
方法并且其在useWatch
下方时, useWatch
只能执行一次, 也就是只在server
端执行一次, 后续不执行。
这里是错误的用法:
// 错误示范
// 书写在上方
useWatch$((track) => {
const count = track(state, "count");
state.doubleCount = count + 2;
});
// 书写在下方
useServerMount$(async () => {
const n: number = await new Promise((resolve) => {
setTimeout(() => {
resolve(9);
}, 500);
});
state.count = n;
});
所以当我们需要持续监听某个值的变化时, 需要把useWatch
放在useServerMount$
下面:
// 正确写法
// 书写在上方
useServerMount$(async () => {
const n: number = await new Promise((resolve) => {
setTimeout(() => {
resolve(9);
}, 500);
});
state.count = n;
});
// 书写在下方
useWatch$((track) => {
const count = track(state, "count");
state.doubleCount = count + 2;
});
六、click事件有大坑
从头看完qwik
的官网后发现, 他举的例子全部都是内连函数, 像是图里这样:
<button onClick$={()=>state.count += 1}>
home组件: {state.count}
</button>
但是其实我们更常用的是下面这种形式:
const handleClick = ()=>{
state.count += 1
}
return <button onClick$={handleClick}>
home组件: {state.count}
</button>
好家伙, 我直呼好家伙, 这是不让我复用方法么? 没办法我是没有尝试成功, 最后只好变成下面这种形式:
这里大概得意思就是说, 这个函数不可序列化, 所以不能使用, 我又想到那只要可序列化的方法就可以放在这里么? 就有了下面的第三种写法:
放在组件的作用域内就不行, 那么我放在组件外, 但是下方使用处依旧报错:
但是除非我们将方法导出, 就不会再报错了:
所以至少外界导入的方法仍然是可以使用的, 当前组件作用域内的方法只能写在dom
内联的方法里, 并且关键点事这些bug
在他的官网文档里都没有详细说明, 都是靠开发者自己去探索, 这就让我使用体验非常差。
七、code模版来助力
Qwik
本身组件代码有点特殊, 所以他也提供了几个代码模版帮用户生成代码, 就在vscode的qwik.code-snippets
文件里:
使用:
八、用法的改革真的好么
从Qwik
框架的各种用法上看得出, 他们团队的野心, 并且官网中也提到了为什么不沿用react
的语法, 他们给出的理由是react
当前的架构无法做到Qwik
想要的效果, 所以只能通过推翻并重构的方式才能实现Qwik
。
但是他们提到的所有困难都是实现'过程'中遇到的问题, 而最终的用法应该是属于'结果', 在没有10倍好的情况下, 开发者为什么要去学习心的写法? 并且这些写法还处处是bug。
当然啦, 所有的创新都值得鼓励, 哪怕做出一点改变都有可能改变这个单调的世界, 但是如果用着不爽也可以大方说出来就是。
九、$缓存了什么?
上面我们简单介绍了下用法, 那么接下来我们就主要聊一聊原理吧, 就从点击事件这个维度来举例, 当我们在button
上定义了一个点击事件, 那么编译出来的结构是这样的:
可以看到on:click
事件竟然对应着一串字符串
, 点击事件为啥不是函数?
其实这里是因为Qwik
的点击事件机制, 首先Qwik
会在全局监听点击事件, 然后当点击某个dom时Qwik
会检测dom的身上是否有onclick事件并读取相应的字符串, 然后按照字符串的地址去加载对应的文件, 加载好文件后执行对应的方法。
那我们写的click事件
是如何转化成字符串的那? 这里就涉及到了一个叫做''的概念:
// 转换前
<button onClick$={() => {
state.count += 1;
}}
// 转换后
<button onClick$={qrl('./chunk-c.js', 'Home_onClick', [store, props])}
所以可以理解为qrl
方法是专门负责将一些逻辑转换到对应的需要异步加载的js文件
的方法, 所以这里我们就理解了为什么组件的onClick
事件的写法有那么多限制, 因为这些逻辑比如符合可以单独抽象到独立的js文件
里面才行, 如果没法抽象则无法做到异步加载。
十、缓存是分块的: Host标签
Host
标签是几乎每个组件的最外层, 也就是如下图所示, 任何组件都要包裹一层Host
:
return (
<Host>
....//
</Host>
);
之所以要额外包裹一层Host
我们来看看官网给出的解释:
宿主元素用于标记组件边界。如果没有宿主元素,Qwik 将不知道组件在哪里开始和结束。需要此信息,以便组件可以独立且无序地呈现,这是 Qwik 的一个关键特性。
我之前写过几篇关于react-keep-alive
的文章, 对这方面也有些了解, 在react
内如果dom
没有渲染到确定的位置, 那么后续再插入子组件是没有响应式的, 具体的不是一两句说的清楚的, 大家可以看看我以前的文章: 一些关于react的keep-alive功能相关知识在这里
既然官方说'必须有个Host'标签, 那么就代表我们每个组件的渲染都会多出一层dom
, 既然没法避免那就尽可能的使用它吧, 首先我们可以指定Host
标签是什么dom
属性:
要注意的是, tagName
是component$
方法的第二个参数:
所以现在你这道为什么Qwik
里面, 可以使用useClientEffect$
方法监控组件是否在可视区了吧, 因为组件的外层基本都有个Host
元素, 所以哪怕是下面这种写法也可以检测元素的显隐:
<Host>
<>
<div>1</div>
<div>2</div>
<div>3</div>
</>
<div>4</div>
</Host>
十一、Qwik如何处理延迟? prefetch
我最开始看这个框架就一直有一个疑问, 点击事件延迟加载可能会导致微微的卡顿
吧, 至少也是增加了点击事件的处理时间啊, 万一这个点击事件的代码有点大并且用户的网不好, 岂不是一首凉凉?
Qwik
团队当然早已想到这些问题, 至少我是被他们给出的理由说服了, 官网的英文版本有点难懂我就用我的语言来解释一下吧:
传统的ssr
框架是需要全量加载js文件
的, 并且要等js
文件加载完毕并且注水
完毕才是页面可交互, 也就是说这些事件是串行
的, 但是Qwik
将其变成了并行模式, 比如click
事件本身只对应字符串
那么它的渲染速度当然很快, 同时Qwik
会开启webWorker
将代码的预取发生在主线程以外的其他线程上, 并且不是一次请求全部, 而是当前使用的几个组件的代码, 这样的话后续只要监听到点击事件请求某个文件, 则webWorker
会将对应的文件直接传递过来, 而不用请求网络。
并且Qwik
还表明: 加载js文件
与执行js逻辑
相比, 后者可能更费时间。
并且不是一个js文件
里面只有一个方法, 而是会用多个相关的方法, 比如触发了某个点击事件那么其他的一下方法也会被加载过来的, 不用逐一去加载。
十二、可恢复性
可恢复性
是Qwik
推出的一个招牌概念, 我们从三个方面聊下这个特性:
- 减少
注水
: 之前也说过, 全局设置点击监听事件, 这样就不用每次加载组件都注水
一遍才能交互。 - 组件树: 在传统的
ssr
模式下,由于服务端渲染完成后可能一些dom
结构已经改变, 此时就需要重新注水
, 但Qwik
可以做到在组件代码实际不存在的情况下重建组件层次结构信,组件代码可以保持惰性, 我的理解这里就有Host
组件的功劳, 比如某个dom的位置被调整了, 那么仅需调整Host
即可。 Qwik
允许在没有父组件代码的情况下恢复任何组件, 我理解的是当前的ssr
框架里需要父组件来创建子组件, 但是Qwik
里将很多状态都内置了, 所以可以做到独立延迟渲染, 比如A是父组件, a是子组件, 那么我加载a组件的时候, 并不需要加载A组件的所有js
逻辑。
十三、延迟加载的演示, 埋点等事件
接下来一起来看看什么时候会去加载react代码
, 因为react
的源代码运行起来还是有点慢的, 下图展示的是一个没有任何交互时的页面请求记录:
可以看出加载的文件非常的小, 当时第一眼看到我以为他不依赖react
, 当时当我们点击一下按钮:
点击事件触发后才去下载core.js
这里要注意, 理论上一切需要使用react
的hooks的时候都会触发加载这个文件, 比如代码里有useClientEffect$
, 那么当其执行的时候就会去下载core.js
。
但是执行useServerMount$
这种服务端执行的hooks
是不会触发加载core.js
的, 所以大家知道怎样写才更高效了吧!!
十四、我的用后感
用起来问题还是不少的, 官网虽然也写了很多的内容, 但是具体的实例还是太少了, 并且举例不到位, 净是一些普普通通状态下的完美例子, 稍微变一变就不成立了。
并且可能是官网没怎么更新的缘故, 某些方法我粘贴过来竟然不能用, 还需要我研究好久...
我是不太赞同推出一套与react
差别较大的语法给到开发者, 并且单说语法改变了的收益也并不大。
十五、我的思考
这个框架也带给了我不少思考, 它能把那么多细微的点做到极致, 优化入你的每一行逻辑, 那么我也有我的一些建议:
- 可否让开发者随意指定哪些事件需要异步, 比如推出
onClick
与onClick$
两个方法来区分是否需要异步加载代码。 - 是否可以针对每个用户做一个点击事件触发的记录, 比如某个用户经常触发某几个事件, 那么理论上可以优先预加载这几个事件的代码, 可以通过点击事件埋点进行统计。
- 将经常被加载的
js
文件放在性能更好或距离更近的CDN
服务器上, 也就是差异化部署。
脱离开框架本身, 对于实际业务的启发:
- 我们是否可以通过埋点来关注每一个
click
事件, 比如记录每周前十的点击事件, 然后顺着这几个事件开始重点优化。 注水
也就是可交互性, 是否真的很重要, 比如某些页面是不是用户进来只是看一看就走了, 没啥需要用到react
能力的地方, 那么这时我们是否不用进来就加载类似core.js
这种文件。- 封装一个类似
Host
组件的组件, 可以让其内的组件出现在可视区才加载这个组件。 - 一个父组件内部可能有多个子组件, 但是可能最常用的只有一个子组件, 那么是否可以延迟加载父组件与其余的子组件?
end
这次就是这样, 希望与你一起进步。