前言
稍微取樣了胡立先前文章的標題,但實際上這篇文章想分享的面向不太ㄧ樣,並不是 styled-components 本身的 issue 或是其所引用的底層套件的 bug,比較像是特定的使用情境,以及對 styled-components 實際運作原理不熟悉等因素,所導致的 bug。這個 bug 不是很容易被發現,解決的方法也算是有點 hack(運用到與官方文檔描述不符合的 API),我覺得蠻有趣的,加上主要的解法實作是由我同事處理,所以想寫下來給自己做一個紀錄,給自己留點印象,對大家可能沒有直接幫助,可以當故事看看做個參考。
TL;DR
下面的篇幅大部分在講解發現問題的流程,也可以直接跳到#重新梳理一下問題與解法 這個章節看重點。
一切的開端
團隊內有個專案是專門提供給行銷人員製作行銷頁面的內部工具,tech stack 中有使用 React 與 styled-components。原本最早的實作方式是簡單的 Single Page Application,透過 API 拉取內容進行 render,而由於頁面大多是在 App 內的 WebView 開啟,為了提高使用者經驗,我們決定將頁面先行在 server 端進行 prerender,將 HTML 放置 CDN,client 端載入所需資源後,JS 再透過 ReactDOM.hydrate
進行 hydration,讓有互動功能的 react component 可以運作。
prerender 的做法,非常直接,我們使用 puppeteer 開啟 Headless server 直接 render 原本的 SPA 頁面,把 page.content()
給存起來。
會採用這種做法的一個原因是因為原本用在 SPA 上的 component 其實也同時共用在內部工具中,而整個 SPA 頁面也還保留著作為預覽,讓使用者在編輯製作行銷頁面的時候能夠即時看到頁面外觀,因此與其修改成 ReactDOM.renderToString()
,直接將 SPA 渲染完的結果存下來,直覺上簡單很多,程式碼的更動也少。
prerender 完後,接著會把不需要在前端 rehydrate 的 component 從 JS bundle 中移除,inject 需要的 style tag,最後連同 HTML 一起放到 CDN 上。
要 inject style tag 的原因是因為 styled-components 如同多數 CSS-in-JS 解決方案,是使用 CSSOM 去 insertRule,而這樣的做法在 Chrome 85 以前是無法透過 devtool 來調整 style,在開發階段除錯上稍嫌麻煩,所以我們利用 prerender 完的優化階段來把 styled-components 放在 CSSOM 中的 sheet 抓出來塞到 style tag 中(註:當時使用的還不是 styled-components v5+ 因此沒有 disableCSSOMInjection
option 可用。)
簡單的架構圖:
基本上這樣一切都很順利,實際測試上線也都沒什麼太大問題。
但是有個隱藏的 bug 我們一直都沒有發現,就是 prerender 完的頁面存有樣式跑版的機會。
而且這個情況並不是很容易發生,實際上在我們意識到之前,PM 早已回報過一兩次,但基於慣性,發現頁面有點奇怪時,自然反應是重新整理、重新執行,看看能否重現 issue,而這個 bug 在重新 prerender 後有蠻大的機率會再次隱藏起來,也因此沒有獲得足夠的注意力讓我們持續專研下去。
直到某個同事在實作一個頁面載入後會有較多狀態變化的 component 時,他發現到,在沒有更動程式碼的情況下,在 client 端才動態出現的 component 樣式卻跑掉了,但瀏覽器 first load HTML 時卻是正常的。
到底是什麼問題
為了除錯,我利用 Devtool 插入中斷點,發現到第一次 HTML 載入時,一切的 Style 都是正常的,包含 styled-components 所產生的 class name 以及我們 inject 到 html 內的 style tag;然而當 JS bundle 載入,前端 react component 重新 render 後,HTML DOM 上的 styled-components class name 與原先的不同,class name 所對應的 css style rule 也不一樣。
接著再細看最終瀏覽器渲染的 HTML,發現到 <head>
內有兩個屬於 styled-components 的 style tag。
到這邊問題就比較清楚了。
我們塞了兩次 styled-components 產生的 style tag,所以導致 class name 的 style 有衝突。
上面紅線括弧的部分是我們在 server-side inject 進去,含有 css text 的 style tag;下方黃線標記的則是 client 端 hydrate 後,所產生的新的 style tag。
造成的後果就是兩個毫無相干的 component 有機會共享了同樣的 class name,其中一個 component 的 style 就跑掉了:
<!--Call to action component-->
<div class="sc-AxiKw iOTgPf"></div>
<!--FQA component-->
<div class="sc-AxhUy iOTgPf"></div>
另外值得一提的是,在我們的 case 中,由於我們還會把不需要在前端 rehydrate 的 react component code 從 JS bundle 裡拿掉,所以那些我們所謂 靜態的 component 樣式基本上不太會受影響,才讓這個 bug 比較難發現。
解決方案
既然發現多了一個 style tag,那我們把後來蓋上去的那個 tag 拿掉不就得了嗎?
但事情果然不是憨人想得這麼簡單。
拿掉在 client-side 重新產生的 style tag 是可以確保整個 application 只吃到我們原先在 server 端 inject 的 CSS 樣式,但在 rehydrate 的過程中,component 的 class name 也變了,所以單純拿掉 client side style tag 反而讓整個頁面的樣式更加慘烈。
還是得從根本解決問題。
之所以會產生兩個 style tag,原因在於我們在 client 端進行 hydration 時,並沒有正確處理 styled-components 的 hydration,只顧慮到了 react component 本身的 hydration,對於 styled-compponent 來說,我們在 server-side inject 的 style tag 不足以讓他進行 rehydrate(原因後面會說明),所以他實際上只能重新 render 所有 component 的 style,重新產生 class name 與 style tag。
而在了解到我們自己 inject 的 style tag 無法讓 styled-components 進行 rehydrate 後,我試著用前面提到過的 disableCSSOMInjection
,根據官網,可以搭配 StyleSheetManager
,讓 styled-components 自己 export 出含有 css text 的 style tag,但結果還是一樣有問題(後面會解釋原因)。
因此,還是得仔細了解該如何讓 styled component 能正確 rehydrate,讀取我們在 prerender 時就已經處理過的 style,才不會造成 class name 衝突,以及有多餘的 style tag 產生。
styled-components 的 hydration
知道 solution 的方向後,開始到官網查閱關於 server side hydration 的資料,發現有針對 server-side rendering 所提出的解決方案:利用 ServerStyleSheet
可以搭配 StyleSheetManager
provider,讓 styled-components 在 server-side 能夠產生 css style,並且提供機制讓該 style 能在 client-side 被 rehydrate:
const sheet = new ServerStyleSheet()
try {
const html = renderToString(
<StyleSheetManager sheet={sheet.instance}>
<YourApp />
</StyleSheetManager>
)
const styleTags = sheet.getStyleTags() // or sheet.getStyleElement();
} catch (error) {
// handle error
} finally {
sheet.seal()
}
看起來就是該這樣做,不過呢,光從名字就跟你說了他是給你在 Server 用的,甚至還在文檔中寫上:Just make sure not to use it on the client-side.
但我們的 prerender 實際上是跑在 client 端,這樣肯定不行吧?
我原本也是這樣想,打算放棄的時候,我優秀的同事跑去讀了讀 styled-components 的原始碼,發現 ServerStyleSheet
內其實沒有用到任何 NodeJS 獨有的 API,也就是說實際上 ServerStyleSheet
跑在 client 端也是沒問題的!
ServerStyleSheet
跟一般 styled-components 在 client 端使用的 StyleSheet
(styled-components 自己實作的版本,並非 Browser 內建的 StyleSheet)差別在於,ServerStyleSheet
多傳了一個 isServer = true
的 option 給 StyleSheet
,而這會使得 styled-components 的 StyleSheet
在 makeTag
的時候,不是去生成一個實際的 style tag,而是產生一個 VirtualTag
:
/** Create a CSSStyleSheet-like tag depending on the environment */
export const makeTag = ({ isServer, useCSSOMInjection, target }: SheetOptions): Tag => {
if (isServer) {
return new VirtualTag(target);
} else if (useCSSOMInjection) {
return new CSSOMTag(target);
} else {
return new TextTag(target);
}
};
這個 VirtualTag
包含所有 style 的資訊,但不需要操作到實際的 DOM,這就是讓 styled-components 能支援 SSR 的原因。
而 StyleSheet
的 toString
函式,能夠 serialize makeTag
產生的 tag 的內容:
export const outputSheet = (sheet: Sheet) => {
const tag = sheet.getTag();
const { length } = tag;
let css = '';
for (let group = 0; group < length; group++) {
const id = getIdForGroup(group);
if (id === undefined) continue;
const names = sheet.names.get(id);
const rules = tag.getGroup(group);
if (names === undefined || rules.length === 0) continue;
const selector = `${SC_ATTR}.g${group}[id="${id}"]`;
let content = '';
if (names !== undefined) {
names.forEach(name => {
if (name.length > 0) {
content += `${name},`;
}
});
}
// NOTE: It's easier to collect rules and have the marker
// after the actual rules to simplify the rehydration
css += `${rules}${selector}{content:"${content}"}${SPLITTER}`;
}
return css;
};
再搭配 ServerStyleSheet
提供的 getStyleTags
方法,能夠 output 出正確的 style tag 來 inject 到你 SSR 產生的 HTML 內,讓 styled-components 可以順利 Rehydrate。
而何謂 “正確” 的 style tag 呢?
在我們最一開始的實作中,我們自己是透過下面這般方式產生出要 inject 到 HTML 中的 style tag:
document.querySelectorAll('style').forEach(elem => {
// styled-components 預設產生的 tag 會是空的,因為採用 CSSOM API insertRule
if (elem.innerHTML !== '' || !(elem.sheet instanceof CSSStyleSheet)) return;
elem.innerHTML = Array.from(elem.sheet.cssRules || [])
.map(rule => rule.cssText)
.join('');
});
也就是說我們是取出 styled-components 預設產出的 style tag,把其中的 cssRules 讀出來後塞回去。
這樣的做法錯在會保留 style tag 上的其他屬性,像是 data-styled=active
。
當 styled-components 在進行 Rehydrate 時,他會去抓取 data-styled
不為 active
的 style tag 來 parse,並進行 rehydrate:
export const rehydrateSheet = (sheet: Sheet) => {
const nodes = document.querySelectorAll(SELECTOR);
for (let i = 0, l = nodes.length; i < l; i++) {
const node = ((nodes[i]: any): HTMLStyleElement);
if (node && node.getAttribute(SC_ATTR) !== SC_ATTR_ACTIVE) {
rehydrateSheetFromTag(sheet, node);
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
}
};
這也說明了為什麼一開始我使用 disableCSSOMInjection
讓 styled-components 幫我產生 text node-based 的 css style tag 也沒有用,因為那樣產生的 style tag ㄧ樣會是帶有 data-style=active
的屬性,並不會被 styled-components 拿去 rehydrate。
這時我們更加釐清了問題。
首先,有兩個 styled-components 的 style tag 的確不對,但重點是這兩個 style tag 同時都擁有 data-styled
為 true
的屬性,以致於 styled-components 在 rehydrate 的時候抓不到可用的 style tag。
另外,也不是單純 data-styled
不為 true
的 style tag 就可以被 rehydrate,前面提到 StyleSheet
的 toString
能夠 serialize VirtualTag
的內容,其中有一段程式碼與註解:
// NOTE: It's easier to collect rules and have the marker
// after the actual rules to simplify the rehydration
css += `${rules}${selector}{content:"${content}"}${SPLITTER}`;
這邊指示出,要能夠被 rehydrate 的 text node-based 的 css style tag,需要有特定的 SPLITTER
:
export const SPLITTER = '/*!sc*/\n';
const rehydrateSheetFromTag = (sheet: Sheet, style: HTMLStyleElement) => {
const parts = style.innerHTML.split(SPLITTER);
const rules: string[] = [];
// ... 略
};
重新梳理一下問題與解法
問題
我們頁面 prerender 完的結果,在 client 端 first load 與 JS bundle rehydrate 後的樣式不同,原因是利用 client side render 製作 prerender 時,產生兩個同樣擁有 data-styled=true
屬性的 styled-components style tag,造成 styled-components 無法 rehydrate,只好重新產生新的 style tag,因而破壞了頁面樣式。
解法
使用 ServerStyleSheet
搭配 StyleSheetManager
在 prerender 階段生成 VirtualTag
(這邊的使用方法稍微 hack 了一點,因為我們的 prerender 是 client-side render,而官方不建議將 ServerStyleSheet
使用在 client side),接著再透過 ServerStyleSheet
的 getStyleTags
取得可被 styled-components 存取進行 rehydration 的 style tag,並 inject 到 prerendered HTML 中。
主要的程式碼片段如下:
const sheet = new ServerStyleSheet();
// 放在整個 Application 的最上層
const StyleSheetProvider: React.FC = ({ children }) => (
<StyleSheetManager sheet={sheet.instance} children={children} />
);
// 在 prerender 結束,產生 HTML 後執行
const injectStyleElement = () => {
const style = document.createElement('style');
document.head.appendChild(style);
style.outerHTML = sheet.getStyleTags();
sheet.seal();
};
到這邊為止就算是將問題解決了,但會發現還是有問題,雖然 style tag 成功只剩下一個,看起來 rehydrate 也有成功,但樣式還是跑掉了。
還好這個原因很好找,官網就有解答 :
In order to reliably perform server side rendering and have the client side bundle pick up without issues, you'll need to use our babel plugin. It prevents checksum mismatches by adding a deterministic ID to each styled component.
就是 SSR 最常見的 checksum issue,我們需要給每一個 styled-component 一個在前後端一致的 ID,這樣才可以確保 rehydrate 後能夠擁有相同的 class。
加上 babel-plugin 後問題就成功解決了!
結論
經過這次的除錯過程,我才發現自己對於 styled-component 這類 React 生態系的套件了解度不夠深,才導致在一開始設計實作 prerender 時沒有注意到這件事情,另外也透過這次的紀錄發現要將一個 bug 的原因與解法從頭到尾書寫出來有多困難,畢竟整個除錯過程你可能是跳耀性的在思考各種可能,文章中的每個步驟其實也絕對不是這樣一步步找出解法的,要有條理的將其梳理出一個流暢的 flow 真的需要一點功力,看來我還有很大的進步空間!
雖然這次的問題實際上的解法不需要更動多少程式碼,原理也很簡單,但若是沒有像我同事那樣鑽入程式碼去查看,光是憑靠自己的邏輯是很難思考出來的。整個過程學習到很多,遇到這個 bug 真是太好了!(好像怪怪的...)