[心得] 如何在 React (with Next.js) 上使用到 WebComponents (上)

Toki Lee
17 min readDec 21, 2020

--

圖片來自 WebCompoentsNext.js 官網

在一段日子前曾有稍微接觸 WebComponents ,而後來又改為主要使用 React ,在期間又突發奇想的把 WebComponents 加入到 React 的專案之中,雖然使用上有些缺點,但我依然還是喜歡 WebComponents 的特性,因此才想做個紀錄。

先說說 WebComponents , WebComponents 是建立在原生的組件系統,使用基本類別 HTMLElement customElements ,可以讓使用者定義出屬於自己的 HTML 元素。如同我們在使用一些框架的理由,藉由組件讓我們的專案更加結構化,重用組件也節省了我們的時間。

同樣的,為了避免 CSS 全域污染我們可能會使用到一些 CSS-in-JS 的相關技術,WebCompoents 這邊也提供了 Shadow DOM ,來解決這個問題。

那麼今天的目標就是結合這兩者在同一個專案之中。

講到這邊,也許會有人覺得不對勁,既然兩者解決的事幾乎相同,那麼全部都使用其中一種不就好了不是?

沒錯!你是對的,不如說我更建議你使用主流前端框架去完成你的工作,你可以找到很多的工具與輪子,甚至像是 SSR 技術可能是 WebComponents 難以給予你的好處。

那麼這篇文章的目的是什麼,為何要結合這兩者呢?

我認為一些習慣操作 DOM 的使用者,在使用著前端框架的這個當下,可能偶爾對於直接掌控 DOM 會有種難以忘懷的感覺(當然框架中有正確的作法可能像是 React 的 Ref 之類的),但如果你現在正尋求著一些奇怪的解法,那不妨試看看 WebComponents。

那麼回到主題,在這裡我會選擇 Next.js 作為基本的 React 架構,並且配合原生的 HTMLElement,而這樣的組合將會遇到些問題,我先列出幾個問題:

  1. 專案結構
    像是我們習慣在 /src/components 下放 React 的 components,那怎麼處理 WebComponents 比較適合呢?
  2. 與 SSR 的配合:
    如果後端渲染會碰到 WebComponents 的話應該會出些問題,如同大多只存在於瀏覽器的物件一樣,當在後端使用到時可能會噴錯。
  3. 關於 customElements 重複定義
    如果同一個 WebComponents 的名稱會被再次定義到,將會噴錯,這個問題或多或少會發生,也許是受到 React 生命週期的影響,也可能單純是 HMR (Hot Module Replacement) 的原因。
  4. 瀏覽器支援
    畢竟 WebComponents 算是個比較新的東西?我們可能會想起某些聲稱不再支援但總也死不掉的瀏覽器,那我們將會需要 Polyfills。

以上的問題,我會一項一項說明使用的方式,和用到的套件。

專案結構

稍微提下我最早使用的方式:

  1. 增加 WebComponents 的進入點。
    這是最初使用的方式,並且我以 .wc.js 代表 WebComponents 的檔案,使用後我覺得非常的麻煩,而且記得在過程中一直有不少問題,所以後來就放棄了。
  2. 再寫一份 webpack config
    這個做法是在另個目錄下放 WebComponents 的相關檔案,並把 bundle 的檔案引入 Next.js 的專案中,雖然這個方式有達到目的,但我覺得還是不夠便利,所以最後也不太推薦這個做法。

最後從第二個做法延伸,那就是用 yarn workspace 將兩者區分為兩個 package,而因為我是近期才學習到 Mono-Repo 的概念,在了解後才發覺似乎可以試試看這樣做。

那就開個專案吧!如果不清楚 yarn workspace,可能稍微先了解下會比較好。(後來有補了另一篇做說明,可以參考看看 [筆記] Yarn Workspaces 基礎教學

mkdir nextjs-wc-ex
cd nextjs-wc-ex
mkdir packages

然後在專案目錄下加入 package.json,而這個目錄我們先稱為 workspace root

// package.json{
"private": "true",
"workspaces": ["packages/*"]
}

上面的 private 代表 workspace root 本身並不允許發布,而 workspaces 下則定義了工作區的位置。

Next-app 工作區

接下來開始加入我們的第一個工作區:

cd packages
yarn create next-app
...What is your project named? › next-app...

跑完之後你會看到這樣的目錄結構

工作區中專案的依賴放在 root node_modules 了

可以注意到的是,工作區的依賴會被放到 workspace root 下的node_modules 中,先稱這個為 root node_modules 目錄,這時候修改 package 的名稱為@nextjs-wc-ex/next-app 後 Next.js 專案的工作區就完成了。

雖然到目前為止什麼都還沒做,但你應該能馬上理解下一件事就是加上 WebComponents 的工作區。

wc-components 工作區

在這邊我會直接使用 Webpack 簡單處理下就好,如果有使用過 Polymer.js 或是 LitElement 的使用者也可以嘗試看看,不過我自己這邊是使用後又移除掉了,原因只是單純想要減少打包出去的大小。

首先在 packages 目錄下建立 we-components 的工作區:

mkdir wc-components

加上 package.json

{
"name": "@nextjs-wc-ex/wc-components",
"version": "0.1.0",
"main": "dist/index.js",
"license": "MIT",
"scripts": {
"watch": "webpack -w",
"build": "webpack"
}
}

接下來將會加上 webpack 的相關套件,在使用 yarn workspace 時可以用以下的指令,對特定的工作區加入套件:

yarn workspace @nextjs-wc-ex/wc-components add -D webpack \
webpack-cli \
webpack-dev-server \
webpack-manifest-plugin \
clean-webpack-plugin \
@babel/core \
babel-loader \
@babel/preset-env

接下來加上 webpack 的設定檔 webpack.config.js

// packages/wc-components/webpack.config.jsconst path = require('path')
const { WebpackManifestPlugin } = require('webpack-manifest-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
entry: {
'index': path.join(__dirname, 'src/index.js'),
},
output: {
path: path.join(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.(js)$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
}
],
},
resolve: {
alias: {
'@': path.resolve(__dirname),
src: path.resolve(__dirname, 'src/'),
},
},
devServer: {
contentBase: path.join(__dirname, 'dist'),
compress: true,
writeToDisk: true,
port: 9001,
},
plugins: [new WebpackManifestPlugin(), new CleanWebpackPlugin()],
}

以上的設定以 src/index.js 作為進入點並打包到 dist 目錄,也就是說 WebComponents 的 components 將會在 src 下進行開發。而下面兩個 Plugin 在這篇文章中我並不會使用到,但因為是蠻習慣上會安裝的 Plugin ,所以還是順便寫上來了。

接下來將會隨意的加個 WebComponents,如果對 WebComponents 不熟悉的可以參考看看之前的文章 Web Components基本概念

加入兩個檔案:

// packages/wc-components/src/index.jsexport { HelloWebComponents } from './components/hello-web-components'// packages/wc-components/src/components/hello-web-components/index.jsexport class HelloWebComponents extends HTMLElement {
constructor() {
super()
this._containerElement = null
this.attachShadow({mode: 'open'})
this.shadowRoot.innerHTML = `
<div class="container">
</div>
`
}
connectedCallback() {
console.log('hello-web-components connected!')
setTimeout(() => {
this.gretting()
})
}
disconnectedCallback() {
console.log('hello-web-components disconnected!')
}
get containerElement() {
if (!this._containerElement) {
this._containerElement = this.shadowRoot.querySelector('.container')
}
return this._containerElement
}
gretting() {
const grettingDiv = document.createElement('div')
grettingDiv.innerText = 'Hello WebComponents!'
this.containerElement.appendChild(grettingDiv)
}
}
customElements.define('hello-web-components', HelloWebComponents)

接下來執行 yarn workspace @nextjs-wc-ex/wc-components build 將會看到打包好的檔案就在 dist 目錄下了。

整合

終於要回到我們的初衷 — 在 React 中使用到 WebComponents 。如何在 Next.js 這邊使用到另個 package 的內容呢?

因為在這邊我們使用了 yarn workspace 的方式,我們只要在工作區 package.json 的依賴中加入另個工作區 package 的名稱與版本後,就能夠像使用其他 npm 套件一般。那麼現在就馬上將@nextjs-wc-ex/wc-components加入@nextjs-wc-ex/next 的依賴中吧。

修改 next-apppackage.json

// packages/next-app/package.json
{
"name": "@nextjs-wc-ex/next-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "10.0.3",
"react": "17.0.1",
"react-dom": "17.0.1",
"@nextjs-wc-ex/wc-components": "0.1.0"
}
}

那麼現在開始就能在 Next.js 的專案下使用到 <hello-web-components>這個WebComponents 了 ,也就是說,接下要來說明會遇到的問題了,我們來看看可能會出現的錯誤吧:

  1. SSR 碰到 WebComponents

如果我們直接在 _app.js 下加入 import '@nextjs-wc-ex/wc-components' 會發生什麼事?

修改 next.js 專案下的 index.js_app.js 檔案:

// packages/next-app/pages/_app.jsimport '../styles/globals.css'
import '@nextjs-wc-ex/wc-components'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp// packages/next-app/pages/index.jsimport Head from 'next/head'
import styles from '../styles/Home.module.css'
export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Nextjs WebComponents Example</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<hello-web-components/>
</main>
</div>
)
}

接下來在 workspace root 下執行 yarn workspace @nextjs-wc-ex/next-app dev 與 yarn workspace @nextjs-wc-ex/wc-components watch,來看看會遇到的問題:

HTMLElement is not defined

總之,就是因為在後端 Node.js 這邊沒有 window.HTMLElement 的原因啦,相信許多使用過 SSR 相關技術的人應該對這個錯誤並不陌生,也許是初學時有遇到過,又或是使用了第三方套件時偶爾踩到。

所以這邊的做法是修改 _app.js ,改為 dynamic import

// packages/next-app/pages/_app.jsimport { useEffect } from 'react'
import '../styles/globals.css'
function MyApp({ Component, pageProps }) {
useEffect(() => {
const importWC = async () => {
await import('
@nextjs-wc-ex/wc-components')
}
importWC()
}, [])
return <Component {...pageProps} />
}
export default MyApp

這樣就能夠解決了,另外其實也能是直接在 _document.js 直接 include已經打包好的 WebComponents bundle檔案,這樣也能確保只會在前端執行到,不過這是我在第二個方案使用過的方式,這邊就不提到了。

2. WebComponents 重複定義

這個問題如果是 HMR 所帶來時可能就不是那麼重要,因為這不是會發生在正常發布的 app 當中。

重複定義相同的 WebComponents

關於這個問題,這邊是有找到了一個套件 custom-elements-hmr-polyfill 可以讓這個錯誤不要跳出來。

修改 /packages/wc-components/src

import { applyPolyfill } from 'custom-elements-hmr-polyfill';// custom-elements-hmr-polyfill
applyPolyfill();
export { HelloWebComponents } from './components/hello-web-components'

雖然加上這個套件後,就不會再跳出錯誤,不過可惜的是似乎 HMR 的改變沒有及時反應在畫面上,也就是說,還是需要手動去 reload 才能夠看到結果,我不確定是不是因為結合 React 的原因。有機會再來研究看看。

而在這種情況我認為不使用 HMR 改為 hot reload 可能會比較適合,不然就要再等待看看社群上會不會有更好的解法。

3. 瀏覽器支援

如果較舊的瀏覽器不支援 customElements 可能會噴以下的錯誤:

這邊以舊版的 firefox 為例

這個並不是太困難的問題,這邊只要加上 webcomponents/polyfills 這個 Polyfills ,就能夠解決了,那麼因為這邊省個麻煩會直接使用個 jsdelivr CDN 就好。

next-app 增加 _document.js 檔案:

// packages/next-app/pages/_documents.jsimport Document, { Html, Head, Main, NextScript } from 'next/document'class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render() {
return (
<Html>
<html>
<Head>
<script src="https://cdn.jsdelivr.net/npm/@webcomponents/webcomponentsjs@2.5.0/webcomponents-loader.js"></script>
</Head>
<body>
<Main />
<NextScript />
</body>
</html>
</Html>
)
}
}
export default MyDocument

這樣就能讓舊版的瀏覽器也支援 WebComponents 了:

另外要注意的一件事情是,在這邊使用到的是 webcomponents-loader.js,使用這個檔案會動態載入需要的 Polyfills,相對來說webcomponents-bundle.js 則是包含了完整的 Polyfills ,如果是對於檔案大小有要求的使用者,一定要確定是使用了哪個比較好。

整個的內容整理如下:

到這裡為止這篇主要是在說明如何結合 WebComponents 與 Next.js 的專案,雖然還有些不理想的地方,但目前還在研究其他可能的用法,而下篇文章可能會說明我什麼時候會使用到 WebComponents ,不過這可能要在構思下才行…

--

--

Toki Lee

沒有技術上不可行,只是時間上做不到⋯