Notes
← All notes
·frontend·20 min

daily-soup-widget 從寫到上架(上):兩條安裝路徑、Shadow DOM、build-time schedule 三個設計選型

一個 side project widget 從零開始的三個設計選型——為什麼安裝路徑要走兩條、為什麼 Shadow DOM 贏過 iframe 跟取獨特名字的 scoped CSS、為什麼日期內容要 build 時就先算好。下篇講真的送上 npm 時撞到的四個雷。

#widget#side-projects#shadow-dom#npm#packaging

這篇講一個叫 daily-soup-widget 的小工具——每天顯示一句中文勵志短句,可以塞進任何網頁裡。功能簡單,但要把它做成「別人裝來就能用的套件」,前面有三個設計選型必須先決定:怎麼讓 React 跟純 HTML 兩種人都裝得來、怎麼讓我的 CSS 不被宿主頁污染、每天該顯示哪句話怎麼算。

文章寫給沒做過 npm 套件、沒做過 embed widget 的人看。讀完應該能回答:

  • 為什麼一個 widget 要同時走 <script> 跟 npm 兩條路?
  • 想做樣式隔離有三個候選——iframe、Shadow DOM、給 class 取獨特名字——怎麼挑?
  • 「每天該顯示哪句」這件事,要 runtime 去抓還是 build 時就算好?

下篇講把它真的送上架時撞到的四個雷(tsc 編不出 .d.ts、npm publish 卡 2FA、dist/ 沒清漏 ship 過期檔、GitHub Actions cron 該設多頻繁)。

本文導覽

這個 widget 解決什麼問題

我有個個人網站需要「每天換一句話」這種小功能,本來想直接在網站裡硬寫一個 React component 就好。但寫到一半開始想:這東西如果別人也想用呢?我自己之後再做別的部落格也想用呢?每個專案複製貼上同一份元件,三個月後同步改字型就會變地獄。

於是定了四個約束。後面每個章節的決策都會呼應這四條:

  1. 內容固定,過去日期一旦發佈不再改——使用者今天看到的「2026 年 5 月 16 日的那句話」,三個月後回來看必須一模一樣
  2. 零後端——side project 不想養 server、不想付 API 費用
  3. 兩種裝法都要支援——純 HTML 部落格用 <script> 一鍵塞、React app 用 npm install 標準流程
  4. bundle 越小越好——載進去的 JS 檔壓縮後最好在 10KB 以內

兩條安裝路徑:script tag 跟 npm

「兩條安裝路徑」就是把套件送到使用者手上的兩條管道:一條走 <script> 標籤直接掛 CDN 上的 JS 檔,一條走 npm install 走標準 Node / bundler 流程。同一個 widget 為什麼要鋪兩條,下面從使用者開始講。

兩群使用者要的東西不一樣

寫純 HTML 部落格的人——在 Hugo / Jekyll / 自架 WordPress 上有個頁面想加個小元件。他不會也不想為了一個小工具去學 React、學 npm、學打包工具。給他的版本就是兩個 <script> 標籤:

<!-- index.html — 部落格作者自己的網頁檔 -->
<script src="https://cdn.example.com/daily-soup-widget/embed.umd.js"></script>
<div id="daily-soup"></div>
<script>DailySoupWidget.mount('#daily-soup');</script>

兩個 script 標籤分工不同:第一個把 widget 程式碼載到瀏覽器(執行完之後會在 window 上多出一個叫 DailySoupWidget 的全域變數),第二個呼叫這個全域變數的 mount() 把 DOM 生出來塞進 <div id="daily-soup"> 那個位置。

DailySoupWidgetmount 都是 widget 作者寫的。DailySoupWidget 是 UMD 這種打包格式自動掛到 window 上的全域變數,名字由作者在 build 設定(Vite / Rollup 的 output.name)寫死。mount 是作者在原始碼裡 export 的 function,接一個 CSS selector → 找到元素 → 在那裡開一個 Shadow DOM 邊界 → 渲染進去。

注意 ship 給部落格作者的只有 JS、沒有 HTML——widget 是鑲嵌進宿主現成的頁面,不是自己另開一個頁面。作者只要在他的頁面裡放一個 <div> 當掛載點就好,HTML 結構由他自己決定。如果連 HTML 一起 ship,那就變成 iframe 方案了,後面 §隔離策略 會講為什麼不選 iframe。

寫 React app 的人——他的專案已經有 npm、有 bundler(webpack / Vite / esbuild 這類把多個 JS 檔合併成一個 bundle 的工具)。他要的是「能像用其他 React component 一樣用這個 widget」——有 TypeScript 型別提示、bundler 可以把沒用到的程式碼自動剃掉(tree-shaking)、不要載到兩份 React:

# 終端機
npm install daily-soup-widget
// app/page.tsx — 使用者的 React 頁面
import { DailySoup } from 'daily-soup-widget';

export default function HomePage() {
  return <DailySoup theme="dark" />;
}

為什麼兩條路能做的事不一樣

差別的根源是「使用環境有沒有 bundler 在中間」。

script tag 路徑npm 路徑
中間有沒有 bundler沒有有(webpack / Vite / esbuild)
拿到的檔案是什麼UMD 黑盒成品ESM 原始模組(保留結構)
能不能 tree-shake不能
React 會不會重複載通常會(看作者怎麼打包)不會(共用使用者自己的 React)
TypeScript 型別沒有

script tag 路徑的瀏覽器直接吃 UMD 那個成品檔,整個是個黑盒,瀏覽器只負責執行不負責拆分析。UMD bundle 要能在「純 HTML 沒有 React 的環境」也跑,所以 React 通常打包進去——React 開發者如果用這份,他的 app 裡就會有兩份 React。

npm 路徑的 bundler 拿到的是 ESM(ES Module)原始模組,bundler 看得到結構:誰 import 了什麼、誰沒被用到。所以 tree-shaking、共用 React 實例、TypeScript 型別提示這三件事,只有 npm 路徑做得到

只走 npm 的話純 HTML 部落格用不了。只走 script tag 的話 React 開發者拿到的就是個整包塞進來的東西,沒型別、跟自己的 React 重複載一份、bundle 變大。兩群人不能用同一條路滿足。

原始碼跟成品是兩個東西

要繼續往下講「同一份 codebase 怎麼出兩種裝法」前,先把一個對沒做過套件的人不顯然的事講清楚:原始碼(src/*.ts)跟成品(dist/*.js)是兩個東西,npm 上只有成品

src/                       dist/
├── widget.ts              ├── embed.umd.js     ← script tag 吃這份
├── index.ts          →    ├── embed.esm.js     ← npm import 吃這份
├── components/            └── embed.cjs.js     ← npm require() 吃這份
└── ... 共 10 個檔
                           (以及 *.d.ts 型別檔、source map)

中間那個箭頭是 npm run build,把 src/ 那 10 個 TypeScript 檔丟給 esbuild,編譯成 dist/ 底下的成品。生命週期 6 步:

  1. 你寫 code——src/*.ts 在你電腦上
  2. npm run build——esbuild 編譯到 dist/*.js,還是只在你電腦上
  3. npm publish——把 dist/ 跟幾份元資料打包上傳 npm
  4. 使用者 npm install daily-soup-widget——下載解壓進他的 node_modules/daily-soup-widget/dist/
  5. 使用者 import { DailySoup }——他的 bundler 看 package.jsonexports 欄位 → 找到 dist/embed.esm.js → 打包進他的 app
  6. 使用者的瀏覽器執行——使用者 code + 你 ship 的 dist/embed.esm.js 一起跑

整個鏈條 src/ 從來沒離開作者的硬碟。package.json 所有對外指向的欄位(main / module / exports)全部指 dist/,沒任何一個指 src/。比喻:src/ 是廚房、食材、作法,dist/ 是上桌的菜,npm 上只有菜

一份原始碼怎麼出三種成品

script tag 拿 1 份、npm 拿 2 份——為什麼不對稱?

通道給幾份哪份為什麼
script tag1UMD瀏覽器沒挑格式的能力,給什麼吃什麼
npm2ESM + CJS背後有 bundler,會看使用者怎麼引用挑對的那份

bundler 怎麼挑:看使用者寫的是 import 還是 require()

使用者寫法吃哪份典型場景
import { DailySoup }ESMVite、現代 Next.js、bundler 預設走 ESM
require('daily-soup-widget')CJS舊 Node、Next.js SSR、Jest 沒設好 ESM 的場景

ESM 是主流,CJS 是 fallback——多數場景吃 ESM,CJS 主要給「老 Node require() 拿」這種少數情境。雖然平常開發很少跑到 CJS,但少了它整批舊環境就裝不起來。

三份成品分別叫什麼:

  • ESM(ES Module,現代瀏覽器跟 Node 都支援的標準 module 格式)——npm 用戶 bundler 走 tree-shaking 那條
  • CJS(CommonJS,Node 早期用 require() 的格式)——Node SSR / 舊版 Node / Jest 等場景的 fallback
  • UMD(Universal Module Definition)——一種特殊打包格式,同一份檔案在 <script>require()import 三種環境都能跑1,script tag 用戶吃這份

「bundler 看 package.json 挑對的那份」具體是這樣寫的:

// package.json
{
  "main": "./dist/embed.cjs.js",
  "module": "./dist/embed.esm.js",
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/embed.esm.js",
      "require": "./dist/embed.cjs.js"
    }
  }
}

exportsimport / require 兩個 key 是規則——bundler 看到使用者寫 import 就吃 import 指的檔,看到 require() 就吃 require 指的檔。main / module 是舊欄位,給不認 exports 的舊工具當 fallback。

依賴設定:Install 時刻跟 Build 時刻是兩件事

寫 widget 套件時最容易卡的就是 peerDependenciespeerDependenciesMeta、bundler 的 external 這幾個欄位的關係。它們看起來都在處理「React 從哪裡來」,但其實作用在兩個完全不同的時刻:一個管 install 一個管 build,兩邊互不干涉。

Step 1:先分清楚 Install 時刻跟 Build 時刻

時刻誰跑做什麼看什麼欄位
Install 時刻使用者跑 npm install從 npm 把套件下載到 node_modules/package.jsondependencies / peerDependencies / peerDependenciesMeta
Build 時刻套件作者跑 npm run buildsrc/*.ts 編譯打包成 dist/*.jsbundler 設定的 external

關鍵:peer 那一組欄位只影響「使用者裝套件那一刻 npm 怎麼處理依賴」,跟「成品 bundle 長什麼樣」零關係。external 反過來只影響「我打包成品時 React 進不進那份 bundle」,跟 npm install 行為零關係。

Step 2:peerDependencies 管 Install——「我預設你冰箱有蛋」

dependenciesdevDependenciespeerDependencies 是三種對依賴的不同宣告:

宣告廣告詞
dependencies我幫你買菜——使用者裝我的時候,npm 自動也把這個一起裝
devDependencies我自己開發用——使用者裝我的時候,npm 不會跟著裝
peerDependencies我預設你冰箱有蛋——我需要這個套件但請使用者自己提供,npm 別自動裝一份

widget 對 React 要用 peerDependencies 而不是 dependencies,因為如果用 dependencies,每個裝 widget 的 React app 都會在自己的 node_modules/ 裡有一份 React、widget 的 node_modules/ 裡再有一份。兩份 React 同時跑會壞——React 的 hooks 靠模組層的全域狀態追記憶,兩份 React 各自記各自的,最常見的症狀是 Invalid hook call

下面這段 JSON 是 package.json不是任何 bundle 設定檔——這點很多人會誤解,套件層只有 1 份 package.json、Build 層才是 3 個 bundle):

// package.json
{
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  }
}

peerDependencies 的格式就是「套件名: 版本字串」——告訴 npm「我要這兩個依賴,但請使用者自己提供」。使用者裝 widget 時如果沒裝 React,npm install 那一刻會印一行 warning:

npm WARN daily-soup-widget@0.3.0 requires a peer of react@>=18 but none is installed.

這個 warning 印得對——使用者裝了一個套件但缺它需要的 React,提醒一下剛好。

peerDependenciesMeta.optional 是什麼:npm 還有個兄弟欄位 peerDependenciesMeta,可以加 { "react": { "optional": true } } 告訴 npm「沒裝 react 也別印 warning」。它的合理使用情境只有一種——「widget 的 UMD 入口完全沒 import React(純 vanilla DOM 寫成)、只有 ESM 入口才用 React」。這種架構下純 vanilla 用戶 install 時不該被 React warning 干擾,所以加 optional 拿掉。本專案不是這種架構(UMD 也用 React 寫),所以不用 optional——React 在所有使用路徑都是必要的,warning 該印就印。

Step 3:external 管 Build——「自助便當還是菜單」

external 是 bundler 設定檔(不是 package.json)裡的欄位。要理解它先理解 build 在做什麼。

Build = 把 src/ 那 10 個 TypeScript 檔合併成一個 .js 檔。esbuild 遇到 import React from 'react' 這行時,有兩個選項:

// 選項 A: 把 React 整套塞進 bundle —— 「自助便當」
// dist/embed.js(成品片段)
function useState(initial) { /* React 1.5MB source 全在這 */ }
function useEffect(fn) { /* ... */ }
// ... 你的 widget code
mount('#daily-soup');

// 選項 B: 留個 require('react') 不塞 —— 「菜單」
// dist/embed.js(成品片段)
const React = require('react');  // 留一張單,叫使用者環境自己提供
const { useState, useEffect } = React;
// ... 你的 widget code
mount('#daily-soup');

兩個選項代價不一樣——A 自帶 React 大但任何地方都能跑、B 留個單小但需要外面有 React。

external: ['react'] 就是這個選擇題的開關:

external 寫了 react選項
沒寫A(自帶 React)
寫了B(留 require('react')

npm 路徑必須選 B——使用者自己有 React,再塞一份進去會有兩份 React。 UMD(script tag)路徑被迫選 A——純 HTML 環境沒 React,留個 require('react') 一定 runtime 爆。

Step 4:widget 怎麼設

一個典型的 widget 會把三個 build 分開設,UMD 不寫 external、ESM/CJS 寫 external

// scripts/build-bundle.ts
const sharedConfig = { entryPoints: ['./src/index.ts'], bundle: true };

await esbuild.build({ ...sharedConfig, format: 'esm', outfile: 'dist/embed.esm.js',
  external: ['react', 'react-dom']  // 留個 require
});
await esbuild.build({ ...sharedConfig, format: 'cjs', outfile: 'dist/embed.cjs.js',
  external: ['react', 'react-dom']  // 留個 require
});
await esbuild.build({ ...sharedConfig, format: 'iife', outfile: 'dist/embed.umd.js',
  globalName: 'DailySoupWidget'
  // 不寫 external —— React 整套塞進去
});

關鍵概念句:external 是個排除清單,沒列上去的套件就會被打包進來

Step 5:peer vs external 三欄對照

peerDependenciesexternal(bundler 設定)
寫在哪package.json(套件層 1 份)bundler 設定檔(Build 層 3 個 bundle 可各自設)
影響哪一刻Install 時刻Build 時刻
改變什麼npm install 要不要自動裝、要不要印 warningReact 程式碼進不進 bundle

收束句:同一份原始碼可以被多個 build 設定平行處理,每份 build 各自決定 React 進不進 bundle——這就是怎麼從一份 src/ 產出三種不同格式的成品。

同一份功能打包成三個檔的代價

代價是同一份功能會被打包成三份,每份各有獨立的 source map 跟壓縮流程。CI 多跑幾秒,上 npm 的檔案大一點點。換到的是覆蓋兩群完全不重疊的使用者,對 widget 這種「就是要給最多人塞進去」的產品來說划算。

上 npm 的 .tgz 內容

npm publish 上傳的不是整個專案,是一個 .tgz 壓縮檔(npm 內部把它叫 tarball,就是 .tar 打包再 .gz 壓縮)。這份 .tgz 裡到底裝什麼,跑 npm pack --dry-run 看實際輸出,daily-soup-widget 是 4 類東西、20 個檔、壓縮 28KB:

類別檔案體積
程式成品dist/embed.{cjs,esm,umd}.js~40KB
Source mapdist/embed.{cjs,esm,umd}.js.map~78KB
TypeScript 型別dist/types/*.d.ts(10 個檔)~10KB
元資料跟文件package.json / README.md / LICENSE~3KB

設計原則一句話:只 ship 跑得起來需要的成品 + source map,原始碼不上

控制哪些檔上、哪些不上靠 package.jsonfiles 欄位(白名單):

// package.json
{
  "files": ["dist/", "README.md", "LICENSE"]
}

優先順序是 files > .npmignore > .gitignore——只要寫了 files,後面兩個就被忽略。package.jsonREADMELICENSEmain 指的入口檔永遠強制包含。node_modules.git 永遠強制排除。

不上原始碼的三個理由:

  1. 使用者只是要用——不會去 patch 你的 source,給他編譯好的就夠
  2. 體積成本——原始碼 + 註解 + 測試檔加起來會比 bundle 大很多
  3. 避免誤改——把 src/ 也 ship 出去,使用者改了 node_modules/daily-soup-widget/src/,下次 install 又被蓋掉,他會很困惑

實際 publish 前用 npm pack --dry-run 看一遍會列出哪些檔會上、哪些不會。下篇會講「dist/ 沒清漏 ship 了過期檔」這個雷,就是 files: ['dist/'] 太寬鬆、舊的 build 殘留物沒清乾淨、跟著上 npm 的情境。

隔離策略:iframe vs Shadow DOM vs scoped CSS

問題:宿主頁的 CSS 會打到我的 widget

宿主頁如果寫了 body { font-family: Comic Sans },這個樣式預設會傳到所有子元素——包含我的 widget。我精心挑的 Noto Sans TC 就失效了。三個常見方案:

方案隔離程度一句話描述
iframe100%(連 JS 全域變數都隔離)把 widget 整個塞進一個迷你瀏覽器分頁,跟外面完全絕緣
Shadow DOMCSS 規則完全隔離(繼承屬性例外),JS 共用瀏覽器內建的「樣式保險箱」,把元素裝進去,外面 CSS selector 摸不到2
scoped CSS取獨特名字躲衝突(例 .dsw-...-7f3a給 class 取一個獨特的名字,希望宿主剛好沒用同名

每個方案的問題

iframe 的問題:太隔離

iframe 裡面要再下載一份 React、要再走一次 HTML 解析、要算寬高要寫 ResizeObserver + postMessage 回報。光是「多載一份 React」這件事,使用者瀏覽器就要多下載大約 40-50KB 的 JS(React + ReactDOM 壓縮後3),首次顯示變慢,寬度自適變麻煩。對「就是顯示一句話」的小元件來說太重。

但這些都是現象,根因是另一件事——iframe 跟父頁註定不同 origin

父頁iframe
例子 URLhttps://cooking-blog.tw/recipes/2026-05-16https://widget.daily-soup.com/embed.html
誰控制部落格作者widget 作者

部落格作者的網域千千萬萬(cooking-blog.tw / travel-tips.org / 自架 WordPress...),widget 內容只能從作者的固定網域載入(例 widget.daily-soup.com)。iframe src 永遠指向 widget 作者的網域,跟父頁的網域永遠不會一樣。Shadow DOM 沒這問題——它不從別 URL 載內容,現場用 JS attachShadow 在父頁 document 裡長一棵子樹,住在父頁 origin 裡。

<script src> 跨 origin 載入是被允許的——順帶澄清:<script> 載另一個網域的 JS 不擋,且載入後用宿主的 origin 身份在跑。所以同樣是「從別 origin 載 JS」,<script> 沒事、iframe 有事,差別在「載完後是不是隔離的執行環境」。

iframe 跟父頁不同 origin 帶來的具體麻煩是這個:父頁的 JS 想直接讀 iframe 裡面的 DOM、或反過來,瀏覽器會擋下來——這是 Same-Origin Policy(SOP,同源政策):A origin 的 JS 不准亂碰 B origin 的東西,預設規則。「直接讀對方 DOM」這條路被堵死,只能繞——postMessage 是 SOP 開的一扇門,兩邊用「傳訊息」的方式互通,不能直接操作對方 DOM。所以「算寬高要寫 ResizeObserver + postMessage 回報」這個成本根因是 SOP,不是 iframe 自己選擇要這麼麻煩。

順帶一提:iframe 內部 JS 自己 fetch 第三方 API 還是會被 CORS 規則檢查,iframe 不會免疫。iframe 跟 SOP / CORS 的具體分法,本章最後面會用一張總表收束。

Shadow DOM 的問題:兩個小坑要先想清楚

坑 1:繼承屬性會穿透。 font-familycolorline-height 這類「子元素預設繼承父元素」的 CSS 屬性,會從宿主一路傳進 Shadow DOM 裡面4

這跟前面表格說「CSS 規則完全隔離」不衝突——外部的 selector 規則(例如 body .quote { color: red })100% 進不來,但繼承屬性走的不是 selector 規則這條路,是「子元素繼承父元素 computed value」這條完全不同的瀏覽器機制。同樣寫成 CSS 但兩條路,selector 那條被擋、繼承那條沒擋。

坑 2:Modeopen 還是 closed 先講 Mode 是什麼——attachShadow 這個 API 接一個設定物件,裡面的 mode 欄位只有兩個合法值:'open''closed',沒第三種。

兩個值的表面差別是「外面的程式碼能不能從 host.shadowRoot 拿到這個 Shadow DOM 的 reference」:

// src/widget.ts —— 兩種 mode 的表面差別
const host = document.querySelector('#daily-soup');

// 開放模式
const openRoot = host.attachShadow({ mode: 'open' });
console.log(host.shadowRoot);   // ShadowRoot 物件本人,拿得到

// 關閉模式
const closedRoot = host.attachShadow({ mode: 'closed' });
console.log(host.shadowRoot);   // null,從外面拿不到

看起來 closed 更安全——consumer 拿不到 reference,動不了我的內部 DOM。但這個「安全」只是表面,consumer 想拿還是有辦法。最直接的手法是在 widget 的 script 載入之前先 monkey-patch(猴子補丁,意思是動手腳改掉內建函式的行為) Element.prototype.attachShadow,把它的 return value 偷存起來:

// index.html —— consumer 在載 widget script 之前注入這段
const originalAttachShadow = Element.prototype.attachShadow;
window.__leakedRoots = new Map();

Element.prototype.attachShadow = function (init) {
  const root = originalAttachShadow.call(this, init);
  window.__leakedRoots.set(this, root);  // 把 closed root 偷存起來
  return root;
};

// 接下來 widget 載入 → 呼叫 attachShadow({ mode: 'closed' }) →
// 它拿到的 root 同時也被存進 window.__leakedRoots[host]
// 從外面照樣讀寫 widget 的內部 DOM

瀏覽器擋不住「在你之前先動手腳」這種攻擊——widget 呼叫 attachShadow 的時候,呼叫的就是被改過的版本,它根本沒辦法知道。

所以 closed 的真實狀態是:對誠實的 consumer 有點摩擦(拿不到 host.shadowRoot 要多寫一行)。對想破壞的人零阻力(5 行 monkey-patch 全破)

open 加上「不公開操作 API」反而誠實——既然 closed 防不住認真破壞的人,與其假裝有鎖、不如選 open 方便除錯,但 widget 自己對外暴露的 API 不要 export shadow root 或操作內部的方法:

// src/index.ts
export function mount(selector: string) {
  // ...
  return { play, pause };              // 對:只暴露行為
  // return { shadowRoot, play, pause }; // 錯:把 root 直接遞出去
}

對比:closed 是「假裝有鎖」,open + 不公開操作 API 是「明說沒鎖但請尊重邊界」——後者更誠實,靠介面約定而不是靠瀏覽器機制硬鎖。

scoped CSS 的問題:取獨特名字躲衝突,只是運氣好沒有保證

scoped CSS 的做法是給 widget 的 class 取一個獨特的名字,希望宿主頁面剛好沒用同樣的名字。三層拆開講:

為什麼會撞? CSS 規則靠 class 名字套用。widget 寫 .quote-text { color: red },宿主寫 .quote-text { color: blue },名字一樣,CSS 引擎按 specificity 跟載入順序決定誰生效,widget 的文字很可能被蓋成藍色。

怎麼取獨特名字? 給 widget class 加獨特前綴 + build 時生的隨機 hash 尾碼,例如 .dsw-quote-text-7f3a——dsw- 是 daily-soup-widget 縮寫、7f3a 是 build 時隨機 hash。寫成 CSS:

/* dist/embed.css —— build 後 */
.dsw-quote-text-7f3a { color: red; font-family: 'Noto Sans TC'; }

宿主要剛好寫同樣的名字幾乎不可能,這就是 scoped CSS 想做的「躲衝突」。

為什麼還是賭運氣? 機率上撞不到不代表瀏覽器規則上保證撞不到。如果宿主寫了這種:

/* 宿主頁面的 CSS —— 強制重設所有元素的所有樣式 */
* { all: revert; }

不管你的 class 多獨特都會被一起重設,獨特命名再獨特也救不回。這條規則一寫上去,scoped CSS 整個破功——而你不能假設所有部落格作者都不會寫這種規則。

對比 Shadow DOM——它不靠命名,靠瀏覽器內建的邊界把 widget 跟宿主 CSS selector 規則徹底分開。宿主寫 * { all: revert } 也只 revert 它自己 document 裡的東西,碰不到 Shadow DOM 裡面。這是真隔離,不是賭機率。

我的選擇:Shadow DOM

要完全跟宿主 JS 隔絕嗎?
├─ 是 → 用 iframe(接受多載 40-50KB + postMessage 通訊成本)
└─ 否 → 需要 CSS 樣式隔離嗎?
       ├─ 是 → 用 Shadow DOM
       └─ 否 → 用 scoped CSS(接受可能被宿主 reset 覆寫)

我的需求是「CSS 要隔離、JS 要共用宿主 React 實例」,唯一答案是 Shadow DOM。實作上用瀏覽器原生的 attachShadow API:

// src/widget.ts
const host = document.querySelector('#daily-soup');
const shadowRoot = host.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
  <style>/* widget 自己的 CSS */</style>
  <div class="dsw-card">...</div>
`;

至於「font-family 繼承穿透」——我選擇讓它穿透。這樣 widget 自然融入宿主排版,看起來像那個網站的一部分而不是貼上去的異物。這是個選擇不是 bug,但要在 attachShadow 那一刻就想清楚。

iframe / SOP / CORS / postMessage 怎麼分

這四個詞在 widget 開發裡常被綁在一起講,但它們其實是不同層的東西。SOP(Same-Origin Policy)是瀏覽器內建的父規則:「A origin 的 JS 不准亂碰 B origin 的東西」,預設規則,管 fetch、跨 origin iframe DOM 存取、跨 origin storage 等多種情境。CORS 是 SOP 底下專門管 fetch 的子規則。

origin 是什麼? 協定 + 網域 + port,三個全相同才算同一 origin(路徑不算、子網域算不同)。例:https://blog.com/foohttps://blog.com/bar 是同 origin、跟 https://api.blog.com 是不同 origin、跟 http://blog.com 也是不同 origin(協定不一樣)。

另外有個寬鬆版本叫 same-site,看「註冊網域 + TLD」這兩塊(就是去域名商買的那塊):api.blog.comblog.com 是 same-site、不同 origin。SOP 跟 CORS 都看 origin 不看 site,site 概念用在 cookie 的 SameSite 屬性跟跨站追蹤防護那邊。

五行解綁總表(下面欄位寫的「撞 SOP / 撞 CORS」是口語簡稱,意思是「程式做了被瀏覽器規則禁止的事,瀏覽器把那個動作擋下來、噴錯誤訊息到 console」。具體錯誤訊息會是 DOMExceptionblocked by CORS policy、或回 null):

情境撞 SOP?撞 CORS?怎麼解
iframe 純嵌入(不互讀 DOM)不撞不撞不用解
父頁 JS 讀 iframe 的 DOM撞 SOP不撞postMessage
iframe 的 JS 讀父頁的 DOM撞 SOP不撞postMessage
iframe 內 JS fetch 跨 origin API不撞撞 CORSserver 回 Access-Control-Allow-Origin
普通頁面 JS fetch 跨 origin API不撞撞 CORSserver 回 Access-Control-Allow-Origin

第 4 跟第 5 行刻意分開列——CORS 跟 iframe 沒關係,普通頁面 fetch 跨 origin 也照撞。

四句 mental model:

  • SOP = 父規則——管所有跨 origin JS 互動(DOM 存取、storage、fetch)
  • CORS = SOP 的 fetch 子規則——只管 fetch 場景 server 怎麼授權
  • iframe = 常見會踩 SOP 的場景但不是唯一觸發者——非 iframe 場景也可能撞 SOP
  • postMessage = SOP 為「跨 origin window 通訊」開的逃生門——只在父子 window 需互通時用

收束 one-liner:iframe 跟外層頁面不同網址(origin)時,兩邊想直接讀對方的內容,瀏覽器會擋(SOP)——要靠 postMessage 互傳訊息。CORS 不是 iframe 的問題,是 JS 想去別家網站抓資料才會遇到。

內容排程:build-time schedule

兩個方案

runtime fetch(執行時去抓): 每次有人打開頁面,widget 就打 API 問「今天該顯示哪句話」。優點是內容可以中央更新不用 republish 套件,缺點是要養後端、會撞 CORS、API 掛了整個 widget 就掛了。

build-time 預先算好: 套件上架前就算好「未來 90 天,每天該顯示哪句話」,把對照表打包進 JS。執行時 widget 拿今天日期查對照表,零網路請求。

為什麼選 build-time

三個動機:

  • 確定性——產品定義就寫「過去日期不再改」,runtime fetch 沒辦法保證(CDN 快取不一致 + 任何後端意外都會破功)
  • 零後端——side project 不想養 server
  • 離線可用——只要 widget JS 載得下來就能跑,不依賴網路也不依賴我的 Vercel 還活著

實作是一個 build 腳本 build-schedule.ts

// scripts/build-schedule.ts
import quotes from './quotes/*.md';

const SCHEDULE_DAYS = 90;
const start = new Date('2026-01-01');

const schedule = {};
for (let i = 0; i < SCHEDULE_DAYS; i++) {
  const date = addDays(start, i);
  const dateKey = date.toISOString().split('T')[0];   // '2026-05-16'
  const hash = simpleHash(dateKey);                    // 日期字串 → 數字
  const index = hash % quotes.length;                  // 0 ~ 29
  schedule[dateKey] = quotes[index].slug;
}

fs.writeFileSync('dist/schedule-zh.json', JSON.stringify(schedule));

這份 JSON 在 build 時生出來,跟著 JS bundle 一起上架到 npm。Runtime 完全沒有 fetch。

順帶躲掉的 CORS 成本

選 build-time 不是為了躲 CORS,是衝著前面三個動機。CORS 沒踩到是 side effect——widget 整條 runtime 沒有任何 fetch、沒有任何跨 origin 網路請求,自然不會被 CORS 規則擋。

回頭看才意識到:build-time schedule 順帶把「跟瀏覽器跨 origin 安全模型搏鬥」這整套維運成本整個免掉。如果改成 runtime fetch、按需 fetch 單句、加曝光統計 endpoint 任何一件事,CORS 就會跑出來——包含 preflight、Access-Control-Allow-CredentialsAccess-Control-Allow-Origin echo 等一連串細節要配。

CORS、SOP、preflight 的完整講解(含 fetch / @font-face / canvas / window.onerror 等「JS 要讀回內容」的場景判準)寫在另一篇 CORS 從零講起(widget 視角)(待出)。這裡只點到「沒踩到」這個結果。

兩個長得像 CORS 其實不是的雷

開發中踩到兩個一開始以為是 CORS 但其實不是:

  • npm install daily-soup-widget@^0.2.0ETARGET No matching version found——不是網路、不是 CORS,是 npm 本地 cache(~/.npm/_cacache/)的 registry metadata 還是舊的。解法:npm cache clean --force 清完再裝
  • demo 頁更新後第一次載到舊 bundle——Vercel 邊緣節點的快取還沒過期。解法:URL 後面加 ?cb=<timestamp> 騙瀏覽器當成新檔重抓

上篇小結 + 下篇預告

四個約束 → 三個選型的對應:

約束對應到哪個選型
兩種裝法都要支援兩條安裝路徑(UMD + npm ESM/CJS)
內容固定、過去日期不再改build-time pre-compute schedule
零後端build-time schedule(順帶躲掉 CORS)
bundle 越小越好Shadow DOM(不是 iframe) + 不引 React 重載

這三個是「決定 widget 怎麼跑」的選型,全部在打開編輯器之前就要想清楚。

下篇講的是另一半:寫完了、build 出來了、要送上 npm 的時候會撞到什麼。包括 tsc 死活不產 .d.ts、npm publish 卡在 2FA、dist/ 沒清漏 ship 一個過期檔、GitHub Actions cron 該設多頻繁。

延伸:daily-soup-widget 從寫到上架(下):tsc/.d.ts、npm 2FA、dist/ 清掃、cron 四個 publish 踩雷


參考資料

Footnotes

  1. UMD pattern — GitHub umdjs/umd repository — UMD 的原始定義 + 各種 entry pattern 範本,說明它如何同時相容 AMD、CommonJS、global 三種環境。

  2. Using shadow DOM — MDN — Shadow DOM v1 規範概述、attachShadow API、mode: 'open' | 'closed' 差別與瀏覽器支援情況。

  3. react + react-dom bundle size — Bundlephobia — React 18 + ReactDOM 18 minified + gzipped 約 44.5 KB,文中「40-50KB」依此估算。

  4. CSS inheritance and shadow DOM — MDN Web Components guide — 解釋為什麼 inherited properties(font-family, color, line-height)會從宿主穿透進 shadow root。