Open12

Cloudflare Workers and micro-frontends の疑問点とその調査結果

aiji42aiji42

疑問①: どのようにエッジランタイムで結合しているのか?

各フラグメントのroot.tsxから <FragmentPlaceholder name="fragment-name" /> でフラグメントをコールしている。

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/main/src/root.tsx#L18-L30

FragmentPlaceholder の内部処理は、SSRStream となっており、fetchFragmentを介してコンポネントのHTML が HTTP Stream で返却される。

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/helpers/src/fragmentHelpers.tsx#L3-L20

fetchFragmentサービスバインディングによるフラグメントのWorkerのコール

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/helpers/src/fragmentHelpers.tsx#L47-L69

サービスバインディングは、fetchのインタフェースでリクエストするが、内部的にはV8 Isolateをローカルコールする仕組み。オーバヘッドがほとんど発生しない。

aiji42aiji42

SSRStreamを使用しているので、内部に時間がかかる処理が挟まっても、それ以外のフラグメントで一旦レンダリングし、時間がかかる処理はWorker側でストリームで返却する。

https://cloud-gallery.web-experiments.workers.dev/

ヘッダのGallery List DelayをいじってNetworkを見るとストリームでHTMLが返却されているのがわかる。


Lag コンポネントでPromiseを返却している

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/gallery/src/root.tsx

Qwik - Resource (ReactのSuspenseみたいなやつ)
https://qwik.builder.io/docs/components/resource/

aiji42aiji42

SSRStreamの課題として、間にレンダリングが遅いフラグメントが挟まると、レンダリングが完了するまで、後続のフラグメントのレンダリングができない事が挙げられる。
これに関しては、このサンプルでは特に対策は取られていない。
実際に、スライダーの値を大きくしてGallery部分のレンダリングを遅延させると、その間はFooter部分のレンダリングは行われない。

aiji42aiji42

疑問②: <head><body> はどう処理しているのか?

フラグメントを結合する上で <head><body> などのタグが邪魔になりそうな気がするがどうしているのか?

https://cloud-gallery-gallery.web-experiments.workers.dev/

https://cloud-gallery-filter.web-experiments.workers.dev/

ネットワークで素のレスポンスを確認するとルートが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はルートのタグを指定してレンダリングできるらしい。

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/filter/src/entry.ssr.tsx

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/helpers/src/renderResponse.ts

もともと、エッジランタイムでフラグメント組成することが想定してたということ?🤔

とりあえず、body とか head のタグは含んでいないので、そのまま結合してOKということ

aiji42aiji42

疑問③: 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>

結合後は特にマージされるようなことはされていなかった。
何回実行されても良いように設計されているみたい。

https://cloud-gallery.web-experiments.workers.dev/

まあそのへんはしゃーないか

aiji42aiji42

疑問④: アセットはどうやって配信しているのか?

それぞれのフラグメントワーカーが保持しているアセット(QwikのResumableなスクリプトなど)は誰が返却するのか?

ルート(main)のentry.ssr.tsxでアセットのルーティングをしている

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/main/src/entry.ssr.tsx

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/helpers/src/fragmentHelpers.tsx#L28-L45

各フラグメントではアセットにベースパスを付けて配信している

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/helpers/src/renderResponse.ts#L38-L45

mainのworkerが受けて、サービスバインディングで各フラグメントへProxyしているようなイメージ

aiji42aiji42

疑問⑤: Qwik の prefetch は機能しているのか?

プリフェッチされている。

Resumableなフラグメントコンポネントにlinkプリフェッチ用のが含まれている。

プリフェッチストラテジーが implementation: { linkInsert: "html-append" } になっている。
流石にWeb Worker利用で結合することはできないっぽい。

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/helpers/src/renderResponse.ts#L15-L46

mainのWorkerだけ Qwik City いれて、マニフェストファイル頑張って弄くれば、もしかしたら Service Worker でのプリフェッチができるようになるかも。

一応サンプルでは機能している。ただ link でのプリフェエッチにしか対応できていない

ただ、CDNエッジから返却できるので、プリフェッチしなくても追加スクリプトのダウンロードは、ストレスになるほどのレイテンシは乗らないかも。

aiji42aiji42

疑問⑥: コンポネント間のステートの共有は?

フィルタキーワードの共有はURLパラメータ

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/filter/src/root.tsx

ヘッダのスライダはCookieで共有 (これはサーバサイドでも利用するので)

https://github.com/cloudflare/workers-web-experiments/blob/main/cloud-gallery/header/src/Slider/Slider.tsx

単純にSSRしたHTMLをWorkerで結合して組成しているだけなので、ステートを共有する仕組みは特にない。


これに関しては人によって意見分かれそう。

Remixなどはクライアントから状態管理を排除する傾向にあり、状態をCookieで管理して、ネストルート単位でサーバサイドと細かく通信することで一貫性をもたせようとしている。
なので、個人的にはフラグメントがそれぞれWorkerと通信してリフレッシュしてくれれば、クライアント側でステートを共有する方法がなくても問題はないが、現状そういう仕組みがあるわけではないのでそこは課題。

aiji42aiji42

一応 Qwik (City) も Remix とか Next.js のように、ナビゲートやリフレッシュの際にドキュメントをフルロードするということが回避されるような仕組みをもっている。
なので、もう少し頑張れば、クライアントサイドでのステート共有(管理)を完全にやめて、サーバサイドに移譲するということもできなくはないはず。

aiji42aiji42

その他: なぜ選ばれたのは Qwik だったのか?

これは laiso さんも記事で述べているように、Qwik のフットプリントが小さかったのが一番大きな理由だと思う。

https://zenn.dev/laiso/articles/972b9d82030542

既存のマイクロフロントエンドアーキテクチャのクライアントサイドで組成するものに対して問題点を指摘したいのでブラウザ上でのフットプリントが小さいQwikを使ったのだと思う。Svelteなどでも同じデモは作れそう。

Reactみたいなでかいスクリプトがいらず、ResumableなQwikならモジュールフェデレーションはあんまり考慮しなくていいよねという感じだと思う。結局クライアント側でサイズが大きいライブラリを入れ始めたら無視できなくなると思うけど。