Widget 框架整合指南
本文件適合外部開發者(IP 站台工程師)——你想把 MediaPulse 問卷 widget 嵌進自己的真實站台。
注意:範例 repo 與真實整合的差異
examples/目錄下的各框架範例(examples/html/、examples/react/、examples/vue/、examples/next/、examples/astro/)皆使用兩個「測試替身」,讓它們能在 repo 內完全自給自足地執行:
- idsync stub(
examples/shared/static/idsync-stub.js):回傳固定的 demo mpid,取代真實的sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js- 共用 mock API server:一台真實 HTTP 服務(
packages/mock-server,重用 mock handlers),範例經 CORS 呼叫它取得假問卷資料,取代真實後端你的真實站台不會用這兩個。本文件說明的是真實整合的做法;範例只作為程式碼結構的參考。
1. 通用整合模型
兩支必要 script
真實整合在每個要放問卷的頁面 <head> 載入兩支 script:
<head>
<!-- 1. idsync:設定 API key 與參數,再載 SDK -->
<script>
window.TVBS_API_KEY = 'YOUR_API_KEY'
window.TVBS_INSIDER_PARAMS = { /* 你們站台的 Insider 參數 */ }
</script>
<script src="https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js"></script>
<!-- 2. MediaPulse widget:從 CDN 載入(URL 待 infra 確認後提供) -->
<script src="https://{widget-cdn}/widget.iife.js" defer></script>
</head>
Widget CDN URL 待定:正式 CDN 走 S3 + CloudFront,確切 URL 在 infra 建置完成後提供。屆時會是固定版本 URL,可搭配 SRI hash 鎖定。
idsync SDK:需 sdk-idsync ≥ v1.3.3(提供
isReady()/getId()介面)。
四種 widget 掛載方式
方式 (a):顯式 custom element
直接在 HTML 放 <media-pulse-survey> 元件。widget script 載入後會自動 upgrade:
<media-pulse-survey
brand-id="brand_tvbsnews"
category-id="cat_politics"
layout="inline"
></media-pulse-survey>
方式 (b):data-survey-zone 容器,自動掛載
在你想放問卷的位置放一個 <div>,widget 會自動在容器裡插入 <media-pulse-survey>(懶載入,捲到接近 viewport 才 fetch):
<div
data-survey-zone
data-brand-id="brand_tvbsnews"
data-category-id="cat_politics"
data-layout="inline"
></div>
方式 (c):script-tag 就地注入(最簡易的「放一個 tag 就定位」做法)
直接在 widget 的 <script> 標籤上帶 data-brand-id(及/或 data-category-id、data-layout、data-mpid)。widget IIFE 載入後會自動在該 <script> 標籤緊接後方插入一個 <media-pulse-survey>,省去另外在 HTML 放容器的步驟:
<script src="https://{widget-cdn}/widget.iife.js"
data-brand-id="brand_x" data-category-id="cat_y" data-layout="sidebar" defer></script>
適合「我只想在某個位置丟一行 tag 就完成嵌入」的最簡情境。相較方式 (a)/(b) 的差別是不需要另一個 DOM 節點——script 本身就是定位點。
方式 (d):window.MediaPulse.render() 命令式 API(googletag 式)
適合需要動態控制掛載時機的情境。widget IIFE 載入前就可以先 push 指令(googletag 式 cmd queue):
<script>
// 可在 widget IIFE 載入前先 push(cmd queue 模式)
window.MediaPulse = window.MediaPulse || { cmd: [] }
window.MediaPulse.cmd.push(function () {
window.MediaPulse.render({
el: '#survey-container',
brandId: 'brand_tvbsnews',
categoryId: 'cat_politics',
mpid: window.TVBS_IDSync?.getId() ?? undefined,
layout: 'inline',
})
})
</script>
render() 選項:
| 參數 | 型別 | 說明 |
|---|---|---|
el | string | Element | CSS selector 或 DOM 元素 |
brandId | string | 品牌 ID(必填) |
categoryId | string? | 頁面分類(選填,後端用來選題) |
mpid | string? | 投票者 ID(見 §2) |
layout | 'inline' | 'sidebar' | 版型(預設 'inline') |
lazy | boolean? | 是否懶載入(預設 false) |
surveyId | string? | 直接指定問卷 ID(見下方 escape hatch) |
Serve 模型 vs. by-id Escape Hatch
Serve 模型(正常情境):提供 brand-id + category-id,後端依投放邏輯選出要顯示的問卷。沒有命中任何問卷時 widget 靜默不顯示(no-fill)。category-id 是 pass-through——前端不做任何處理,傳給後端後由後端做 targeting;範例的 mock API server 收到 category-id 但不篩選(mock 只依 brand 選題)。
By-id Escape Hatch:指定 survey-id(或 data-survey-id)可直接載入特定問卷,繞過 serve 邏輯。適合 preview、QA、或特定問卷的固定嵌入場景。
layout 版型
| 值 | 說明 |
|---|---|
inline | 文章流內嵌入(預設) |
sidebar | 收合側欄,讀者點觸發鈕後彈出 overlay |
2. idsync 串接
真實站台載入方式
<head>
<!-- Step 1:設定 API key(在 SDK script 之前) -->
<script>
window.TVBS_API_KEY = 'YOUR_API_KEY'
window.TVBS_INSIDER_PARAMS = { /* Insider 參數 */ }
</script>
<!-- Step 2:載入 SDK -->
<script src="https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js"></script>
<!-- Step 3:初始化,在 onReady 把 mpid 補進 widget -->
<script>
TVBS_IDSync.init({
onReady: function () {
var mpid = TVBS_IDSync.getId()
// 補進頁面上所有已掛載的 widget 元件
document.querySelectorAll('media-pulse-survey').forEach(function (el) {
el.setAttribute('mpid', mpid)
})
},
onError: function (err) {
// idsync 失敗:widget 會自動 fallback 到匿名 localStorage id
console.warn('[idsync]', err)
},
timeout: 5000, // ms,依你們站台調整
})
</script>
<!-- Step 4:widget CDN -->
<script src="https://{widget-cdn}/widget.iife.js" defer></script>
</head>
mpid 投票者 cascade
Widget 依以下優先序解析投票者身分(voter-id.ts):
- 顯式
mpid屬性(<media-pulse-survey mpid="...">或el.setAttribute('mpid', ...)) window.TVBS_IDSync.getId()(SDK 已就緒時自動讀取)- 匿名 fallback:widget 用
crypto.randomUUID()在宿主站第一方localStorage(keymp_voter_id)建立匿名 ID
身分以 X-Mpid request header 送給後端,不依賴任何 cookie。
Late-adopt:idsync 比 widget 更晚就緒
Widget 常掛在文章底部(懶載入),讀者捲到時 idsync 通常已就緒。萬一 widget 先掛載、idsync 後才就緒:
// 在 onReady 補設 mpid,widget 會重新 bootstrap 採用真實 id(作答開始前有效)
TVBS_IDSync.init({
onReady: function () {
var mpid = TVBS_IDSync.getId()
document.querySelectorAll('media-pulse-survey').forEach(function (el) {
el.setAttribute('mpid', mpid)
})
},
})
一旦讀者開始作答(選了選項或填了文字),身分會凍結,不再更換。
onError / timeout 降級
idsync 失敗或超時時,widget 不會崩壞——它會 fallback 到匿名 ID,問卷照常運作。只是這次作答記在匿名 ID 下,無法與後續 idsync 就緒後的同一讀者合併(v1 行為)。
範例 repo 用 stub 的說明
範例 repo 的所有 examples/* 用的是 examples/shared/static/idsync-stub.js(一個固定回 'mpid_demo_showcase' 的本地替身),不是真實的 sdk.tvbs.com.tw/idsync/...。真實站台請替換成上面的真實載入方式。
3. 各框架專節
HTML(無框架)
參考範例:examples/html/index.html、examples/html/src/inline.ts
Script 注入點:在 <head> 放 idsync;widget 可用 <script defer> 在 head 或 body 底部載入。
真實整合範本:
<!doctype html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<script>
window.TVBS_API_KEY = 'YOUR_API_KEY'
window.TVBS_INSIDER_PARAMS = {}
</script>
<script src="https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js"></script>
<script src="https://{widget-cdn}/widget.iife.js" defer></script>
</head>
<body>
<article>
<p>文章內容...</p>
<media-pulse-survey
brand-id="brand_tvbsnews"
category-id="cat_politics"
></media-pulse-survey>
</article>
<script>
TVBS_IDSync.init({
onReady: function () {
var mpid = TVBS_IDSync.getId()
document.querySelectorAll('media-pulse-survey').forEach(function (el) {
el.setAttribute('mpid', mpid)
})
},
})
</script>
</body>
</html>
範例 repo 的 HTML 例子(examples/html/src/inline.ts)在 JavaScript 裡動態 innerHTML 建出 <media-pulse-survey>,然後用 bootWidget() 載入 widget(後端是共用 mock API server)。真實站台不需要這些,直接在 HTML 放元件即可。
React(Vite SPA)
React 19 custom element 屬性注意事項
React 19 對 custom element 的屬性一律以**字串(string)**方式傳遞,不做 property binding。Widget 的
brand-id、category-id、mpid、layout全部都是字串屬性,所以正常使用完全沒問題。但如果你未來需要傳遞非字串值給某個 custom element(例如物件或陣列),JSX prop 方式無效——請改用ref+useEffect裡的el.setAttribute()/el.someProperty = value。
參考範例:examples/react/src/SurveySlot.tsx、examples/react/index.html
Script 注入點:在 index.html 的 <head> 放 idsync script;不要在 React component 裡 import 或動態載入 idsync,因為你的 React app 是 SPA,HTML head 是固定的。
JSX 型別宣告(讓 TypeScript 認得 <media-pulse-survey>):
// src/media-pulse.d.ts(或 types.d.ts)
import type { DetailedHTMLProps, HTMLAttributes } from 'react'
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'media-pulse-survey': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
'brand-id'?: string
'category-id'?: string
'layout'?: string
'mpid'?: string
'survey-id'?: string
}
}
}
}
範例 repo 的做法(examples/next/types.d.ts)相同。
Widget 掛載:
// src/SurveySlot.tsx
import { useEffect, useState } from 'react'
export function SurveySlot({ layout }: { layout?: 'inline' | 'sidebar' }) {
const [mpid, setMpid] = useState('')
useEffect(() => {
// useEffect 在 client 跑,安全地讀 window.TVBS_IDSync
setMpid(window.TVBS_IDSync?.getId() ?? '')
}, [])
return (
<media-pulse-survey
brand-id="brand_tvbsnews"
category-id="cat_politics"
{...(layout ? { layout } : {})}
{...(mpid ? { mpid } : {})}
/>
)
}
範例 repo(examples/react/src/SurveySlot.tsx)的程式碼與上面幾乎一致,差別只在範例用 bootWidget() 載入 widget(後端是共用 mock API server)。
index.html 真實整合:
<!-- public/index.html(或 Vite 的 index.html) -->
<head>
<script>window.TVBS_API_KEY = 'YOUR_API_KEY'</script>
<script src="https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js"></script>
<script src="https://{widget-cdn}/widget.iife.js" defer></script>
</head>
Next.js(App Router + Static Export)
參考範例:examples/next/app/SurveySlot.tsx、examples/next/next.config.mjs
Next.js App Router 中,widget 的載入和 mpid 的讀取必須在 client component 的 useEffect 裡進行——window 物件在 server render 階段不存在。
'use client' component:
// app/SurveySlot.tsx
'use client'
import { useEffect, useState } from 'react'
export function SurveySlot({ layout }: { layout?: 'sidebar' }) {
const [mpid, setMpid] = useState('')
useEffect(() => {
// 僅在 client:載入 widget 並讀取 mpid
// 真實整合不需要 bootWidget,widget 已由 <Script> 在 head 載入
setMpid(window.TVBS_IDSync?.getId() ?? '')
}, [])
return (
<media-pulse-survey
brand-id="brand_tvbsnews"
category-id="cat_politics"
{...(layout ? { layout } : {})}
{...(mpid ? { mpid } : {})}
/>
)
}
在 app/layout.tsx 注入 script(用 Next.js <Script>):
// app/layout.tsx
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-TW">
<head>
<Script id="tvbs-api-key" strategy="beforeInteractive">
{`window.TVBS_API_KEY = 'YOUR_API_KEY'`}
</Script>
<Script
src="https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://{widget-cdn}/widget.iife.js"
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
)
}
注意事項:
output: 'export'(static export):Next.js 不做任何 server-side rendering,widget 和 idsync 完全在 client 跑,上面的做法完全適用。basePath:若你有設basePath,Next.js 會自動為<Script src>加上前綴,但手寫的<script src>字串不會自動加——用 Next.js<Script>元件可避免此問題。next dev下資源路徑可能與next build後不同,建議用next build && next start驗證。
Astro(靜態站)
參考範例:examples/astro/src/pages/astro/index.astro、examples/astro/src/components/SurveySlot.astro
Astro 預設做靜態生成(SSG)。idsync 必須在 client script 裡載入;widget 元件寫在 .astro template 裡。
重要:不要在 Astro frontmatter(--- 區塊)裡碰 window 或 custom element——frontmatter 在 build time 跑,那時沒有瀏覽器環境。從 frontmatter import 的模組也不能有瀏覽器 API。
頁面 .astro 範本:
---
// frontmatter:這裡只能 import 純資料/型別,不能碰 window
import { getPageData } from '../data/article'
const article = await getPageData()
---
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<!-- idsync:is:inline 讓 Astro 不處理這段 script,原樣輸出到 HTML -->
<script is:inline>
window.TVBS_API_KEY = 'YOUR_API_KEY'
window.TVBS_INSIDER_PARAMS = {}
</script>
<script is:inline src="https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js"></script>
<!-- widget:defer 確保 DOM 已就緒 -->
<script is:inline src="https://{widget-cdn}/widget.iife.js" defer></script>
</head>
<body>
<article>
{article.paragraphs.map(p => <p>{p}</p>)}
<!-- widget 元件直接在 template 裡放 -->
<media-pulse-survey
brand-id="brand_tvbsnews"
category-id="cat_politics"
data-mp-slot
></media-pulse-survey>
</article>
<!-- client script:初始化 idsync,在 onReady 補 mpid -->
<script>
// 這段是 client-side script,Astro 會 bundle 後注入
// 注意:不要在 init() 之前同步呼叫 getId()——那時 identity 尚未解析,
// getId() 會回傳 null,是無效的死碼。mpid 必須在 onReady 裡才能取到。
TVBS_IDSync.init({
onReady() {
const id = TVBS_IDSync.getId()
if (id) document.querySelector('media-pulse-survey')?.setAttribute('mpid', id)
},
})
</script>
</body>
</html>
範例 repo 的 examples/astro/src/components/SurveySlot.astro 用的是相同模式:element 在 template、idsync 讀取在 <script> 塊。差別是範例用 bootWidget() 載入 widget(後端是共用 mock API server)。
Gotcha:is:inline vs Astro 處理的 script
<script is:inline src="...">→ 直接輸出,Astro 不碰,適合外部 CDN script(idsync、widget)<script>(無修飾)→ Astro 會 bundle + 做模組處理,適合你的 client 邏輯- 不要在
<script>塊(Astro 處理的)裡 import 任何會碰window的東西,然後期望它在 SSG build 階段能跑——import 時間點不同
Vue(Vite SPA)
參考範例:examples/vue/src/SurveySlot.vue、examples/vue/vite.config.ts
Vue 需要告訴 compiler media-pulse-* 是 custom element,否則會出現 unknown element 警告。
vite.config.ts(或 vue.config.js):
// vite.config.ts
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// 放行 media-pulse-* 避免 Vue 警告未知自訂元素
isCustomElement: tag => tag.startsWith('media-pulse-'),
},
},
}),
],
})
SurveySlot.vue:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const props = defineProps<{ layout?: 'inline' | 'sidebar' }>()
const mpid = ref('')
// onMounted 在 client 跑,安全地讀 window.TVBS_IDSync
onMounted(() => {
mpid.value = (window as any).TVBS_IDSync?.getId() ?? ''
})
</script>
<template>
<media-pulse-survey
brand-id="brand_tvbsnews"
category-id="cat_politics"
:layout="props.layout"
:mpid="mpid || undefined"
/>
</template>
範例 repo 的 examples/vue/src/SurveySlot.vue 與上面幾乎相同。
index.html 真實整合(同 React Vite 做法):
<head>
<script>window.TVBS_API_KEY = 'YOUR_API_KEY'</script>
<script src="https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js"></script>
<script src="https://{widget-cdn}/widget.iife.js" defer></script>
</head>
4. SSR / Hydration 注意事項
Server-side rendering(SSR)或靜態生成(SSG)的環境裡,window 不存在於 server 階段。
通用規則:
- 永遠不要在 server 端碰
window/document/ custom element — 只在 client lifecycle hook 裡操作 - Widget script 是 client-only,不要在 SSR 階段嘗試載入
各框架對應:
| 框架 | 做法 |
|---|---|
| Next.js App Router | 放 'use client' + 在 useEffect 裡讀 window.TVBS_IDSync 和操作 DOM |
| Next.js static export | 無 server widget,完全 client-side,useEffect 安全 |
| Astro | idsync 用 <script is:inline>(client HTML);client 邏輯用 <script>(Astro client bundle);frontmatter 不碰 window |
| Nuxt | 用 onMounted 讀 idsync;widget script 在 app.html 的 head 用 defer 載 |
| React(純 Vite SPA) | SPA 沒有 SSR,useEffect 跑在 client,直接安全 |
| Vue(純 Vite SPA) | 同上,onMounted 安全 |
5. Troubleshooting
Widget element 沒有渲染
可能原因:
- Widget script 沒有載入(查 Network tab 確認
widget.iife.js有成功載入) deferscript 的執行順序問題:確保 widget script 在元件 DOM 已存在後才執行(defer在 DOMContentLoaded 之前執行,通常沒問題)- 元素的
display: none或 container 不存在
Vue 出現 [Vue warn]: Unknown custom element: <media-pulse-survey>
需要設定 isCustomElement,見 §3 Vue 專節。
mpid 一直是空字串 / null
idsync 尚未就緒。解法:
- 確認
TVBS_IDSync.init()有被呼叫(在頁面 script 裡) - 在
onReadycallback 裡補設mpid屬性(late-adopt 模式,見 §2) - 如果讀者在 idsync 就緒前就作答,這次作答會記在匿名 ID 下——這是 v1 的已知限制
CSP 擋住 script 載入
需在你的 CSP 設定放行:
script-src:widget CDN 網域(URL 待 infra 提供)connect-src:MediaPulse API 網域(待 infra 提供)
詳細 CSP 設定請聯絡 MediaPulse 團隊取得確切網域。
category_id 改了但 widget 顯示的問卷沒有變
category_id 是 pass-through 參數——widget 直接把它送給後端,由後端做 targeting 邏輯。範例的 mock API server 不做 category 篩選(mock 只依 brand 選題)。在真實環境,category targeting 由後端實作。
Next.js next dev 下路徑不對
Next.js 設了 basePath 的情況下,next dev 和 next build 的資源路徑可能略有不同。建議用 next build && next start(或 serve static export)驗證真實行為,而非只依靠 next dev。
Widget 被載入兩次
IIFE 內建 idempotent 保護(WeakSet 去重),重複載入不會造成 double mount。但還是盡量避免重複載入以節省頻寬。
6. 正式部署提醒
Widget CDN:正式發佈走 S3 + CloudFront。確切 CDN URL 在 infra 建置完成後提供。使用固定版本 URL,搭配 SRI(Subresource Integrity)hash 鎖定版本,不會有未通知的 breaking change。
idsync SDK:從 https://sdk.tvbs.com.tw/idsync/tvbs-idsync.min.js 載入(需 ≥ v1.3.3)。
Showcase(範例展示站):media-pulse-showcase.zeabur.app 跑的是共用 mock API server(media-pulse-mock-api.zeabur.app,重用 mock handlers 的真實 HTTP 服務)+ idsync stub,不是真實 API。展示站只供開發期間參考,真實整合請用上面的真實 CDN URL。
CSP 設定:在你的站台放行 widget CDN 的 script-src 和 MediaPulse API 的 connect-src(兩個網域待 infra 確認後提供)。
CORS:widget 對 MediaPulse API 發的請求帶 X-Mpid header,CORS 放行是 MediaPulse 後端責任,不需要你的站台處理。