Johnny Wang BlogJohnny Wang Blog
Articles
Project
Live2d
johnnywang/book
Articles
Project
Live2d
johnnywang/book
  • About Me

    • 關於 Johnny
    • 文章清單
  • Javascript

    • View Transitions - 認識並使用原生 document 轉場效果
    • 用 Web Container 打造自己的線上 NodeJS 開發環境
    • 來試用看看原生 Web Popover API
    • 在電腦裡搞一個 RWKV AI 小助手
    • 遊戲 App 素材解包學習紀錄
    • 動手自己做一個 ChatGPT UI 工具吧
    • Create a React Server Components Project without NextJS - 製作一個不依賴 NextJS 的 React Server Components 專案
    • 如何在 Vuepress 裡快樂寫 React
    • 從 Mock Service Worker 源碼中學習
      • 前言
      • Mock 操作種類
      • MSW 2.0
      • mockServiceWorker.js
      • setupWorker.ts
      • 流程總結
      • 感想
    • 擺脫 Node modules 地獄,擁抱 Yarn Plug'n'Play(PnP)
    • 快速上手 NextJS v13 - 基礎觀念 AppRouter 篇
    • 快速上手 NextJS v13 - Data Fetching, Caching, Revalidating 篇
    • 用 Socket.io 搭配 Matterjs 製作一款 Real-Time Canvas 聊天室(文長慎入)
    • 如何只用一支 CDN 及 4行設定,讓瀏覽器讀懂 Typescript, React, Vue
    • 用 tsup 快速建立 Typescript 開發環境
    • 開發 Email EDM 你可以更輕鬆
    • 關於我的 Side project - Maju Web Editor
    • Web URL 中神奇的 createObjectURL method
    • Awesome Vite 一個由社群維護的 template 集大成
    • Vue、React 使用心得分享(文長慎入)
    • 一起動手用 Socket.io 和 Peerjs 打造 WebRTC 即時視訊
    • Temporal Typescript SDK 學習筆記
    • React Web3 Storage
    • React useState 取得最新值
    • Babel 7 Decorator 的神奇小問題
    • 用 Koa + Vite + Pinia 打造基礎 SSR 環境
    • Live2d 官方範例改寫
    • 如何把 Video 畫在 Canvas 上
    • 史上最簡單的 Webpack 5 教學
    • 在瀏覽器中直接 import Vue SFC 開發起來
    • 手寫一個可中斷的 delay promise
    • NodeJS 輕量開發框架 Expressjs 與 Koa2 的區別
    • 用 2D 物理引擎 Matterjs 製作經典馬力歐 1-1
    • 原生 Javascript 的類型標註工具 JSDoc
    • Pinia - Vuex 的後繼者
    • 用 Nodejs 寫個 FTP command line 工具
    • Vue3 Server Render 手把手帶你搭建
    • 你真的懂 Event Loop 嗎
    • Babel7 基本介紹與使用
    • Vue/Vitejs 部分源碼解析
    • Vuejs 依賴追蹤 2020 版
    • Vuejs 依賴追蹤 2019 版
    • Js literal 模板編譯
    • 用 JavaScript 鎖定用戶調整畫面比例
    • Date.now() 與 new Date()
    • Promise 相關知識
    • Web Component 學習筆記
    • TypeScript 基礎篇
    • TypeScript 進階篇
    • MVC, MVVM, MVI 軟體設計架構
    • MVVM 簡單模擬框架實作
  • CSS & Sass

    • 2024 CSS 年度報告筆記
    • 2021 CSS 年度報告筆記
    • 如何不用 setTimeout 幫 display: none 的 DOM 加動畫
    • 純 CSS 實作星球環繞動畫效果
    • 差點錯過的 Tailwindcss 入門學習筆記
    • Tailwindcss 進階學習筆記
    • Sass, SCSS Built-In-Modules 內建模組與函數
    • SCSS Parent Selector
    • CSS 畫面鎖橫屏時,滾動的問題!!
    • Safari 使用 animation 時動態產生 rem 的坑
    • CSS: mix-blend-mode 屬性混合圖層動畫
  • Memo

    • Javascript

      • Yarn Plug'n'Play (PnP) 的 VSCode 設定方式
      • 如何用 Web API 讀取剪貼簿內容
      • CSP (Content Security Policy) 是什麼?
      • Astro 入門學習筆記
      • 如何解決 Astro 套用主題切換時,畫面抖動瞬閃問題
      • Rspack - 以 Rust 打造的快速構建工具
      • Typescript v5 Decorator 學習筆記
      • Sequelize 筆記
      • ES2022 學習筆記
      • Crypto 密碼加密方法
      • 實作 Test 的注意事項
      • Jest 測試工具 - 基礎篇
      • 正則表達式
      • Rxjs 學習筆記
      • Gulp 學習筆記
      • Clean Code Javascript 學習
      • JS 實戰開發特殊小技巧
    • React

      • React forwardRef 使用筆記
      • 如何在 Nextjs 中使用 middleware set cookie
      • React Styled-Components 基礎篇
      • React Styled-Components 進階篇
      • React Unit Test
      • React Testing Library
      • React Emotion Basic
      • React Styled-JSX
      • React 18 - Concurrent Features Memo
      • useEffect 的高階封裝範例
      • React useContext & useReducer 搭配
      • React Router Config 筆記
    • Vue

      • Cypress Vue
      • Vue 單元測試學習筆記
      • 學習 Vuetify 的一些筆記
      • Vuex 學習筆記
    • React Native

      • React Native - basic
      • Expo Basic
      • Expo Router
      • Expo Build
      • Issues
    • GraphQL

      • GraphQL 學習筆記 - 基礎篇
      • GraphQL 學習筆記 - 進階篇
      • Apollo Client 學習使用筆記
      • GraphQL Memo
    • Parse

      • Parse Javascript 文檔閱讀筆記
      • Parse User Object 章節
      • Parse Session Object 章節
      • Parse Schema Object 章節
      • Parse Cloud Code 章節
    • CSS

      • Sass, SCSS 基礎筆記
    • Docker

      • Docker 基礎技術
      • Dockerfile 技術篇
      • Docker-compose 技術篇 - 3.7
      • Kubernetes 學習筆記
      • ArgoCD 學習筆記
      • Podman 學習筆記
      • Colima 安裝使用筆記
    • Git Learning

      • Git 版本控制
      • Git 基礎使用 - init, clone, add, commit, status, log
      • Git 分支
      • Git Merge 分支合併指令
      • Git Stash 保存紀錄
      • Git 基礎復原指令 - restore
      • Remote 遠端協同工作 - remote, fetch, pull, push
      • Git Tag 標籤 - tag
      • Git 設定 - config
      • Git 檔案比對與差異 - diff
      • Git Rebase 定義分支的參考基準
      • Subtree 子樹
      • Git Reflog 指令紀錄
      • Git Filter Branch
      • Git Crypt 使用筆記
      • Git 客製化工具
    • Bash

      • Bash 基礎概念
      • Variable 變數
      • Script 腳本撰寫
      • Condition 流程控制
      • Operation 算數表達式
      • Function 函數
      • Command Line Params 指令列參數處理
    • Patterns

      • Introduce
      • Design Patterns

        • Singleton Pattern
        • Compound Pattern
        • Proxy Pattern
        • Hooks Pattern
        • Observer Pattern
        • Mediator/Middleware Pattern
        • HOC Pattern
        • Factory Pattern
        • Render Props Pattern
        • Flyweight Pattern
        • Container/Presentational Component
        • Prototype Pattern
        • Mixin Pattern
        • Provider Component
        • Command Component
        • Module Pattern
      • Render Patterns

        • Rendering Patterns 渲染模式介紹
    • FE 性能優化

      • 前端性能優化
      • 靜態資源優化
      • 頁面渲染架構優化
      • Server 與 Network 優化
      • 開發流程、監控體系優化
    • Ollama 筆記
    • Nginx JS module 基本使用筆記
    • FlowiseAI 使用介紹筆記
    • Github Copilot 使用筆記
    • Traefik Memo
    • API First 學習筆記
    • What is AC - Acceptance Criteria 驗收條件
    • Mermaid 學習筆記
    • 串接 Sonarcube 筆記
    • Youtube Data API 基礎使用筆記
    • FB API 學習筆記
    • VSCode 好用快捷鍵
    • 在 Simulator 上開發測試
    • 問題修正紀錄
  • References

    • 全端開發學習資源
    • 實用工具
    • 網頁前端課程目錄
  • Daily

    • 中醫學習

      • 中醫自學方向
      • 黃帝內經-素問
      • 基礎理論

        • 中藥治病
        • 中藥產地與採收
        • 陰陽五行
        • 精氣血津液
        • 整體觀與臟象
        • 病因病機
        • 辨證論治
        • 四診八綱
        • 五運六氣
        • 經絡
      • 診斷學

        • 辯證
        • 望診
        • 聞診
        • 問診
        • 脈診簡史
        • 切診
        • 常見脈象與臨床意義
        • 經脈辨證
        • 中醫的六邪在疾病初期、後期分別有那些症狀
      • 中藥學

        • 中藥基礎理論
        • 常見中藥材
        • 解表藥(30)
        • 清熱藥(82)
        • 瀉下藥(16)
        • 利水渗湿药(8)
        • 化痰止咳平喘药(15)
        • 止血藥(8)
        • 開竅-平肝-安神藥(11)
        • 化濕藥(5)
        • 祛風濕藥(12)
        • 理氣藥(13)
        • 活血祛瘀藥(7)
        • 消食藥(4)
        • 驅蟲藥(6)
        • 祛寒药(6)
        • 補虛藥(43)
        • 收斂藥(13)
      • 方劑學

        • 總論
        • 解表劑
        • 瀉下劑
        • 清熱劑
        • 理血劑
        • 去暑劑
        • 去濕劑
      • 針灸學

        • 特定穴
      • 其他

        • 內證觀察筆記 上篇【內證漫談】
        • 內證觀察筆記 中篇【太極器官、五藏】
        • 內證觀察筆記 下篇【十二正經】
        • 磁力對生理的影響
    • 易經學習

      • 易經入門
    • 道德經學習

      • 道德經學習重點筆記
      • 第一章 道可道,非常道
      • 第二章 夫唯弗居,是以不去
      • 第三章 不尚賢,使民不爭
      • 第四章 道沖而用之或不盈,淵兮似萬物之宗
      • 第五章 天地不仁,以萬物為芻狗,多聞數窮,不如守中
      • 第六章 谷神不死,是謂玄牝
      • 第七章 天長地久。天地所以能長且久者,以其不自生,故能長生。
      • 第八章 上善若水。水善利萬物而不爭,處眾人之所惡,故幾於道
      • 第九章 功成身退,天之道也。
      • 第十章 載營魄抱一,能無離乎﹖
      • 第十一章 有之以為利,無之以為用
      • 第十二章 五色令人目盲,五音令人耳聾,五味令人口爽,馳騁畋獵令人心發狂,難得之貨令人行妨。
      • 第十三章 寵辱若驚,貴大患若身。愛以身為天下,若可托天下
      • 第十四章 是謂無狀之狀,無物之象,是謂惚恍。迎之不見其首,隨之不見其後。執古之道,以御今之有。
      • 第十五章 保此道者,不欲盈。夫唯不盈,故能蔽而新成。
      • 第十六章 致虛極,守靜篤。萬物並作,吾以觀復。
      • 第十七章 太上,下知有之。悠兮其貴言,功成事遂,百姓皆謂我自然。
      • 第十八章 大道廢,有仁義;智慧出,有大偽;
      • 第十九章 絕聖棄智,民利百倍。見素抱樸,少私寡欲
      • 第二十章 絕學無憂。眾人皆有餘,而我獨若遺。
      • 第二十一章 道之為物,惟恍惟惚。惚兮恍兮,其中有象;恍兮惚兮,其中有物。
      • 第二十二章 曲則全,枉則直,窪則盈,敝則新,少則得,多則惑。是以聖人抱一為天下式
      • 第二十三章 希言自然。天地尚不能久,而況於人乎?信不足焉,有不信焉。
      • 第二十四章 企者不立,跨者不行
      • 第二十五章 有物混成,先天地生。人法地,地法天,天法道,道法自然。
      • 第二十六章 重為輕根,靜為躁君。輕則失本,躁則失君。
      • 第二十七章 善行無轍跡,善言無瑕謫。故善人者,不善人之師;不善人者,善人之資。不貴其師,不愛其資,雖智大迷,是謂要妙。
      • 第二十八章 知雄守雌,為天下谿,知榮守辱,為天下谷
      • 第二十九章 天下神器,不可為也,是以聖人去甚,去奢,去泰。
      • 第三十章 以道佐人主者,不以兵強天下
      • 第三十一章 夫兵者,不祥之器,物或惡之,故有道者不處。
      • 第三十二章 道常無名,樸雖小,天下莫能臣也。
      • 第三十三章 知人者智,自知者明。
      • 第三十四章 大道氾兮,其可左右。以其終不自為大,故能成其大。
      • 第三十五章 執大象,天下往。往而不害,安平太。樂與餌,過客止。
      • 第三十六章 將欲歙之,必固張之;將欲弱之,必固強之
      • 第三十七章 道常無為而無不為
      • 第三十八章 上德不德,是以有德;下德不失德,是以無德
      • 第三十九章 昔之得一者,天得一以清。不欲琭琭如玉,珞珞如石。
      • 第四十章 反者道之動,弱者道之用。天下萬物生於有,有生於無
      • 第四十一章 上士聞道,勤而行之;大方無隅,大器晚成,大音希聲,大象無形,道隱無名。
      • 第四十二章 道生一,一生二,二生三,三生萬物。
      • 第四十三章 天下之至柔,馳騁天下之至堅
      • 第四十四章 名與身孰親﹖身與貨孰多﹖得與亡孰病﹖
      • 第四十五章 大成若缺,其用不弊。大盈若沖,其用不窮。
      • 第四十六章 天下有道,卻走馬以糞。天下無道,戎馬生於郊。
      • 第四十七章 不出戶,知天下;不窺牖,見天道。
      • 第四十八章 為學日益,為道日損。損之又損,以至於無為。
      • 第四十九章 聖人無常心,以百姓心為心。
      • 第五十章 出生入死。生之徒,十有三;死之徒,十有三;人之生,動之死地,亦十有三。
      • 第五十一章 道生之,德畜之,物形之,勢成之。
      • 第五十二章 天下有始,以為天下母。以知其子,既知其子,復守其母,沒身不殆。
      • 第五十三章 使我介然有知,行於大道,唯施是畏。大道甚夷,而人好徑。
      • 第五十四章 善建者不拔,善抱者不脫,子孫以祭祀不輟。
      • 第五十五章 含德之厚,比於赤子。知和曰常,知常曰明。益生曰祥。心使氣曰強。
      • 第五十六章 知者不言,言者不知。
      • 第五十七章 以正治國,以奇用兵,以無事取天下。
      • 第五十八章 其政悶悶,其民淳淳;其政察察,其民缺缺。禍兮福之所倚,福兮禍之所伏。
      • 第五十九章 治人事天,莫若嗇。夫唯嗇,是謂早服
      • 第六十章 治大國,若烹小鮮。夫兩不相傷,故德交歸焉。
      • 第六十一章 大國者下流,天下之交。天下之牝,牝常以靜勝牡,以靜為下。
      • 第六十二章 道者萬物之奧。雖有拱璧以先駟馬,不如坐進此道。
      • 第六十三章 為無為,事無事,味無味。圖難於其易,為大於其細;
      • 第六十四章 合抱之木,生於毫末;九層之臺,起於累土;千里之行,始於足下。為者敗之,執者失之。
      • 第六十五章 古之善為道者,非以明民,將以愚之。
      • 第六十六章 是以欲上民,必以言下之。欲先民,必以身後之。
      • 第六十七章 天下皆謂我道大,似不肖。夫唯大,故似不肖。
      • 第六十八章 善為士者不武,善戰者不怒,善勝敵者不與
      • 第六十九章 吾不敢為主而為客,不敢進寸而退尺。禍莫大於輕敵,輕敵幾喪吾寶。
      • 第七十章 吾言甚易知,甚易行。天下莫能知,莫能行。
      • 第七十一章 知不知上,不知知病。夫唯病病,是以不病。
      • 第七十二章 民不畏威,則大威至。無狎其所居,無厭其所生。夫唯不厭,是以不厭。
      • 第七十三章 天之道,不爭而善勝,不言而善應,不召而自來,繟然而善謀。
      • 第七十四章 民不畏死,奈何以死懼之?若使民常畏死,而為奇者,吾得執而殺之,孰敢?
      • 第七十五章 民之饑,以其上食稅之多,是以饑。
      • 第七十六章 人之生也柔弱,其死也堅強。萬物草木之生也柔脆,其死也枯槁。
      • 第七十七章 天之道,其猶張弓與﹖高者抑之,下者舉之;有餘者損之,不足者補之。
      • 第七十八章 天下莫柔弱於水,而攻堅強者莫之能勝,以其無以易之。
      • 第七十九章 和大怨,必有餘怨,安可以為善﹖
      • 第八十章 小國寡民。使有什伯之器而不用,使民重死而不遠徙。
      • 第八十一章 信言不美,美言不信。天之道,利而不害;聖人之道,為而不爭。
    • 人類真相推廣協會

      • 人類真相介紹
      • 人生大挑戰-閱讀筆記
      • 書籍-人生大挑戰

        • 序文
        • 童年的回憶
        • 賺外快的童年
        • 養家的童年
        • 少年時期的回憶
        • 粉紅睡衣女鬼的祕密
        • 麵線、甘蔗和賽鴿
        • 我在黑社會的日子
        • 『台北一條龍』
        • 賭徒‧妻子‧盤仔人
        • 人鬼之戰—正邪不分的恐怖
        • 開展創業石銅雕畫的日子
        • 渡畜牲者‧瞎掰鬼與邪靈
        • 無所不在的陷阱—邪靈的詭計
        • 鬼屋‧符令‧大揭祕
        • 妖魔鬼怪大變身—邪靈與動物
        • 乩童與宮廟的祕密
        • 活鬼纏身的恐怖亂象
        • 前面親兄弟、後面無情義的假面
        • 前面手牽手、後面下毒手的險境
        • 人心險惡、五馬分屍所逼自殺的真相
        • 自殺後的奇遇—人死後的世界
        • 我自殺後的奇遇—「陰間地府處」與「陰府大本營」
        • 我被趕出家門、面臨眾叛親離的苦境
        • 其他
    • 英文學習

      • 自我介紹
      • 簡單寒暄
      • 指路
      • 買賣
      • 轉接電話
      • 通勤
      • 訂位
      • 數字
    • 金剛經 時間不存在?自我是虛擬?
    • 20250319 外婆過世
    • 說服的藝術:避免陷阱,建立共識
    • 禪修一定要去除「如何」嗎?
    • 不對稱的回報
    • 避免慢性壓力
    • 關鍵溝通表達力
    • 快樂的秘訣
    • 生活在現代社會,維持高品質思考的重要性
    • 心理學家的面相術:解讀情緒的密碼
    • 什麼是真相,真相是什麼
    • 身為一位歷史觀察者(文長慎入)
    • 2022 Leisure Learn
    • 兩年八個月,我從 Garena 畢業了
    • DeFi
    • 我的終生大事,交往到結婚
    • 轉職前端工程師 3 年多的回顧
    • MacOS + Iterm2 + Oh My Zsh
    • 我喜歡的名言佳句
    • Web Interview Preparation

從 Mock Service Worker 源碼中學習

Share On:

FacebookLINEMessengerTelegram

前言

嗨大家好,我是 Johnny,最近閒暇時我在想,到底 msw 是如何做到 web 與 service worker 之間的溝通,一直以為 msw 只是單純發個請求給 service worker 後,service worker 再直接把配對到的內容丟回給 web 這樣,但看完 msw 的源碼後才發現,我還是太嫩了QQ,人家根本不只是這麼單純的丟過去丟回來而已...

Mock 操作種類

相信各位前端開發者們,都有用過各種 Mock 服務來進行測試與開發,但其中的原理,根據不同工具其底層的架構跟作法都不太一樣,舉例來說,常見有以下幾種 mock 的原理作法

  • 套件內部攔截:像是 axios 內建有 Request interceptor 讓開發者在使用 axios 工具實際送出 request 之前就攔截返回預先訂制的 response content,好處是不改動任何原生底層的 api,壞處是除了 axios 以外,無法攔截直接調用原生 api 的場景
  • 底層替換攔截:這類以 mock.js 為代表,直接替換最底層的 XHR 物件,藉此在不改動第三方套件的情況下攔截所有請求,好處是不論使用何種第三方套件,只要底層是使用 XHR 發出請求就都可以攔截到,但缺點是 XHR 物件在 server 端並不存在,要使用的話必須對 server 端進行相容處理
  • 獨立 mock server:這類以 mockoon 等為代表(雖然 mockoon 有 serverless 工具,但畢竟還是要部署在一個地方比如 amazon, vercel 等才能使用),或是其他直接啟動一個 server 來回應請求,這種方式比較不算在單純前端 mock 的範圍內,畢竟已經是直接啟動一個 server 了...,那就不單單只是所謂純前端的事了,好處是可以拿到最真實的 api 請求的 request, response 物件,壞處是你要為此多啟動一個 server
  • mock service worker:以 msw 為代表,透過啟動一個 service worker 為中間層,攔截所有從當前網頁發出的請求並返回,優點是除了可以模擬到真實 api 請求的 request, response 物件,同時不需要額外多啟動一台 server,缺點是如果網頁本身已經有其他的 service worker 可能會需要想辦法合併兩者,這種作法相對前三者較為新

MSW 2.0

看完以上四種 mock 原理,前面三個相信已經有很多大神們解釋過了,今天我們要來透過瀏覽 msw 的源碼來了解這種相對新的作法究竟如何做到的?

正式理解源碼前,我們首先來對整個 msw 的使用有個基本概念,撰寫此文時剛好 msw 2.0 released 了,就直接看最新的內容

安裝

安裝省略直接看官網...

產生 msw 的 mockServiceWorker.js

安裝完成後,透過 msw 提供的 cli 指令在指定位置產生 service worker file,產生的這個 sw file 是給 msw 使用,後面會來仔細看裡面究竟寫了啥

$ npx msw init ./public --save

定義 msw interceptor

// handler.js
// 1. Import the "HttpResponse" class from the library.
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/resource', () => {
    // 2. Return a mocked "Response" instance from the handler.
    return HttpResponse.json({
      msg: 'Hello world!'
    })
  }),
]

在 Browser 端使用

雖然 msw 也相容直接在 nodejs 端使用,但這邊先以 Browser 端做介紹

// mock.js
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

const worker = setupWorker(...handlers)
worker.start() // 實際可根據環境自由選擇是否引入與執行啟動

在網頁中啟動

接著在瀏覽器上開啟網頁,如果有看到 data 內容就表示 mock 成功摟

// main.js(entrypoint of you web app)
import './mock';

(async () => {
  const res = await fetch('/resource');
  const data = await res.json();
  console.log(data);
})();

mockServiceWorker.js

  • File

msw 直接從源碼本身著手會稍微有難度,因為牽涉到 nodejs, browser 端的處理,畢竟這邊主要是想理解 service worker 的部分處理機制,而 service worker 本身只存在於 browser 端,所以這邊單純以瀏覽 browser 相關的 code 為主,以下只是部分節錄

// 用來儲存 active 的 clientId
const activeClientIds = new Set()

// 定義 service worker 收到 message
self.addEventListener('message', async function (event) {
  const clientId = event.source.id
  const client = await self.clients.get(clientId)
  const allClients = await self.clients.matchAll({
    type: 'window',
  })
  switch (event.data) {
    // keepAlive 避免 service worker 休眠去了
    case 'KEEPALIVE_REQUEST': {
      sendToClient(client, {
        type: 'KEEPALIVE_RESPONSE',
      })
      break
    }
    // 當 web 載入 service worker 時添加 active client
    // (可能同時開很多個 web page 連上同一個 service worker)
    case 'MOCK_ACTIVATE': {
      activeClientIds.add(clientId)
      // 通知 web 端載入並紀錄 clientId 完成
      sendToClient(client, {
        type: 'MOCKING_ENABLED',
        payload: true,
      })
      break
    }
    // 當 web 端中斷連線時須移除 active clientId
    case 'CLIENT_CLOSED': {
      activeClientIds.delete(clientId)

      const remainingClients = allClients.filter((client) => {
        return client.id !== clientId
      })

      // Unregister itself when there are no more clients
      if (remainingClients.length === 0) {
        self.registration.unregister()
      }

      break
    }
  }
})

self.addEventListener('fetch', function (event) {
  const { request } = event
  // 省略一大段...
  const requestId = crypto.randomUUID()
  event.respondWith(handleRequest(event, requestId))
})

async function handleRequest(event, requestId) {
  const client = await resolveMainClient(event)
  const response = await getResponse(event, client, requestId)
  // 省略一小段...
  return response
}

// 取得主要的 client
// 發出請求的 client 並不一定就是註冊 worker 的那個 client
// 在回應請求時應該使用後者(註冊 worker 的那個 client)
async function resolveMainClient(event) {
  const client = await self.clients.get(event.clientId)

  if (client?.frameType === 'top-level') {
    return client
  }

  const allClients = await self.clients.matchAll({
    type: 'window',
  })

  return allClients
    .filter((client) => {
      return client.visibilityState === 'visible'
    })
    .find((client) => {
      return activeClientIds.has(client.id)
    })
}

async function getResponse(event, client, requestId) {
  const { request } = event

  // 複製 request,因為可能已經被使用
  // (i.e. 比如 body 可能已經被送到 client)
  const requestClone = request.clone()

  function passthrough() {
    const headers = Object.fromEntries(requestClone.headers.entries())
    return fetch(requestClone, { headers })
  }

  // 省略一大段 passthrough 判斷...

  // 通知 main client 端請求已被攔截
  // 這裡會等到 client 端處理好整個 response 後繼續執行
  // 主要是透過 sendToClient 中的 MessageChannel 雙向 sync 溝通
  const requestBuffer = await request.arrayBuffer()
  const clientMessage = await sendToClient(
    client,
    {
      type: 'REQUEST',
      payload: {
        id: requestId,
        url: request.url,
        mode: request.mode,
        method: request.method,
        headers: Object.fromEntries(request.headers.entries()),
        cache: request.cache,
        credentials: request.credentials,
        destination: request.destination,
        integrity: request.integrity,
        redirect: request.redirect,
        referrer: request.referrer,
        referrerPolicy: request.referrerPolicy,
        body: requestBuffer,
        keepalive: request.keepalive,
      },
    },
    [requestBuffer],
  )
  // 根據 main client 端回傳的 message 決定是 intercept 或 passthrough
  // respondWithMock 會實際產生一個 HTTP Response object 並丟回給發出請求的 client
  switch (clientMessage.type) {
    case 'MOCK_RESPONSE': {
      return respondWithMock(clientMessage.data)
    }

    case 'MOCK_NOT_FOUND': {
      return passthrough()
    }
  }

  return passthrough()
}

// 透過 MessageChannel 與 client 端進行 sync 雙向溝通
// 把 port2 丟給 client,讓 client 可透過 port2 與 port1(當前 worker) 溝通
// 透過 MessageChannel 可以藉由 promise 讓 function 等待 client 回傳結果
// 而不是讓 client 透過 postMessage 回傳,因為 postMessage 無法讓 worker 直接 await 等待結果
function sendToClient(client, message, transferrables = []) {
  return new Promise((resolve, reject) => {
    const channel = new MessageChannel()

    channel.port1.onmessage = (event) => {
      if (event.data && event.data.error) {
        return reject(event.data.error)
      }
      resolve(event.data)
    }

    client.postMessage(
      message,
      [channel.port2].concat(transferrables.filter(Boolean)),
    )
  })
}

async function respondWithMock(response) {
  // Setting response status code to 0 is a no-op.
  // However, when responding with a "Response.error()", the produced Response
  // instance will have status code set to 0. Since it's not possible to create
  // a Response instance with status code 0, handle that use-case separately.
  if (response.status === 0) {
    return Response.error()
  }
  const mockedResponse = new Response(response.body, response)
  Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
    value: true,
    enumerable: true,
  })
  return mockedResponse
}

這個檔案就是在前面透過 msw cli 產生的 service worker,其主要作用是處理整個 service worker 的初始化與後續 message, fetch 請求的攔截,在收到 client 端發來的請求時,透過 MessageChannel 與 main client 溝通獲得對應的 response message content,最後再傳回給 client

setupWorker.ts

  • File

這個檔案是在 client 初始化整個 service worker 的入口,可以找到這個 start method

export class SetupWorkerApi {
  // 省略一大坨...
  private createWorkerContext(): SetupWorkerInternalContext {
    const context: SetupWorkerInternalContext = {
      // 省略一大坨...
    };
    this.startHandler = context.supports.serviceWorkerApi
      ? createFallbackStart(context)
      : createStartHandler(context) // 下一個關鍵入口在這~

    return context
  }

  public async start(options: StartOptions = {}): StartReturnType {
    this.context.startOptions = mergeRight(
      DEFAULT_START_OPTIONS,
      options,
    ) as SetupWorkerInternalContext['startOptions']

    return await this.startHandler(this.context.startOptions, options)
  }
}

透過這個 start method,循線找到 createStartHandler

createStartHandler.ts

  • File
export const createStartHandler = (
  context: SetupWorkerInternalContext,
): StartHandler => {
  return function start(options, customOptions) {
    // 處理來自 service worker 名叫 `REQUEST` 的 message
    // 這裡就對應上了上面的 getResponse 裡的 sendToClient "REQUEST"
    context.workerChannel.on(
      'REQUEST',
      createRequestListener(context, options), // 下一個關鍵入口在這~
    )

    const instance = await getWorkerInstance(
      options.serviceWorker.url,
      options.serviceWorker.options,
      options.findWorker,
    )
    const [worker, registration] = instance
    context.worker = worker
    context.registration = registration

    context.events.addListener(window, 'beforeunload', () => {
      if (worker.state !== 'redundant') {
        // 通知 Service Worker 當前 client 將關閉
        context.workerChannel.send('CLIENT_CLOSED')
      }
      // 確保 keepAlive interval 關閉,避免 memory leaks
      window.clearInterval(context.keepAliveInterval)
    })

    // 啟動 keepAlive interval
    context.keepAliveInterval = window.setInterval(
      () => context.workerChannel.send('KEEPALIVE_REQUEST'),
      5000,
    )
  }
}

createStartHandler 主要會掛載處理 message REQUEST,並啟動 keepAlive 機制,接著進入到 createRequestListener 看看具體是怎麼處理 request 的吧

createRequestListener.ts

  • File
  • class WorkerChannel
export const createRequestListener = (
  context: SetupWorkerInternalContext,
  options: RequiredDeep<StartOptions>,
) => {
  return async (
    event: MessageEvent,
    message: ServiceWorkerMessage<
      'REQUEST',
      ServiceWorkerIncomingEventsMap['REQUEST']
    >,
  ) => {
    // WorkerChannel 為 msw 另外定義的一個 class ,傳入一個 port,可透過該 port 傳送 message 給對應的 port(這兩個 ports 是透過 MessageChannel 產生的一對 port)
    const messageChannel = new WorkerChannel(event.ports[0])

    const requestId = message.payload.id
    const request = parseWorkerRequest(message.payload)
    const requestCloneForLogs = request.clone()

    try {
      // 下一個進階入口在這~處理 request 並產生對應 response 丟回 onMockedResponse
      await handleRequest(
        request,
        requestId,
        context.requestHandlers,
        options,
        context.emitter,
        {
          async onMockedResponse(response, { handler, parsedResult }) {
            // 複製 mocked Response 讓 body 可被讀取為 buffer 並傳送給 worker
            const responseClone = response.clone()
            const responseInit = toResponseInit(response)

            /**
             * @note Safari doesn't support transferring a "ReadableStream".
             * Check that the browser supports that before sending it to the worker.
             */
            if (context.supports.readableStreamTransfer) {
              const responseStream = response.body
              messageChannel.postMessage(
                'MOCK_RESPONSE',
                {
                  ...responseInit,
                  body: responseStream,
                },
                responseStream ? [responseStream] : undefined,
              )
            } else {
              // As a fallback, send the response body buffer to the worker.
              const responseBuffer = await responseClone.arrayBuffer()
              messageChannel.postMessage('MOCK_RESPONSE', {
                ...responseInit,
                body: responseBuffer,
              })
            }
          },
        },
      )
    } catch (error) {
      if (error instanceof Error) {
        // 處理任何未知錯誤
        messageChannel.postMessage('MOCK_RESPONSE', {
          status: 500,
          statusText: 'Request Handler Error',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            name: error.name,
            message: error.message,
            stack: error.stack,
          }),
        })
      }
    }
  }
}

createRequestListener 主要會使用 worker 傳來的 payload 創建一個 Request object,接著透過 handleRequest 把 request 拿去比對取得對應的 response,最後再透過 messageChannel 把 response 傳遞回 worker,到此整個 msw 的 Service Worker 訊息交換機制算是理解完成了

流程總結

簡單總結下流程,整個訊息交換過程從 client 端開始

  • client 發出 activate 請求,把自己註冊進 service worker 中
  • 當某個 client 端發出 request 後,service worker 攔截請求並把 request 詳細資料傳回給 main client 同時帶著一個 MessageChannel 的 port2
  • main client 收到 service worker 傳送的 message REQUEST 後,在本地查找對應的 response
  • 不論有無找到,最後 main client 都會將結果透過 MessageChannel 的 port2 把 response 內容傳回 service worker
  • service worker 收到 main client 的 response 內容,構建成一個 HTTP Response object 後傳回給發出請求的 client

Share On:

FacebookLINEMessengerTelegram

感想

這次心血來潮跑去閱讀 msw 關於 browser side 的 service worker 用法,真的是獲益良多,看完後甚至都可以(已經)直接自幹一個簡易版本的 msw 了...,除了 service worker 的一些特性外,最重要的是之前完全沒聽過 MessageChannel 這東西,透過這次學習總算學到了這東西,雖然不知道實際開發中還可以用在哪些地方,後續再來研究看看,能在日後的開發上實際使用上的場景

那這次技術分享就到這拉,感謝各位的收看,如果喜歡我的分享文章也歡迎分享給更多人看看摟,下篇見拉,掰掰~=V=

最近更新:: 2023/10/24 上午10:05
Contributors: johnnywang
Prev
如何在 Vuepress 裡快樂寫 React
Next
擺脫 Node modules 地獄,擁抱 Yarn Plug'n'Play(PnP)