CSS-in-JS 的庫是如何工作的?
本文將介紹 CSS-in-JS 的實現原理。
前言
筆者近期學習 Material UI 的過程中,發現 Material UI 的組件都是使用 CSS-in-JS 的方式編寫的,聯想到之前在社區裏看到過不少批判 CSS-in-JS 的文章,對此有些驚訝。
CSS-in-JS 的庫是如何工作的?是什麼讓 Material UI 選擇了 CSS-in-JS 的方式開發組件庫?
這不禁引起了筆者的好奇,於是決定探索一番,實現一個自己的 CSS-in-JS 庫。
調研
目前社區中流行的 CSS-in-JS 庫主要有兩款:
-
emotion
-
styled-components
兩者的 API 基本一致,鑑於 emotion
的源碼中 JavaScript、Flow、TypeScript 三種代碼混在一起,閱讀起來實在是爲難筆者,因此果斷放棄了學習 emotion
的念頭。
那麼就以 styled-components
爲學習對象,看看它是如何工作的。
styled-components 核心能力
使用方法
打開 styled-components
官方文檔,點擊導航欄的 Documentation,找到 API Reference 一欄,第一個展示的就是 styled-components
的核心 API——styled
,用法相信瞭解 React 的同學或多或少接觸過:
官方文檔地址:https://styled-components.com/
Documentation 地址:https://styled-components.com/docs
API Reference 地址:https://styled-components.com/docs/api
const Button = styled.div`
background: palevioletred;
border-radius: 3px;
border: none;
color: white;
`;
const TomatoButton = styled(Button)`
background: tomato;
`;
通過在 React 組件中使用模板字符串編寫 CSS 的形式實現一個自帶樣式的 React 組件。
抽絲****剝繭
clone styled-components
的 GitHub Repo 到本地,安裝好依賴後用 VS Code 打開,會發現 styled-components
是一個 monorepo
,核心包與 Repo 名稱相同:
GitHub Repo 地址:https://github.com/styled-components/styled-components
直接從 src/index.ts
開始查看源碼:
默認導出的 styled
API 是從 src/constructors/styled.tsx
導出的,那麼繼續向上溯源。
src/constructors/styled.tsx
中的代碼非常簡單,去掉類型並精簡後的代碼如下:
import createStyledComponent from '../models/StyledComponent';
// HTML 標籤列表
import domElements from '../utils/domElements';
import constructWithOptions from './constructWithOptions';
// 構造基礎的 styled 方法
const baseStyled = (tag) =>
constructWithOptions(createStyledComponent, tag);
const styled = baseStyled;
// 實現 HTML 標籤的快捷調用方式
domElements.forEach(domElement => {
styled[domElement] = baseStyled(domElement);
});
export default styled;
從 styled API 的入口可以得知,上面使用方法一節中,示例代碼:
使用方法地址:https://www.notion.so/CSS-in-JS-8d64aa49e0554607b596de753bcabee8
const Button = styled.div`
background: palevioletred;
border-radius: 3px;
border: none;
color: white;
`;
實際上與以下代碼完全一致:
const Button = styled('div')`
background: palevioletred;
border-radius: 3px;
border: none;
color: white;
`;
styled
API 爲了方便使用封裝了一個快捷調用方式,能夠通過 styled[HTMLElement]
的方式快速創建基於 HTML 標籤的組件。
接下來,繼續向上溯源,找到與 styled
API 有關的 baseStyled
的創建方式:
const baseStyled = (tag) =>
constructWithOptions(createStyledComponent, tag);
找到 constructWithOptions
方法所在的 src/constructors/constructWithOptions.ts
,去掉類型並精簡後的代碼如下:
import css from './css';
export default function constructWithOptions(componentConstructor, tag, options) {
const templateFunction = (initialStyles, ...interpolations) =>
componentConstructor(tag, options, css(initialStyles, ...interpolations));
return templateFunction;
}
精簡後的代碼變得異常簡單,baseStyled
是由 constructWithOptions
函數工廠創建並返回的。constructWithOptions
函數工廠的核心其實是 templateFunction
方法,其調用了組件的構造方法 componentConstructor
,返回了一個攜帶樣式的組件。
至此,即將進入 styled-components
的核心,一起抽絲剝繭一探究竟。
核心源碼
constructWithOptions
函數工廠調用的組件構造方法 componentConstructor
是從外部傳入的,而這個組件構造方法就是整個 styled-components
的核心所在。
在上面的源碼裏,baseStyled
是將 createStyledComponent
這個組件構造方法傳入 componentConstructor
後返回的 templateFunction
,templateFunction
的參數就是通過模板字符串編寫的 CSS 樣式,最終會傳入組件構造方法 createStyledComponent
中。
文字描述非常混亂,畫個圖來梳理一下創建一個帶有樣式的組件的流程:
也就是說,當用戶使用 styled
API 創建帶有樣式的組件時,本質上是在調用 createStyledComponent
這個組件構造函數。
createStyledComponent
的源碼在 src/models/StyledComponent.ts
中,由於源碼較爲複雜,詳細閱讀需要移步 GitHub:
styled-components/StyledComponent.ts at main · styled-components/styled-components
從源碼可以得知,createStyledComponent
的返回值是一個帶有樣式的組件 WrappedStyledComponent
,在返回這個組件之前對其做了一些處理,大部分都是設置組件上的一些屬性,可以在查看源碼的時候暫時跳過。
從返回值向上溯源,發現 WrappedStyledComponent
是使用 React.forwardRef
創建的一個組件,這個組件調用了 useStyledComponentImpl
這個 Hook 並返回了 Hook 的返回值。
繼續從返回值向上溯源,發現 useStyledComponentImpl
Hook 中的大部分代碼都是與我們瞭解 styled-components
是如何工作這件事情無關的代碼,都是可以在查看源碼的時候暫時跳過的部分,但是有一個 Hook 的名稱讓筆者覺得就是整個 styled-components
最核心的部分:
const generatedClassName = useInjectedStyle(
componentStyle,
isStatic,
context,
process.env.NODE_ENV !== 'production' ? forwardedComponent.warnTooManyClasses : undefined
);
在撰寫本文之前,筆者大致知道 CSS-in-JS 的庫是通過在運行時解析模板字符串並且動態創建 <style></style>
標籤將樣式插入頁面中實現的,從 useInjectedStyle
Hook 的名稱來看,其行爲就是動態創建 <style></style>
標籤並插入頁面中。
深入 useInjectedStyle
,去掉類型並精簡後的代碼如下:
function useInjectedStyle(componentStyle, isStatic, resolvedAttrs, warnTooManyClasses) {
const styleSheet = useStyleSheet();
const stylis = useStylis();
const className = isStatic
? componentStyle.generateAndInjectStyles(EMPTY_OBJECT, styleSheet, stylis)
: componentStyle.generateAndInjectStyles(resolvedAttrs, styleSheet, stylis);
return className;
}
useInjectedStyle
調用了從參數傳入的 componentStyle
上的 generateAndInjectStyles
方法,將樣式傳入其中,返回了樣式對應的 className
。
進一步查看 componentStyle
,是在 createStyledComponent
中被實例化並傳入 useInjectedStyle
的:
const componentStyle = new ComponentStyle(
rules,
styledComponentId,
isTargetStyledComp ? styledComponentTarget.componentStyle : undefined
);
因此 componentStyle
上的 generateAndInjectStyles
實際上是 ComponentStyle
這個類的實例方法,對應的源碼比較長也比較複雜,詳細閱讀需要移步 GitHub,核心是對模板字符串進行解析,並將類名 hash 後返回 className
:
styled-components/ComponentStyle.ts at main · styled-components/styled-components
核心源碼至此就已經解析完成了,其餘部分的源碼主要是爲了提供更多基礎功能以外的 API,提高可用度。
solid-sc 實現
解析完 styled-components
的核心源碼後,迴歸到 CSS-in-JS 出現的原因上,最重要的因素可能是 JSX 的出現,在前端領域裏掀起了一股 All in JS 的浪潮,就此誕生了上文中提到的 CSS-in-JS 的庫。
那麼,我們是否可以理解爲 CSS-in-JS 與 JSX 屬於一個綁定關係?
無論這個問題的答案如何,至少能夠使用 JSX 語法的前端框架,都應該能夠使用 CSS-in-JS 的技術方案。
近期筆者也在研究學習 SolidJS,希望能夠參與到 SolidJS 的生態建設當中,注意到 SolidJS 社區中暫時還沒有能夠對標 emotion/styled-components
的 CSS-in-JS 庫,雖然已經有能夠滿足大部分需求的 Solid Styled Components 了:
https://github.com/solidjs/solid-styled-components
但是隻是會用不是筆者的追求,筆者更希望能夠嘗試自己實現一個,於是就有了 MVP 版本:
learning-styled-components/index.tsx at master · wjq990112/learning-styled-components
不熟悉 SolidJS 的同學可以暫時將其當做 React 來閱讀,核心思路上文其實已經解析過了,本質上就是將組件上攜帶的樣式在運行時進行解析,給一個獨一無二的 className
,然後將其塞到 <style>
標籤中。
MVP 版本中只有兩個函數:
const createClassName = (rules: TemplateStringsArray) => {
return () => {
className++;
const style = document.createElement('style');
style.dataset.sc = '';
style.textContent = `.sc-${className}{${rules[0]}}`.trim();
document.head.appendChild(style);
return `sc-${className}`;
};
};
const createStyledComponent: StyledComponentFactories = (
tag: keyof JSX.IntrinsicElements | Component
) => {
return (rules: TemplateStringsArray) => {
const StyledComponent: ParentComponent = (props) => {
const className = createClassName(rules);
const [local, others] = splitProps(props, ['children']);
return (
<Dynamic component={tag} class={className()} {...others}>
{local.children}
</Dynamic>
);
};
return StyledComponent;
};
};
兩個函數的職責也非常簡單,一個用於創建 <style>
標籤並給樣式生成一個唯一的 className
,另一個用於創建經過樣式包裹的動態組件。
當然,MVP 版本要成爲 SolidJS 社區中能夠對標 emotion/styled-components
的 CSS-in-JS 庫,還有非常多工作要做,比如:
-
運行時解析不同方式傳入的 CSS 規則
-
緩存相同組件的
className
,相同組件複用樣式 -
避免多次插入
<style>
標籤,多個組件樣式複用同一個 -
&etc.
不過,至少開了個好頭,MVP 版本能夠跑通 styled-components
文檔中 Getting Started 部分的示例:
learning-styled-components - StackBlitz
MVP 版本的源代碼在 https://github.com/wjq990112/learning-styled-components 的 master 分支,如果後續時間和精力允許,會在其他分支上完善,或者單獨開一個 Repo,不管怎麼樣,先挖個坑。
Getting Started 地址:https://styled-components.com/docs/basics#getting-started
learning-styled-components - StackBlitz 地址:
最後
在研究 styled-components
的過程中,我發現社區中對 CSS-in-JS 的技術方案意見非常兩極分化,喜歡的同學非常喜歡,討厭的同學也非常討厭。
筆者本人也在思考,爲什麼 CSS-in-JS 會這麼流行,以至於 Material UI 也選擇了這樣的技術方案。
筆者認爲可能有以下幾個原因:
-
不需要 CSS 預處理器實現複雜的樣式組合
-
不需要冗長的 CSS 規則約束
className
-
構建產物中沒有 CSS 文件,不需要單獨引入組件樣式
但 CSS-in-JS 的技術方案弊端也比較明顯,畢竟需要在運行時解析樣式並動態插入到頁面中,而且 JS bundle 體積也會增大,加載過程會阻塞頁面渲染。
展望
目前社區內的樣式解決方案多以原子化 CSS 爲主,原子化的形式能夠有效減小樣式體積,在編譯階段去掉沒有使用到的樣式,結合上文中提到的 CSS-in-JS 的技術方案的弊端,或許將 CSS-in-JS 運行時解析樣式的部分,放到編譯階段進行處理,生成由原子化的樣式組成的 CSS 文件,會是一個值得嘗試的優化方向。
作者 | 王家祺 (熾翎)
編輯 | 橙子君
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/zOlvdksBjlgxVyidgnt-mA