随着 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 ?
要探究这个问题,首先需要从浏览器代码执行顺序去理解
- 浏览器访问 Server,Server Render 一个 HTML 作为 Response
- 浏览器加载和渲染 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 环境下打包出来的界面
当你点击 + 之后,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 有以下几个特点
- 给 Dom 加上特有的 Attribute,如
q:id
、q:key
、q:container
等 - 生成很多 Comment Dom,如
<!--t=9-->
- $ 包裹的代码都 split 为代码块并将交互方法加上
on:
,./chunk#symbol[captures]
为加载的 js 以及执行的 function name - 加载 Service Worker
- 加载
<script type="qwik/json">
里面的 obj 包含refs
、ctx
、objs
、subs
<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>
- 加载
<script q:func="qwik/json">
,处理document.currentScript.qFuncs
<script q:func="qwik/json"> document.currentScript.qFuncs=[ (p0,)=>(p0.value+12), (p0,)=>(p0.value) ] </script>
- 加载 qwikloader
- 加载
<script>window.qwikevents.push("click")</script>
- 非 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 做了以下几件事情
- 定义 window.qwikevents.push,push 就是在 doc 和 window 上 addEventListener 对应的 eventName
- readystatechange 时执行 processReadyStateChange,处理
qinit
、qidle
,并将所有qvisible
的 dom 都加上 IntersectionObserver,用来触发 dispatch - 当用户交互(click、hover、visiable 等), 这将触发之前绑定的事件会调用 dispatch。
- dispatch 会根据 el 触发的事件(以 click 为例,则是 on:click) 获取 attrValue,也就是
./chunk#symbol[captures]
- 解析
./chunk#symbol[captures]
- import(chunk) 加载 chunk 并设置
doc.__q_context__ = [element, ev, url]
- chunk 加载完成后执行
module[symbol]
- 重置
doc.__q_context__ = previousCtx
- dispatch 会根据 el 触发的事件(以 click 为例,则是 on:click) 获取 attrValue,也就是
Flow
Conclusion
qwikloader 的逻辑并不复杂,qwik/json 和 dom 的一些 qwik 属性都是 Qwik 提前处理好的数据以及标记,方便 lazy 后去获取 value 或者定位 dom。所以整个更新机制复杂的地方在 lazy 部分。
虽然接下来主要探究 lazy 部分的 state 更新,但 SSR 的 HTML 也同样有值得深入探究的地方,如
- Comment Dom 节点跟 Div 这些有什么不一样,为什么用它来定位
- State 过多的时候,qwik/json 会不会太大,同样 funcs 也是如此
- Qwik 在将 $ 的代码块 split 时的策略是什么