头图

Qwik.js框架是如何追求极致性能的?!

lulu_up上海市
English

背景、

    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过程就是用水把海参泡发, 达到可以食用的状态, 也就是页面可正常交互的状态。
image.png

二、ssr流程有什么可优化的点

   看完上述ssr的流程后你有什么感觉? 有没有感觉ssr可能是个"视觉骗子", 我们简单罗列几个可优化的点:

  1. 虽然首屏展示的速度快了, 但是不可交互, 所以他的tti(页面可交互时间)并没有太大的优化, 但不可否认也是有提升的只是不太多。
  2. 下载的js仍然是比较全量的js代码。
  3. js代码执行的时候, 仍然需要处理大量的逻辑, 还要重新处理一遍页面上的dom。

   2020年的时候我负责的项目就是使用的ssr技术搭建的, 首屏速度的确有提高, 但是缺点就是比较消耗服务器资源, 并且维护成本上去了, 比如偶尔的内存泄漏, 还有每次更新代码都需要去服务器上手动执行一些命令(当时的团队流水线还不完善), 给当时的我的直接感觉就是阵仗挺大收益有点小。

三、Qwik是什么

    可以将Qwik理解成一款语法接近react的前端ssr框架, 但是比传统的ssr框架做的更激进:

  1. 大幅优化甚至取消了hydration的过程
  2. 不光是延迟加载组件, 还可以延迟加载点击事件等代码
  3. 几乎可以做到, 只加载当前用到的js代码与css代码
  4. dom元素没有出现在屏幕的可视区, 则不执行组件内部方法

    Qwik的目标是延迟加载所有的代码, 比如一个按钮你没有点击它之前, 那么Qwik就不会去加载点击相关逻辑, 甚至他都不会去加载react相关的代码, 毕竟有的时候用户进入页面后也确实没有进行任何操作, 那么我们没必要去加载所有资源。

    当然了看完上述的描述你会感觉到使用Qwik会不会操作起来卡顿啊, 带着疑问拿好车票我们一点点深入研究。

四、初始化项目

    把安装与基本用法简单说下, 这样大家脑海里就有比较清晰的概念了, 但我感觉官网写的不错, 所以详细的用法请移步 qwik官网

初始化项目

    下面这条命令就是创建项目的命令:

npm init qwik@latest

    第一次使用这个命令我竟然愣住了, 因为我只用npm init 初始化一个空项目, 充其量使用npm init -y 这种方式, 但是我去查了官网才发现原来还可以这样用:
image.png

// 原命令
npm init qwik@latest

// 相当于
npx create-qwik

// 要注意, 不是
npx qwik@latest/create

所以由此可知, 我们直接npm install create-qwik -g 然后再create-qwik就可以同样的初始化项目啦:

image.png

选项的具体能力不是本篇文章的重点, 本次我们先从整体上体验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;
  });

image.png

    打印的文字在浏览器看不到, 开发时可以在vscode的控制台里面查看:
image.png

useClientEffect$对元素的可见性的监控

    仅在客户端渲染时, 当然也有对应的生命周期方法:

  useClientEffect$(() => {
    console.log("初始化: useClientEffect$");
  });

    这个钩子可以监控组件是否展示在屏幕上, 也就是说只有当组件可以被用户看到时才执行, 那么我们就来实验一下, 我们把home组件顶出屏幕外, 观察useClientEffect$是否执行:

    <Host>
      <h1 style={{ marginBottom:'1200px'}}>Welcome to QwikCity</h1>
      <Home></Home>
    </Host>

image.png

但是当我们滚动屏幕后展示出home组件:

image.png

    其实他是利用了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>

image.png

image.png

    好家伙, 我直呼好家伙, 这是不让我复用方法么? 没办法我是没有尝试成功, 最后只好变成下面这种形式:
image.png

image.png

    这里大概得意思就是说, 这个函数不可序列化, 所以不能使用, 我又想到那只要可序列化的方法就可以放在这里么? 就有了下面的第三种写法:

image.png

    放在组件的作用域内就不行, 那么我放在组件外, 但是下方使用处依旧报错:
image.png

    但是除非我们将方法导出, 就不会再报错了:
image.png

    所以至少外界导入的方法仍然是可以使用的, 当前组件作用域内的方法只能写在dom内联的方法里, 并且关键点事这些bug在他的官网文档里都没有详细说明, 都是靠开发者自己去探索, 这就让我使用体验非常差。

七、code模版来助力

    Qwik本身组件代码有点特殊, 所以他也提供了几个代码模版帮用户生成代码, 就在vscode的qwik.code-snippets文件里:
image.png

   使用:
image.png

八、用法的改革真的好么

    从Qwik框架的各种用法上看得出, 他们团队的野心, 并且官网中也提到了为什么不沿用react的语法, 他们给出的理由是react当前的架构无法做到Qwik想要的效果, 所以只能通过推翻并重构的方式才能实现Qwik

    但是他们提到的所有困难都是实现'过程'中遇到的问题, 而最终的用法应该是属于'结果', 在没有10倍好的情况下, 开发者为什么要去学习心的写法? 并且这些写法还处处是bug。

    当然啦, 所有的创新都值得鼓励, 哪怕做出一点改变都有可能改变这个单调的世界, 但是如果用着不爽也可以大方说出来就是。

九、$缓存了什么?

    上面我们简单介绍了下用法, 那么接下来我们就主要聊一聊原理吧, 就从点击事件这个维度来举例, 当我们在button上定义了一个点击事件, 那么编译出来的结构是这样的:

image.png

    可以看到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属性:

   要注意的是, tagNamecomponent$方法的第二个参数:

image.png

image.png

    所以现在你这道为什么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推出的一个招牌概念, 我们从三个方面聊下这个特性:

  1. 减少注水: 之前也说过, 全局设置点击监听事件, 这样就不用每次加载组件都注水一遍才能交互。
  2. 组件树: 在传统的ssr模式下,由于服务端渲染完成后可能一些dom结构已经改变, 此时就需要重新注水, 但Qwik可以做到在组件代码实际不存在的情况下重建组件层次结构信,组件代码可以保持惰性, 我的理解这里就有Host组件的功劳, 比如某个dom的位置被调整了, 那么仅需调整Host即可。
  3. Qwik 允许在没有父组件代码的情况下恢复任何组件, 我理解的是当前的ssr框架里需要父组件来创建子组件, 但是Qwik里将很多状态都内置了, 所以可以做到独立延迟渲染, 比如A是父组件, a是子组件, 那么我加载a组件的时候, 并不需要加载A组件的所有js逻辑。

十三、延迟加载的演示, 埋点等事件

    接下来一起来看看什么时候会去加载react代码, 因为react的源代码运行起来还是有点慢的, 下图展示的是一个没有任何交互时的页面请求记录:

image.png

    可以看出加载的文件非常的小, 当时第一眼看到我以为他不依赖react, 当时当我们点击一下按钮:

image.png

    点击事件触发后才去下载core.js

    这里要注意, 理论上一切需要使用react的hooks的时候都会触发加载这个文件, 比如代码里有useClientEffect$, 那么当其执行的时候就会去下载core.js

    但是执行useServerMount$这种服务端执行的hooks是不会触发加载core.js的, 所以大家知道怎样写才更高效了吧!!

十四、我的用后感

    用起来问题还是不少的, 官网虽然也写了很多的内容, 但是具体的实例还是太少了, 并且举例不到位, 净是一些普普通通状态下的完美例子, 稍微变一变就不成立了。

    并且可能是官网没怎么更新的缘故, 某些方法我粘贴过来竟然不能用, 还需要我研究好久...

    我是不太赞同推出一套与react差别较大的语法给到开发者, 并且单说语法改变了的收益也并不大。

十五、我的思考

    这个框架也带给了我不少思考, 它能把那么多细微的点做到极致, 优化入你的每一行逻辑, 那么我也有我的一些建议:

  1. 可否让开发者随意指定哪些事件需要异步, 比如推出onClickonClick$两个方法来区分是否需要异步加载代码。
  2. 是否可以针对每个用户做一个点击事件触发的记录, 比如某个用户经常触发某几个事件, 那么理论上可以优先预加载这几个事件的代码, 可以通过点击事件埋点进行统计。
  3. 将经常被加载的js文件放在性能更好或距离更近的CDN服务器上, 也就是差异化部署。

    脱离开框架本身, 对于实际业务的启发:

  1. 我们是否可以通过埋点来关注每一个click事件, 比如记录每周前十的点击事件, 然后顺着这几个事件开始重点优化。
  2. 注水也就是可交互性, 是否真的很重要, 比如某些页面是不是用户进来只是看一看就走了, 没啥需要用到react能力的地方, 那么这时我们是否不用进来就加载类似core.js这种文件。
  3. 封装一个类似Host组件的组件, 可以让其内的组件出现在可视区才加载这个组件。
  4. 一个父组件内部可能有多个子组件, 但是可能最常用的只有一个子组件, 那么是否可以延迟加载父组件与其余的子组件?

end

     这次就是这样, 希望与你一起进步。

阅读 1.7k

自信自律, 终身学习.

5.2k 声望
6.8k 粉丝
0 条评论

自信自律, 终身学习.

5.2k 声望
6.8k 粉丝
文章目录
宣传栏