Bobolo
  • Home
  • Me

Qwik Signal Serialize

Qwik 如何实现更新 - SSR

9 Min Read

FRI MAY 12 2023

Written By Bobolo

随着 Qwik 发布 1.0 版本,框架就进入相对成熟的阶段,Qwik 怎么让 State 和 UI 建立起联系或者说怎么更新 Dom?这问题就值得探究了

Qwik 的 State 采用的是 Signal,跟 Vue、Preact、Solid 这些有点类似。它不像 React 对 Update 前后做对比,来确定更新的内容,而是通过通知订阅者状态更改去 Update State。有兴趣的可以看一下 Qwik 文章

它的原理虽然和 Vue、Preact、Solid 这些相似,但它将一部份逻辑做了 Lazy,下面主要就是探讨 Lazy 的 State 和 没有进行 Lazy 的 State 要怎么处理。

Excute Flow

Qwik 使用 Lazyload,如果 Lazyload 的 Code 和 State 无关,如 onClick$={() => alert(123)},这和 React 的 Click 回调中使用 import.then() 加载 Js 是一样。但现在 Web 应用不可能不需要 State,那 Qwik 又是如何把 State 进行 Lazy ?

要探究这个问题,首先需要从浏览器代码执行顺序去理解

  1. 浏览器访问 Server,Server Render 一个 HTML 作为 Response
  2. 浏览器加载和渲染 HTML
    • 用户如果有交互操作,再去下载以及执行 Lazy 的 JS
    • 用户没有交互则不进行处理

因此文章也会分为两部分,一部分是 SSR 生成的 HTML,另一部份是 Lazy 的部份,下面主要是说明 HTML 部分

Example

下面以 Qwik 的 init project 代码来作为案例,通过 count 的加减来去理解

import { component$, useSignal } from '@builder.io/qwik'
import styles from './counter.module.css'

export default component$(() => {
  const count = useSignal(70)

  return (
    <div class={styles['counter-wrapper']}>
      <button
        class="button-dark button-small"
        onClick$={() => (count.value -= 1)}
      >
        -
      </button>
      {count.value + 12}
      {count.value}
      <button
        class="button-dark button-small"
        onClick$={() => (count.value += 1)}
      >
        +
      </button>
    </div>
  )
})

在 Dev 环境下打包出来的界面

Render

当你点击 + 之后,Qwik 会去加载三个文件:

  • 切割出来的用户 click 回调函数的 JS,简称 click.js
  • @builder.io/qwik/core 是 Qwik 的核心代码
  • @builder.io/qwik/build 就是环境变量
// click.js
import { useLexicalScope } from '/node_modules/@builder.io/qwik/core.mjs?v=c847271f'
export const counter_component_div_button_onClick_1_LkCVrojX09Y = () => {
  const [count] = useLexicalScope()
  return (count.value += 1)
}

// @builder.io/qwik/build
export const isServer = false
export const isBrowser = true
export const isDev = true

这里先不对 click 后的代码进行分析,先看看这个单独的 HTML。

SSR

回顾 SSR 生成的 HTML,可以发现 Qwik 生成的 HTML 有以下几个特点

  1. 给 Dom 加上特有的 Attribute,如 q:idq:keyq:container
  2. 生成很多 Comment Dom,如 <!--t=9-->
  3. $ 包裹的代码都 split 为代码块并将交互方法加上 on:./chunk#symbol[captures] 为加载的 js 以及执行的 function name
  4. 加载 Service Worker
  5. 加载 <script type="qwik/json"> 里面的 obj 包含 refsctxobjssubs
    <script type="qwik/json">
     {
         "refs": {
             "8": "0",
             "b": "0"
         },
         "ctx": {},
         "objs": [
             "\u00121",
             70,
             "\u00110 @0",
             "#9",
             "\u00110 @1",
             "#a"
         ],
         "subs": [
             [
                 "3 #9 2 #9",
                 "3 #a 4 #a"
             ]
         ]
     }
    </script>
    
  6. 加载 <script q:func="qwik/json">,处理 document.currentScript.qFuncs
     <script q:func="qwik/json">
         document.currentScript.qFuncs=[
             (p0,)=>(p0.value+12),
             (p0,)=>(p0.value)
         ]
     </script>
    
  7. 加载 qwikloader
  8. 加载 <script>window.qwikevents.push("click")</script>
  9. 非 prod 环境还有一些 log、Error 等 script

以上就是 HTML 的基本内容,它跟常见的 HTML 还是有比较大的区别。

qwik/json

HTML 的 script 这里有两个 qwik/json,一个是一个 object,一个是处理 document.currentScript.qFuncs

先说 object,object 包含以下几个

  • refs: 生成 $refMap$,也就是 state 的 map,用来返回对应的 state,这个 state 带有发布订阅功能去进行 ui 的更新
  • ctx: containerState 的 context,类似于 react 的 context
  • objs: 包含 state 的类型('\u00121')、初始值(70)、基于 state 的衍生类型("\u00110") 和返回的 value (@ = 使用 qFuncs 数组,0 为第一个)、以及 dom 的位置('id = #9 nextNode')
  • subs: 包含 [dom 操作类型,dom 位置,使用 objs 中的 state 以及 value]

q:func="qwik/json" 就是 Qwik 在打包时根据项目对 state 的使用,直接生成的函数,用 state 作为参数进行处理并返回对应的结果。

这两部分都会在后续的 Lazy 时用到,在 HTML 中加载可以方便后续 Lazy 的时候恢复 State。

这里不确定大型项目时 Qwik 会如何处理,这部分逻辑在编译时,因为如果 State 非常多,也造成 HTML 变大,性能变差,这部分官方中并没有详细提及,需要查看源码

qwikloader

import type { QContext } from './core/state/context'

export const qwikLoader = (doc: Document, hasInitialized?: number) => {
  const Q_CONTEXT = '__q_context__'
  const win = window as any
  const events = new Set()

  const querySelectorAll = (query: string) => doc.querySelectorAll(query)

  const broadcast = (infix: string, ev: Event, type = ev.type) => {
    querySelectorAll('[on' + infix + '\\:' + type + ']').forEach(target =>
      dispatch(target, infix, ev, type)
    )
  }

  const getAttribute = (el: Element, name: string) => el.getAttribute(name)

  const resolveContainer = (containerEl: Element) => {
    if ((containerEl as any)['_qwikjson_'] === undefined) {
      const parentJSON =
        containerEl === doc.documentElement ? doc.body : containerEl
      let script = parentJSON.lastElementChild
      while (script) {
        if (
          script.tagName === 'SCRIPT' &&
          getAttribute(script, 'type') === 'qwik/json'
        ) {
          ;(containerEl as any)['_qwikjson_'] = JSON.parse(
            script.textContent!.replace(/\\x3C(\/?script)/g, '<$1')
          )
          break
        }
        script = script.previousElementSibling
      }
    }
  }

  const createEvent = (eventName: string, detail?: any) =>
    new CustomEvent(eventName, { detail })

  const dispatch = async (
    element: Element,
    onPrefix: string,
    ev: Event,
    eventName = ev.type
  ) => {
    const attrName = 'on' + onPrefix + ':' + eventName
    if (element.hasAttribute('preventdefault:' + eventName)) {
      ev.preventDefault()
    }
    const ctx = (element as any)['_qc_'] as QContext | undefined
    const qrls = ctx?.li.filter(li => li[0] === attrName)
    if (qrls && qrls.length > 0) {
      for (const q of qrls) {
        await q[1].getFn([element, ev], () => element.isConnected)(ev, element)
      }
      return
    }
    const attrValue = getAttribute(element, attrName)
    if (attrValue) {
      const container = element.closest('[q\\:container]')!
      const base = new URL(getAttribute(container, 'q:base')!, doc.baseURI)
      for (const qrl of attrValue.split('\n')) {
        const url = new URL(qrl, base)
        const symbolName =
          url.hash.replace(/^#?([^?[|]*).*$/, '$1') || 'default'
        const reqTime = performance.now()
        const module = import(url.href.split('#')[0])
        resolveContainer(container)
        const handler = (await module)[symbolName]
        const previousCtx = (doc as any)[Q_CONTEXT]
        if (element.isConnected) {
          try {
            ;(doc as any)[Q_CONTEXT] = [element, ev, url]
            emitEvent('qsymbol', {
              symbol: symbolName,
              element: element,
              reqTime,
            })
            await handler(ev, element)
          } finally {
            ;(doc as any)[Q_CONTEXT] = previousCtx
          }
        }
      }
    }
  }

  const emitEvent = (eventName: string, detail?: any) => {
    doc.dispatchEvent(createEvent(eventName, detail))
  }

  const camelToKebab = (str: string) =>
    str.replace(/([A-Z])/g, a => '-' + a.toLowerCase())

  const processDocumentEvent = async (ev: Event) => {
    let type = camelToKebab(ev.type)
    let element = ev.target as Element | null
    broadcast('-document', ev, type)

    while (element && element.getAttribute) {
      await dispatch(element, '', ev, type)
      element =
        ev.bubbles && ev.cancelBubble !== true ? element.parentElement : null
    }
  }

  const processWindowEvent = (ev: Event) => {
    broadcast('-window', ev, camelToKebab(ev.type))
  }

  const processReadyStateChange = () => {
    const readyState = doc.readyState
    if (
      !hasInitialized &&
      (readyState == 'interactive' || readyState == 'complete')
    ) {
      // document is ready
      hasInitialized = 1

      emitEvent('qinit')
      const riC = win.requestIdleCallback ?? win.setTimeout
      riC.bind(win)(() => emitEvent('qidle'))

      if (events.has('qvisible')) {
        const results = querySelectorAll('[on\\:qvisible]')
        const observer = new IntersectionObserver(entries => {
          for (const entry of entries) {
            if (entry.isIntersecting) {
              observer.unobserve(entry.target)
              dispatch(entry.target, '', createEvent('qvisible', entry))
            }
          }
        })
        results.forEach(el => observer.observe(el))
      }
    }
  }

  const addEventListener = (
    el: Document | Window,
    eventName: string,
    handler: (ev: Event) => void,
    capture = false
  ) => {
    return el.addEventListener(eventName, handler, { capture, passive: false })
  }

  const push = (eventNames: string[]) => {
    for (const eventName of eventNames) {
      if (!events.has(eventName)) {
        addEventListener(doc, eventName, processDocumentEvent, true)
        addEventListener(win, eventName, processWindowEvent)
        events.add(eventName)
      }
    }
  }

  if (!(doc as any).qR) {
    const qwikevents = win.qwikevents
    if (Array.isArray(qwikevents)) {
      push(qwikevents)
    }
    win.qwikevents = {
      push: (...e: string[]) => push(e),
    }
    addEventListener(doc, 'readystatechange', processReadyStateChange)
    processReadyStateChange()
  }
}

export interface QwikLoaderMessage extends MessageEvent {
  data: string[]
}

Qwik 提到跟其他框架对比只加载 1kb 的 JS 就是这个 qwikloader,这里需要和 <script>window.qwikevents.push("click")</script> 结合起来看

window.qwikevents.push ,应用所用到的交互方法,如项目中有使用 onClick$、onBlur 等就会有,因为需要在 Qwikloader 中注册拦截。

Qwikloader 做了以下几件事情

  1. 定义 window.qwikevents.push,push 就是在 doc 和 window 上 addEventListener 对应的 eventName
  2. readystatechange 时执行 processReadyStateChange,处理 qinitqidle,并将所有 qvisible 的 dom 都加上 IntersectionObserver,用来触发 dispatch
  3. 当用户交互(click、hover、visiable 等), 这将触发之前绑定的事件会调用 dispatch。
    1. dispatch 会根据 el 触发的事件(以 click 为例,则是 on:click) 获取 attrValue,也就是 ./chunk#symbol[captures]
    2. 解析 ./chunk#symbol[captures]
    3. import(chunk) 加载 chunk 并设置 doc.__q_context__ = [element, ev, url]
    4. chunk 加载完成后执行 module[symbol]
    5. 重置 doc.__q_context__ = previousCtx

Flow

qwik-loader

Conclusion

qwikloader 的逻辑并不复杂,qwik/json 和 dom 的一些 qwik 属性都是 Qwik 提前处理好的数据以及标记,方便 lazy 后去获取 value 或者定位 dom。所以整个更新机制复杂的地方在 lazy 部分。

虽然接下来主要探究 lazy 部分的 state 更新,但 SSR 的 HTML 也同样有值得深入探究的地方,如

  • Comment Dom 节点跟 Div 这些有什么不一样,为什么用它来定位
  • State 过多的时候,qwik/json 会不会太大,同样 funcs 也是如此
  • Qwik 在将 $ 的代码块 split 时的策略是什么

Reference

Powered by Bobolo

Copyright © Bobolo Blog 2021