daily-soup-widget 從寫到上架(上):兩條安裝路徑、Shadow DOM、build-time schedule 三個設計選型
一個 side project widget 從零開始的三個設計選型——為什麼安裝路徑要走兩條、為什麼 Shadow DOM 贏過 iframe 跟取獨特名字的 scoped CSS、為什麼日期內容要 build 時就先算好。下篇講真的送上 npm 時撞到的四個雷。
這篇講一個叫 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 解決什麼問題
- 兩條安裝路徑:script tag 跟 npm
- 隔離策略:iframe vs Shadow DOM vs scoped CSS
- 內容排程:build-time schedule
- 上篇小結 + 下篇預告
這個 widget 解決什麼問題
我有個個人網站需要「每天換一句話」這種小功能,本來想直接在網站裡硬寫一個 React component 就好。但寫到一半開始想:這東西如果別人也想用呢?我自己之後再做別的部落格也想用呢?每個專案複製貼上同一份元件,三個月後同步改字型就會變地獄。
於是定了四個約束。後面每個章節的決策都會呼應這四條:
- 內容固定,過去日期一旦發佈不再改——使用者今天看到的「2026 年 5 月 16 日的那句話」,三個月後回來看必須一模一樣
- 零後端——side project 不想養 server、不想付 API 費用
- 兩種裝法都要支援——純 HTML 部落格用
<script>一鍵塞、React app 用npm install標準流程 - 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"> 那個位置。
DailySoupWidget 跟 mount 都是 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 步:
- 你寫 code——
src/*.ts在你電腦上 npm run build——esbuild 編譯到dist/*.js,還是只在你電腦上npm publish——把dist/跟幾份元資料打包上傳 npm- 使用者
npm install daily-soup-widget——下載解壓進他的node_modules/daily-soup-widget/dist/ - 使用者
import { DailySoup }——他的 bundler 看package.json的exports欄位 → 找到dist/embed.esm.js→ 打包進他的 app - 使用者的瀏覽器執行——使用者 code + 你 ship 的
dist/embed.esm.js一起跑
整個鏈條 src/ 從來沒離開作者的硬碟。package.json 所有對外指向的欄位(main / module / exports)全部指 dist/,沒任何一個指 src/。比喻:src/ 是廚房、食材、作法,dist/ 是上桌的菜,npm 上只有菜。
一份原始碼怎麼出三種成品
script tag 拿 1 份、npm 拿 2 份——為什麼不對稱?
| 通道 | 給幾份 | 哪份 | 為什麼 |
|---|---|---|---|
| script tag | 1 | UMD | 瀏覽器沒挑格式的能力,給什麼吃什麼 |
| npm | 2 | ESM + CJS | 背後有 bundler,會看使用者怎麼引用挑對的那份 |
bundler 怎麼挑:看使用者寫的是 import 還是 require()。
| 使用者寫法 | 吃哪份 | 典型場景 |
|---|---|---|
import { DailySoup } | ESM | Vite、現代 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"
}
}
}
exports 裡 import / require 兩個 key 是規則——bundler 看到使用者寫 import 就吃 import 指的檔,看到 require() 就吃 require 指的檔。main / module 是舊欄位,給不認 exports 的舊工具當 fallback。
依賴設定:Install 時刻跟 Build 時刻是兩件事
寫 widget 套件時最容易卡的就是 peerDependencies、peerDependenciesMeta、bundler 的 external 這幾個欄位的關係。它們看起來都在處理「React 從哪裡來」,但其實作用在兩個完全不同的時刻:一個管 install 一個管 build,兩邊互不干涉。
Step 1:先分清楚 Install 時刻跟 Build 時刻
| 時刻 | 誰跑 | 做什麼 | 看什麼欄位 |
|---|---|---|---|
| Install 時刻 | 使用者跑 npm install | 從 npm 把套件下載到 node_modules/ | package.json 的 dependencies / peerDependencies / peerDependenciesMeta |
| Build 時刻 | 套件作者跑 npm run build | 把 src/*.ts 編譯打包成 dist/*.js | bundler 設定的 external |
關鍵:peer 那一組欄位只影響「使用者裝套件那一刻 npm 怎麼處理依賴」,跟「成品 bundle 長什麼樣」零關係。external 反過來只影響「我打包成品時 React 進不進那份 bundle」,跟 npm install 行為零關係。
Step 2:peerDependencies 管 Install——「我預設你冰箱有蛋」
dependencies、devDependencies、peerDependencies 是三種對依賴的不同宣告:
| 宣告 | 廣告詞 |
|---|---|
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 三欄對照
peerDependencies | external(bundler 設定) | |
|---|---|---|
| 寫在哪 | package.json(套件層 1 份) | bundler 設定檔(Build 層 3 個 bundle 可各自設) |
| 影響哪一刻 | Install 時刻 | Build 時刻 |
| 改變什麼 | npm install 要不要自動裝、要不要印 warning | React 程式碼進不進 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 map | dist/embed.{cjs,esm,umd}.js.map | ~78KB |
| TypeScript 型別 | dist/types/*.d.ts(10 個檔) | ~10KB |
| 元資料跟文件 | package.json / README.md / LICENSE | ~3KB |
設計原則一句話:只 ship 跑得起來需要的成品 + source map,原始碼不上。
控制哪些檔上、哪些不上靠 package.json 的 files 欄位(白名單):
// package.json
{
"files": ["dist/", "README.md", "LICENSE"]
}
優先順序是 files > .npmignore > .gitignore——只要寫了 files,後面兩個就被忽略。package.json、README、LICENSE、main 指的入口檔永遠強制包含。node_modules 跟 .git 永遠強制排除。
不上原始碼的三個理由:
- 使用者只是要用——不會去 patch 你的 source,給他編譯好的就夠
- 體積成本——原始碼 + 註解 + 測試檔加起來會比 bundle 大很多
- 避免誤改——把
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 就失效了。三個常見方案:
| 方案 | 隔離程度 | 一句話描述 |
|---|---|---|
| iframe | 100%(連 JS 全域變數都隔離) | 把 widget 整個塞進一個迷你瀏覽器分頁,跟外面完全絕緣 |
| Shadow DOM | CSS 規則完全隔離(繼承屬性例外),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 | |
|---|---|---|
| 例子 URL | https://cooking-blog.tw/recipes/2026-05-16 | https://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-family、color、line-height 這類「子元素預設繼承父元素」的 CSS 屬性,會從宿主一路傳進 Shadow DOM 裡面4。
這跟前面表格說「CSS 規則完全隔離」不衝突——外部的 selector 規則(例如 body .quote { color: red })100% 進不來,但繼承屬性走的不是 selector 規則這條路,是「子元素繼承父元素 computed value」這條完全不同的瀏覽器機制。同樣寫成 CSS 但兩條路,selector 那條被擋、繼承那條沒擋。
坑 2:Mode 選 open 還是 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/foo跟https://blog.com/bar是同 origin、跟https://api.blog.com是不同 origin、跟http://blog.com也是不同 origin(協定不一樣)。另外有個寬鬆版本叫 same-site,看「註冊網域 + TLD」這兩塊(就是去域名商買的那塊):
api.blog.com跟blog.com是 same-site、不同 origin。SOP 跟 CORS 都看 origin 不看 site,site 概念用在 cookie 的 SameSite 屬性跟跨站追蹤防護那邊。
五行解綁總表(下面欄位寫的「撞 SOP / 撞 CORS」是口語簡稱,意思是「程式做了被瀏覽器規則禁止的事,瀏覽器把那個動作擋下來、噴錯誤訊息到 console」。具體錯誤訊息會是 DOMException、blocked by CORS policy、或回 null):
| 情境 | 撞 SOP? | 撞 CORS? | 怎麼解 |
|---|---|---|---|
| iframe 純嵌入(不互讀 DOM) | 不撞 | 不撞 | 不用解 |
| 父頁 JS 讀 iframe 的 DOM | 撞 SOP | 不撞 | postMessage |
| iframe 的 JS 讀父頁的 DOM | 撞 SOP | 不撞 | postMessage |
| iframe 內 JS fetch 跨 origin API | 不撞 | 撞 CORS | server 回 Access-Control-Allow-Origin |
| 普通頁面 JS fetch 跨 origin API | 不撞 | 撞 CORS | server 回 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-Credentials、Access-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.0噴ETARGET 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
-
UMD pattern — GitHub umdjs/umd repository — UMD 的原始定義 + 各種 entry pattern 範本,說明它如何同時相容 AMD、CommonJS、global 三種環境。 ↩
-
Using shadow DOM — MDN — Shadow DOM v1 規範概述、
attachShadowAPI、mode: 'open' | 'closed'差別與瀏覽器支援情況。 ↩ -
react + react-dom bundle size — Bundlephobia — React 18 + ReactDOM 18 minified + gzipped 約 44.5 KB,文中「40-50KB」依此估算。 ↩
-
CSS inheritance and shadow DOM — MDN Web Components guide — 解釋為什麼 inherited properties(font-family, color, line-height)會從宿主穿透進 shadow root。 ↩