Cloudflare Workers and micro-frontends の疑問点とその調査結果
ソース Cloudflare Workers and micro-frontends: made for one another
前提知識として、Qwikの理解が若干必要なので、先にこちらの記事に目を通されよ。
疑問①: どのようにエッジランタイムで結合しているのか?
各フラグメントのroot.tsxから <FragmentPlaceholder name="fragment-name" />
でフラグメントをコールしている。
FragmentPlaceholder
の内部処理は、SSRStream となっており、fetchFragment
を介してコンポネントのHTML が HTTP Stream で返却される。
fetchFragment
は サービスバインディングによるフラグメントのWorkerのコール
サービスバインディングは、fetchのインタフェースでリクエストするが、内部的にはV8 Isolateをローカルコールする仕組み。オーバヘッドがほとんど発生しない。
SSRStream
を使用しているので、内部に時間がかかる処理が挟まっても、それ以外のフラグメントで一旦レンダリングし、時間がかかる処理はWorker側でストリームで返却する。
ヘッダのGallery List Delay
をいじってNetworkを見るとストリームでHTMLが返却されているのがわかる。
Lag コンポネントでPromiseを返却している
Qwik - Resource (ReactのSuspenseみたいなやつ)
SSRStreamの課題として、間にレンダリングが遅いフラグメントが挟まると、レンダリングが完了するまで、後続のフラグメントのレンダリングができない事が挙げられる。
これに関しては、このサンプルでは特に対策は取られていない。
実際に、スライダーの値を大きくしてGallery部分のレンダリングを遅延させると、その間はFooter部分のレンダリングは行われない。
<head>
や <body>
はどう処理しているのか?
疑問②: フラグメントを結合する上で <head>
や <body>
などのタグが邪魔になりそうな気がするがどうしているのか?
ネットワークで素のレスポンスを確認するとルートがdivになっている
<div q:container="paused" q:version="0.10.0" q:render="ssr" q:base="/build/"><!--qv q:sstyle=⭐️cuc1el-0 q:id=0 q:key=D0PpYvQhIVM:--><style q:style="cuc1el-0">.input.⭐️cuc1el-0{padding:10px;width:350px;font-size:16px;border:1px solid #333;border-radius:2px}.input:focus.⭐️cuc1el-0{box-shadow:0 1px 6px #20212447;outline:0}.result-list.⭐️cuc1el-0{width:350px;box-sizing:border-box;border:1px solid #777;margin-top:1px;position:absolute;z-index:1;background-color:#fff;padding:0;box-shadow:0 4px 6px #20212447;border-radius:2px}.result-list-item.⭐️cuc1el-0{list-style-type:none}.link.⭐️cuc1el-0{display:block;font-size:18px;padding:.5rem 1rem;text-decoration:none;color:#33e}.link:hover.⭐️cuc1el-0,.link:focus.⭐️cuc1el-0{background-color:#efefef}.label.⭐️cuc1el-0{display:block;margin-right:5px;margin-bottom:5px;font-weight:700}
</style><div class="⭐️cuc1el-0" on-document:click="q-ec501eb2.js#s_Qr0cGy7uqkE[0 1]" q:id="1"><div class="⭐️cuc1el-0"><input tabIndex="0" autoFocus="" autoCorrect="off" autoComplete="off" autoCapitalize="off" type="text" placeholder="Search by tag (ex. classic)" value class="⭐️cuc1el-0 input" on:keyup="q-ec501eb2.js#s_PIcIHo6gw9c[0 1]" q:id="2"></div><!--qv q:sstyle=⭐️r8j49f-0 q:id=3 q:key=NL7YC7ROxOg:--><style q:style="r8j49f-0">.input.⭐️r8j49f-0{padding:10px;width:350px;font-size:16px;border:1px solid #333;border-radius:2px}.input:focus.⭐️r8j49f-0{box-shadow:0 1px 6px #20212447;outline:0}.result-list.⭐️r8j49f-0{width:350px;box-sizing:border-box;border:1px solid #777;margin-top:1px;position:absolute;z-index:1;background-color:#fff;padding:0;box-shadow:0 4px 6px #20212447;border-radius:2px}.result-list-item.⭐️r8j49f-0{list-style-type:none}.link.⭐️r8j49f-0{display:block;font-size:18px;padding:.5rem 1rem;text-decoration:none;color:#33e}.link:hover.⭐️r8j49f-0,.link:focus.⭐️r8j49f-0{background-color:#efefef}.label.⭐️r8j49f-0{display:block;margin-right:5px;margin-bottom:5px;font-weight:700}
</style><!--/qv--></div><!--/qv--><script type="qwik/json">{"ctx":{"#1":{"r":"3! 1!"},"#2":{"r":"5! 1!"},"#3":{"h":"2! 6","s":"7"}},"objs":[[],{"inputValue":"4","searchResults":"0"},{"listRef":"5!","state":"1!"},{"current":"#1"},"",{"current":"8"},"\u0002q-45af1044.js#s_NL7YC7ROxOg","r8j49f-0","\u0001"],"subs":[["_1","0 #3"],["_1","1 #0 1 #2 value inputValue","0 #3 searchResults"],["_2"]]}</script><link href="/build/q-ec501eb2.js" rel="prefetch" as="script"><link href="/build/q-6e19b970.js" rel="prefetch" as="script"><link href="/build/q-73bc5cdd.js" rel="prefetch" as="script"><script id="qwikloader">((e,t)=>{const n="__q_context__",o=window,a=new Set,i=t=>e.querySelectorAll(t),r=(e,t,n=t.type)=>{i("[on"+e+"\\:"+n+"]").forEach((o=>c(o,e,t,n)))},s=(e,t)=>new CustomEvent(e,{detail:t}),l=(t,n)=>(t=t.closest("[q\\:container]"),new URL(n,new URL(t.getAttribute("q:base"),e.baseURI))),c=async(t,o,a,i=a.type)=>{const r="on"+o+":"+i;t.hasAttribute("preventdefault:"+i)&&a.preventDefault();const s=t._qc_,c=null==s?void 0:s.li.filter((e=>e[0]===r));if(c&&c.length>0){for(const e of c)await e[1].getFn([t,a],(()=>t.isConnected))(a,t);return}const u=t.getAttribute(r);if(u)for(const o of u.split("\n")){const i=l(t,o),r=d(i),s=performance.now(),c=b(await import(i.href.split("#")[0]))[r],u=e[n];if(t.isConnected)try{e[n]=[t,a,i],f("qsymbol",{symbol:r,element:t,reqTime:s}),await c(a,t)}finally{e[n]=u}}},f=(t,n)=>{e.dispatchEvent(s(t,n))},b=e=>Object.values(e).find(u)||e,u=e=>"object"==typeof e&&e&&"Module"===e[Symbol.toStringTag],d=e=>e.hash.replace(/^#?([^?[|]*).*$/,"$1")||"default",p=e=>e.replace(/([A-Z])/g,(e=>"-"+e.toLowerCase())),v=async e=>{let t=p(e.type),n=e.target;for(r("-document",e,t);n&&n.getAttribute;)await c(n,"",e,t),n=e.bubbles&&!0!==e.cancelBubble?n.parentElement:null},w=e=>{r("-window",e,p(e.type))},y=()=>{var n;const r=e.readyState;if(!t&&("interactive"==r||"complete"==r)&&(t=1,f("qinit"),(null!=(n=o.requestIdleCallback)?n:o.setTimeout).bind(o)((()=>f("qidle"))),a.has("qvisible"))){const e=i("[on\\:qvisible]"),t=new IntersectionObserver((e=>{for(const n of e)n.isIntersecting&&(t.unobserve(n.target),c(n.target,"",s("qvisible",n)))}));e.forEach((e=>t.observe(e)))}},q=(e,t,n,o=!1)=>e.addEventListener(t,n,{capture:o}),g=t=>{for(const n of t)a.has(n)||(q(e,n,v,!0),q(o,n,w),a.add(n))};if(!e.qR){const t=o.qwikevents;Array.isArray(t)&&g(t),o.qwikevents={push:(...e)=>g(e)},q(e,"readystatechange",y),y()}})(document);</script><script>window.qwikevents.push("click", "keyup")</script></div>
QwikのrenderToStream
はルートのタグを指定してレンダリングできるらしい。
もともと、エッジランタイムでフラグメント組成することが想定してたということ?🤔
とりあえず、body とか head のタグは含んでいないので、そのまま結合してOKということ
疑問③: Qwik の初期スクリプトが複数回実行されないのか?
Resumableなコンポネントが含まれているフラグメントには、Qwikの初期化スクリプトが含まれている。
それが結合後にどうなるのか?
headerの初期化スクリプト(https://cloud-gallery-header.web-experiments.workers.dev/)
<script id="qwikloader">((e,t)=>{const n="__q_context__",o=window,a=new Set,i=t=>e.querySelectorAll(t),r=(e,t,n=t.type)=>{i("[on"+e+"\\:"+n+"]").forEach((o=>c(o,e,t,n)))},s=(e,t)=>new CustomEvent(e,{detail:t}),l=(t,n)=>(t=t.closest("[q\\:container]"),new URL(n,new URL(t.getAttribute("q:base"),e.baseURI))),c=async(t,o,a,i=a.type)=>{const r="on"+o+":"+i;t.hasAttribute("preventdefault:"+i)&&a.preventDefault();const s=t._qc_,c=null==s?void 0:s.li.filter((e=>e[0]===r));if(c&&c.length>0){for(const e of c)await e[1].getFn([t,a],(()=>t.isConnected))(a,t);return}const u=t.getAttribute(r);if(u)for(const o of u.split("\n")){const i=l(t,o),r=d(i),s=performance.now(),c=b(await import(i.href.split("#")[0]))[r],u=e[n];if(t.isConnected)try{e[n]=[t,a,i],f("qsymbol",{symbol:r,element:t,reqTime:s}),await c(a,t)}finally{e[n]=u}}},f=(t,n)=>{e.dispatchEvent(s(t,n))},b=e=>Object.values(e).find(u)||e,u=e=>"object"==typeof e&&e&&"Module"===e[Symbol.toStringTag],d=e=>e.hash.replace(/^#?([^?[|]*).*$/,"$1")||"default",p=e=>e.replace(/([A-Z])/g,(e=>"-"+e.toLowerCase())),v=async e=>{let t=p(e.type),n=e.target;for(r("-document",e,t);n&&n.getAttribute;)await c(n,"",e,t),n=e.bubbles&&!0!==e.cancelBubble?n.parentElement:null},w=e=>{r("-window",e,p(e.type))},y=()=>{var n;const r=e.readyState;if(!t&&("interactive"==r||"complete"==r)&&(t=1,f("qinit"),(null!=(n=o.requestIdleCallback)?n:o.setTimeout).bind(o)((()=>f("qidle"))),a.has("qvisible"))){const e=i("[on\\:qvisible]"),t=new IntersectionObserver((e=>{for(const n of e)n.isIntersecting&&(t.unobserve(n.target),c(n.target,"",s("qvisible",n)))}));e.forEach((e=>t.observe(e)))}},q=(e,t,n,o=!1)=>e.addEventListener(t,n,{capture:o}),g=t=>{for(const n of t)a.has(n)||(q(e,n,v,!0),q(o,n,w),a.add(n))};if(!e.qR){const t=o.qwikevents;Array.isArray(t)&&g(t),o.qwikevents={push:(...e)=>g(e)},q(e,"readystatechange",y),y()}})(document);</script>
<script>window.qwikevents.push("change", "input")</script>
filterの初期化スクリプト (https://cloud-gallery-filer.web-experiments.workers.dev/)
<script id="qwikloader">((e,t)=>{const n="__q_context__",o=window,a=new Set,i=t=>e.querySelectorAll(t),r=(e,t,n=t.type)=>{i("[on"+e+"\\:"+n+"]").forEach((o=>c(o,e,t,n)))},s=(e,t)=>new CustomEvent(e,{detail:t}),l=(t,n)=>(t=t.closest("[q\\:container]"),new URL(n,new URL(t.getAttribute("q:base"),e.baseURI))),c=async(t,o,a,i=a.type)=>{const r="on"+o+":"+i;t.hasAttribute("preventdefault:"+i)&&a.preventDefault();const s=t._qc_,c=null==s?void 0:s.li.filter((e=>e[0]===r));if(c&&c.length>0){for(const e of c)await e[1].getFn([t,a],(()=>t.isConnected))(a,t);return}const u=t.getAttribute(r);if(u)for(const o of u.split("\n")){const i=l(t,o),r=d(i),s=performance.now(),c=b(await import(i.href.split("#")[0]))[r],u=e[n];if(t.isConnected)try{e[n]=[t,a,i],f("qsymbol",{symbol:r,element:t,reqTime:s}),await c(a,t)}finally{e[n]=u}}},f=(t,n)=>{e.dispatchEvent(s(t,n))},b=e=>Object.values(e).find(u)||e,u=e=>"object"==typeof e&&e&&"Module"===e[Symbol.toStringTag],d=e=>e.hash.replace(/^#?([^?[|]*).*$/,"$1")||"default",p=e=>e.replace(/([A-Z])/g,(e=>"-"+e.toLowerCase())),v=async e=>{let t=p(e.type),n=e.target;for(r("-document",e,t);n&&n.getAttribute;)await c(n,"",e,t),n=e.bubbles&&!0!==e.cancelBubble?n.parentElement:null},w=e=>{r("-window",e,p(e.type))},y=()=>{var n;const r=e.readyState;if(!t&&("interactive"==r||"complete"==r)&&(t=1,f("qinit"),(null!=(n=o.requestIdleCallback)?n:o.setTimeout).bind(o)((()=>f("qidle"))),a.has("qvisible"))){const e=i("[on\\:qvisible]"),t=new IntersectionObserver((e=>{for(const n of e)n.isIntersecting&&(t.unobserve(n.target),c(n.target,"",s("qvisible",n)))}));e.forEach((e=>t.observe(e)))}},q=(e,t,n,o=!1)=>e.addEventListener(t,n,{capture:o}),g=t=>{for(const n of t)a.has(n)||(q(e,n,v,!0),q(o,n,w),a.add(n))};if(!e.qR){const t=o.qwikevents;Array.isArray(t)&&g(t),o.qwikevents={push:(...e)=>g(e)},q(e,"readystatechange",y),y()}})(document);</script>
<script>window.qwikevents.push("click", "keyup")</script>
結合後は特にマージされるようなことはされていなかった。
何回実行されても良いように設計されているみたい。
まあそのへんはしゃーないか
疑問④: アセットはどうやって配信しているのか?
それぞれのフラグメントワーカーが保持しているアセット(QwikのResumableなスクリプトなど)は誰が返却するのか?
ルート(main)のentry.ssr.tsx
でアセットのルーティングをしている
各フラグメントではアセットにベースパスを付けて配信している
mainのworkerが受けて、サービスバインディングで各フラグメントへProxyしているようなイメージ
疑問⑤: Qwik の prefetch は機能しているのか?
プリフェッチされている。
Resumableなフラグメントコンポネントにlinkプリフェッチ用のが含まれている。
プリフェッチストラテジーが implementation: { linkInsert: "html-append" }
になっている。
流石にWeb Worker利用で結合することはできないっぽい。
mainのWorkerだけ Qwik City いれて、マニフェストファイル頑張って弄くれば、もしかしたら Service Worker でのプリフェッチができるようになるかも。
一応サンプルでは機能している。ただ link でのプリフェエッチにしか対応できていない
ただ、CDNエッジから返却できるので、プリフェッチしなくても追加スクリプトのダウンロードは、ストレスになるほどのレイテンシは乗らないかも。
疑問⑥: コンポネント間のステートの共有は?
フィルタキーワードの共有はURLパラメータ
ヘッダのスライダはCookieで共有 (これはサーバサイドでも利用するので)
単純にSSRしたHTMLをWorkerで結合して組成しているだけなので、ステートを共有する仕組みは特にない。
これに関しては人によって意見分かれそう。
Remixなどはクライアントから状態管理を排除する傾向にあり、状態をCookieで管理して、ネストルート単位でサーバサイドと細かく通信することで一貫性をもたせようとしている。
なので、個人的にはフラグメントがそれぞれWorkerと通信してリフレッシュしてくれれば、クライアント側でステートを共有する方法がなくても問題はないが、現状そういう仕組みがあるわけではないのでそこは課題。
一応 Qwik (City) も Remix とか Next.js のように、ナビゲートやリフレッシュの際にドキュメントをフルロードするということが回避されるような仕組みをもっている。
なので、もう少し頑張れば、クライアントサイドでのステート共有(管理)を完全にやめて、サーバサイドに移譲するということもできなくはないはず。
その他: なぜ選ばれたのは Qwik だったのか?
これは laiso さんも記事で述べているように、Qwik のフットプリントが小さかったのが一番大きな理由だと思う。
既存のマイクロフロントエンドアーキテクチャのクライアントサイドで組成するものに対して問題点を指摘したいのでブラウザ上でのフットプリントが小さいQwikを使ったのだと思う。Svelteなどでも同じデモは作れそう。
Reactみたいなでかいスクリプトがいらず、ResumableなQwikならモジュールフェデレーションはあんまり考慮しなくていいよねという感じだと思う。結局クライアント側でサイズが大きいライブラリを入れ始めたら無視できなくなると思うけど。