先說說 WebComponents , WebComponents 是建立在原生的組件系統,使用基本類別 HTMLElement
與 customElements
,可以讓使用者定義出屬於自己的 HTML 元素。如同我們在使用一些框架的理由,藉由組件讓我們的專案更加結構化,重用組件也節省了我們的時間。
同樣的,為了避免 CSS 全域污染我們可能會使用到一些 CSS-in-JS 的相關技術,WebCompoents 這邊也提供了 Shadow DOM ,來解決這個問題。
那麼今天的目標就是結合這兩者在同一個專案之中。
講到這邊,也許會有人覺得不對勁,既然兩者解決的事幾乎相同,那麼全部都使用其中一種不就好了不是?
沒錯!你是對的,不如說我更建議你使用主流前端框架去完成你的工作,你可以找到很多的工具與輪子,甚至像是 SSR 技術可能是 WebComponents 難以給予你的好處。
那麼這篇文章的目的是什麼,為何要結合這兩者呢?
我認為一些習慣操作 DOM 的使用者,在使用著前端框架的這個當下,可能偶爾對於直接掌控 DOM 會有種難以忘懷的感覺(當然框架中有正確的作法可能像是 React 的 Ref 之類的),但如果你現在正尋求著一些奇怪的解法,那不妨試看看 WebComponents。
那麼回到主題,在這裡我會選擇 Next.js 作為基本的 React 架構,並且配合原生的 HTMLElement
,而這樣的組合將會遇到些問題,我先列出幾個問題:
- 專案結構
像是我們習慣在/src/components
下放 React 的 components,那怎麼處理 WebComponents 比較適合呢? - 與 SSR 的配合:
如果後端渲染會碰到 WebComponents 的話應該會出些問題,如同大多只存在於瀏覽器的物件一樣,當在後端使用到時可能會噴錯。 - 關於
customElements
重複定義
如果同一個 WebComponents 的名稱會被再次定義到,將會噴錯,這個問題或多或少會發生,也許是受到 React 生命週期的影響,也可能單純是 HMR (Hot Module Replacement) 的原因。 - 瀏覽器支援
畢竟 WebComponents 算是個比較新的東西?我們可能會想起某些聲稱不再支援但總也死不掉的瀏覽器,那我們將會需要 Polyfills。
以上的問題,我會一項一項說明使用的方式,和用到的套件。
專案結構
稍微提下我最早使用的方式:
- 增加 WebComponents 的進入點。
這是最初使用的方式,並且我以.wc.js
代表 WebComponents 的檔案,使用後我覺得非常的麻煩,而且記得在過程中一直有不少問題,所以後來就放棄了。 - 再寫一份 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...
跑完之後你會看到這樣的目錄結構
可以注意到的是,工作區的依賴會被放到 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-app
的 package.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 了 ,也就是說,接下要來說明會遇到的問題了,我們來看看可能會出現的錯誤吧:
- 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
,來看看會遇到的問題:
總之,就是因為在後端 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 當中。
關於這個問題,這邊是有找到了一個套件 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
可能會噴以下的錯誤:
這個並不是太困難的問題,這邊只要加上 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 ,不過這可能要在構思下才行…