← 回首頁

Widget 框架整合指南

本文件適合外部開發者(IP 站台工程師)——你想把 MediaPulse 問卷 widget 嵌進自己的真實站台

注意:範例 repo 與真實整合的差異

examples/ 目錄下的各框架範例(examples/html/examples/react/examples/vue/examples/next/examples/astro/)皆使用兩個「測試替身」,讓它們能在 repo 內完全自給自足地執行:

你的真實站台不會用這兩個。本文件說明的是真實整合的做法;範例只作為程式碼結構的參考。


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-iddata-layoutdata-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() 選項:

參數型別說明
elstring | ElementCSS selector 或 DOM 元素
brandIdstring品牌 ID(必填)
categoryIdstring?頁面分類(選填,後端用來選題)
mpidstring?投票者 ID(見 §2)
layout'inline' | 'sidebar'版型(預設 'inline'
lazyboolean?是否懶載入(預設 false)
surveyIdstring?直接指定問卷 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):

  1. 顯式 mpid 屬性<media-pulse-survey mpid="...">el.setAttribute('mpid', ...)
  2. window.TVBS_IDSync.getId()(SDK 已就緒時自動讀取)
  3. 匿名 fallback:widget 用 crypto.randomUUID() 在宿主站第一方 localStorage(key mp_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.htmlexamples/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-idcategory-idmpidlayout 全部都是字串屬性,所以正常使用完全沒問題。但如果你未來需要傳遞非字串值給某個 custom element(例如物件或陣列),JSX prop 方式無效——請改用 ref + useEffect 裡的 el.setAttribute() / el.someProperty = value

參考範例examples/react/src/SurveySlot.tsxexamples/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.tsxexamples/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>
  )
}

注意事項


Astro(靜態站)

參考範例examples/astro/src/pages/astro/index.astroexamples/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


Vue(Vite SPA)

參考範例examples/vue/src/SurveySlot.vueexamples/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 階段。

通用規則

各框架對應

框架做法
Next.js App Router'use client' + 在 useEffect 裡讀 window.TVBS_IDSync 和操作 DOM
Next.js static export無 server widget,完全 client-side,useEffect 安全
Astroidsync 用 <script is:inline>(client HTML);client 邏輯用 <script>(Astro client bundle);frontmatter 不碰 window
NuxtonMounted 讀 idsync;widget script 在 app.html 的 head 用 defer
React(純 Vite SPA)SPA 沒有 SSR,useEffect 跑在 client,直接安全
Vue(純 Vite SPA)同上,onMounted 安全

5. Troubleshooting

Widget element 沒有渲染

可能原因:

  1. Widget script 沒有載入(查 Network tab 確認 widget.iife.js 有成功載入)
  2. defer script 的執行順序問題:確保 widget script 在元件 DOM 已存在後才執行(defer 在 DOMContentLoaded 之前執行,通常沒問題)
  3. 元素的 display: none 或 container 不存在

Vue 出現 [Vue warn]: Unknown custom element: <media-pulse-survey>

需要設定 isCustomElement,見 §3 Vue 專節。

mpid 一直是空字串 / null

idsync 尚未就緒。解法:

  1. 確認 TVBS_IDSync.init() 有被呼叫(在頁面 script 裡)
  2. onReady callback 裡補設 mpid 屬性(late-adopt 模式,見 §2)
  3. 如果讀者在 idsync 就緒前就作答,這次作答會記在匿名 ID 下——這是 v1 的已知限制

CSP 擋住 script 載入

需在你的 CSP 設定放行:

詳細 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 devnext 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 後端責任,不需要你的站台處理。