利用 Material-UI 統一 UI framework —— 均一前端工程師宜陞技術分享
Why we do this: 統一「自由奔放」的 UI 框架
1 個網站套用 5 個 UI 框架
開發前端 APP 的前置作業,不外乎是根據設計師送來的設計圖,從 UI 框架中選擇適用的元件(component),但均一團隊從「選擇 UI 框架」這件事開始,就是道複雜的多選題。
在還是好傻好天真的 ES5 時代,均一的網頁大多是套用 Bootstrap,當我們開始 migrate 到 ES6 + React,由於適用於 React 的 UI 框架仍未發展成熟,為了有充足的元件庫可以使用,均一先後引入了 Material-UI(註一)及 React-Bootstrap。
除了前面提到的歷史因素,造成均一 UI 框架混亂的關鍵因素還有維護套件註定要面對的「套件升級」。
當工程師發現現有的 UI 框架無法提供所需的元件,通常會考慮以下幾種解法:
- 自己刻出需要的元件
- 升級套件,擴充現有套件的元件庫
- 引入一個符合需求的新套件
如果是功能陽春的元件,通常會選擇「自己的元件自己刻」,但如果元件的功能複雜又遇上專案死線在即,往往會考慮引入新套件以加速開發,至於「套件升級」則因為牽一髮動全身的特性,總是淪為最不得已的選擇。
隨著新的 UI 框架持續被引入,舊框架的維護被長時間擱置,當我們鐵了心要規範框架的使用時,早已在均一的 codebase 中引入 5 大包 UI 框架 —— 引入了 Bootstrap、Semantic-UI、Material-UI、React-Bootstrap、Ant-Design。
其中較常被使用的 Material-UI、React-Bootstrap 都還停留在最初被引入時的版本號 version 0.x.x。
而過去過於「自由奔放」的管理原則,也產生了以下的技術債:
- coding style 混亂
- UI 框架版本老舊
- 同一個頁面套用兩種以上框架,導致 UI 凌亂
為了償還前面提到的種種技術債,我們開始訂定相關的還債計畫。
註一:Material-UI 是當前流行的 UI 框架之一,它用來提供網頁中的元件(如:Button、Input)。
How we do this 1: 對 UI 框架專一
限縮框架數量
要確保 UI 框架不再多元發展,最直接的方式就是限縮框架的數量,因此目標之一就是選定「一個」適合長期使用的框架,並持續維護、更新。
當選定的框架無法提供工程師所需的元件,仍可以選擇引入較輕量的套件加速開發,確保工程團隊能靈活的運用第三方套件——但是,禁止再引入一整包框架!
選擇 Material-UI
在選擇框架前,我們調查了當前熱門的 UI 框架,考量到 Material-UI 的元件庫及 styling 解決方案相對完備,瀏覽器支援度符合團隊需求,且均一也已經有不少頁面是以 Material-UI 進行開發,因此,統一改成 Material-UI 的成本較小、易用性高,最後雀屏中選,成為均一主要的 UI 框架。
筆者在前一段提到均一使用的 Material-UI 還有個陳年的老問題:版本過舊,當我們開始研究 Material-UI 的發展近況時,才赫然發現 Material-UI 早已從 material-ui
轉移到 @material-ui/core
。因此在採用 Material-UI 的第一個階段性任務就是:升級 Material-UI (from material-ui@0.19.0 to @material-ui/core
@4.9.1),並在升級過程中改善 styling 相關技術。
How we do this 2: 選擇合適的 style API
升級 Material-UI 的過程,除了更新元件的語法,也花了不少心力思考怎麼善用 Material-UI 豐富的 styling 解決方案。最先遇到的問題就是如何取捨 style API(註二)。
使用 Material-UI 的 styled-components API 取代 styled-components library(註三)
在考慮引進 Material-UI 的 style API 之前,均一團隊慣用的解決方案是 styled-components library (以下簡稱 styled-components)。
使用舊版 Material-UI 時,因為 styled-components 的 CSS 權重不足,無法蓋過 Material-UI 元件的 inline style,所以我們仍是以 inline style 來實作 Material-UI 的元件客製化。
新版的 Material-UI,styled-components CSS 權重不足的問題已被解決,除此之外,Material-UI 也受到 styled-components 的啟發,進一步提供了 styled-components API (以下簡稱 styled API)。
由於 styled API 幾乎可以取代 styled-components,經過以下考量,均一團隊決定使用 styled API 取代 styled-components:
- CSS 權重:使用 styled API 不需要額外設定 CSS 的權重
- Theming:使用 styled API 可以直接取得 Material-UI 的 theme object,不需要混用 styled-components 的 theming 系統(註四)
- Coding style:後面會提到我們也開放使用另一種 Material-UI 的 style API ——hook API。由於 styled API、hook API 的 CSS 皆是以 JSS 撰寫 (styled-components 的 CSS 則是用 String templates),選擇 styled API 能讓 CSS 的 coding style 更統一。
註二:style API 是 Material-UI 提供的 styling 解決方案之一,style API 可用來撰寫元件的 CSS。Material-UI 主要提供了 3 種 style API:hook API、styled components API、higher-order component API。
註三:styled-components library 是一個 JavaScript 套件,它用來撰寫元件的 CSS。Material-UI 受到 styled-components library 啟發而發展出 styled-components API。
註四:theme 可以用來更換網站的主題設計,如 styled-components library 與 Material-UI 皆提供所屬的 theme 系統(ThemeProvider
)。可透過調整 theme 的設定,統一更換網站的主題(如:顏色、形狀、陰影)。
使用 classes API 客製化內層元件,並捨棄 global class
當遇上 Material-UI 元件含有內層元件時 (如:<Button>
其實內含了 MuiButton-root
與 MuiButtom-label
兩層元件),Material-UI 針對內層元件的客製化提供了兩種方法:classes API 和 global class。
考量到 styled API 可以直接鎖定 global class,不像使用 classes API 還需要自訂義 classes,我們一開始選擇使用 global class 來客製化內層元件:
// 鎖定 global class .MuiButton-label 來客製化 Button 的內層元件 label
const StyledButton = styled(Button)(
({ theme }) => ({
backgroundColor: 'white',
'& .MuiButton-label': { // 鎖定 global class
padding: [[theme.spacing(3)]],
},
}),
);
天真地用了一陣子後,才發現鎖定 global class 其實有個潛在問題:global class 是會變動的。當同一個元件樹中有巢狀的 theme 時,內層 theme 的 global class 會被加上 index,如:MuiTypography-root
會變為 MuiTypography-root-67
。
假如 styled API 鎖定了 MuiTypography-root
,可能會因為巢狀 theme 產生的 index 導致 CSS 選擇器失效。
而此問題的解法就是:classes API。由於 classes API 會產生獨立的 class ,不會因為巢狀 theme 導致失效,為了避免潛在問題,我們便捨棄了 global class,並統一使用 classes API:
// classes API:
const useButtonStyles = makeStyles(theme => ({
root: {
margin: 3,
},
myLabel: {
padding: [[theme.spacing(3)]],
},
}), { name: 'useButtonStyles' });
const StyledButton = ({ ...props }) => {
const classes = useButtonStyles();
return (<Button
classes={{
root: classes.root,
label: classes.myLabel, // 改用 classes API 來客製化 Button 的內層元件 label
}}
{...props}
/>);
};
使用 hook API「拆分 style sheet」與「客製化多層的子元件」
隨著決定以 classes API 來實作內層元件客製化,團隊內也開始嘗試使用另一種 style API:hook API (makeStyles
) 來操作 classes API。嘗試的過程中發現在某些情境下 hook API 能更有彈性的操作 style sheet。
例如在拆分 style sheet 的情境中,hook API 可以輕易的拆分 style sheet 並注入不同的元件:
const usePopupBtnStyles = makeStyles(theme => ({
root: { // 拆分出 root
height: 70,
},
deleteContained: { // 拆分出 deleteContained
color: theme.palette.primary.contrastText,
},
}), { name: 'usePopupBtn' });
const PopupBtnDelete = ({ ...props }) => {
const classes = usePopupBtnStyles();
return (<Button
classes={{
root: classes.root, // 注入 root
contained: classes.deleteContained, // 注入 deleteContained
}}
{...props}
/>);
};
const PopupBtnCancel = ({ ...props }) => {
const classes = usePopupBtnStyles();
return <Button classes={{ root: classes.root }} {...props} />; // 只注入 root
};
在另一種情境,當元件內有多層的子元件時,hook API 也能產生多份 style sheet 分別注入各個子元件:
// hook API
const useStepStyles = makeStyles(theme => ({ // 產生 4 份 style sheet
labelContainer: {
textAlign: 'center',
},
label: {
fontSize: '24px',
},
stepLabelRoot: {
background: theme.palette.primary.main,
},
stepRoot: {
flex: 3,
},
}), { name: 'StyledStepLabel' });
const StyledStepLabel = ({}) => {
const classes = useStepStyles();
return (<Step
classes={{ root: classes.stepRoot }} // 其中 1 份,注入 Step 的 style
>
<StepLabel
classes={{ // 其中 3 份,注入 StepLabel 的 styles
root: classes.stepLabelRoot,
labelContainer: classes.labelContainer,
label: classes.label,
}}
>
stepLabel
</StepLabel>
</Step>);
};
在 Material-UI 提供的 3 種 style API 之中,均一團隊主要仍使用 styled API,同時也持續嘗試 hook API 該如何和 styled API 配搭運用。
How we do this 3: 善用 theming,style 共用最大化
移除 styled-components 的 ThemeProvider
過去我們一直沒有明確規範 theming 的用法及用途,在各個專案中也會混用 Material-UI 及 styled-components 的 ThemeProvider
。
延續前面提到的,均一團隊將逐步淘汰 styled-components,所以在正式啟用 theming 前做的第一件事就是移除 styled-components 的 ThemeProvider
並統一使用 Material-UI 的 ThemeProvider
。
善用 Material-UI 的 ThemeProvider
在統整各 package 的 theming 時,我們嘗試用以下原則進行重構:
- 適用均一全站的 theme 統一放在
commonMuiTheme
- 適用各個 package 的 theme 會寫在各自的
customTheme
- 不適合套用整個 package 的元件客製化,則使用 style API
- 同一個元件樹建議只有一個
ThemeProvider
,避免巢狀 theme
重構的第一步,我們便根據設計師開出來的元件規範,寫出適用全站的 commonMuiTheme
:
// commonMuiTheme.js
const commonMuiTheme = {
palette: {
text: {
primary: 'rgba(0, 0, 0, 0.6)',
},
},
};
第二步,則是在各 package 的 entry point 撰寫 customTheme
,並透過 Material-UI 提供的 createMuiTheme 來 merge commonMuiTheme
和 customTheme
:
// entry point: index.js
const customTheme = {
palette: {
primary: courseMenuPalette.primary,
},
};
/*
* createMuiTheme 能讓 customTheme 覆寫
* commonMuiTheme 中相同的 key
*/
const muiTheme = createMuiTheme(commonMuiTheme, customTheme);
const show = () => (
<ThemeProvider theme={muiTheme}>
<Grouping/>
</ThemeProvider>
);
最後,如果元件的客製化不適合放在 commonMuiTheme
或 customTheme
,則會使用 hook 和 styled API 來實作。
How we fix problems: multiple ThemeProvider cause class name conflict
除了 migration to Material-UI,均一團隊極力想完成的另一件大事就是 migration to React。
為了同時兼顧專案開發與均一團隊的技術提升,我們會在開發項目中安排 React migration,常見的作法是將頁面中的局部功能替換成 React,以逐步達成整個頁面的 migration 。
這個作法的結果,以均一的課程主題頁為例,課程主題頁只有「content」及「切換出版社 Button」是以 React + Material-UI 實作,在整個頁面中,「content」及「切換出版社 Button」是兩棵不同的元件樹,即兩棵樹有各自的 ThemeProvider
。
這種單一頁面多個 ThemeProvider
的情形,極有可能會發生 class name conflict,均一團隊在升級 Material-UI 的過程中就遇上了各種類型的 conflict。
以下範例可重現同一頁面中有兩個平行的 ThemeProvider
會產生的問題。範例中的 Theme1
照理來說要為紅色,但因為 class name conflict 導致顯示為綠色:
const Theme1 = createMuiTheme({
palette: {
primary: {
main: red[400]
}
}
});
const Theme2 = createMuiTheme({
palette: {
primary: {
main: green[600]
}
}
});
const ThemeConflict = ({}) => (
<div>
<ThemeProvider theme={Theme1}>
<Icon color="primary">add_circle</Icon> {// 預期為「紅色」,實際看到為「綠色」}
</ThemeProvider>
<ThemeProvider theme={Theme2}>
<Icon color="primary">add_circle</Icon> {// 預期為「綠色」,實際看到為「綠色」}
</ThemeProvider>
</div>
);
造成 conflict 的原因是,兩個平行的 ThemeProvider
各自產生了同名的 global class MuiIcon-colorPrimary
—— 不像巢狀 theme 還會為內層 theme 的 class 加上 index,由於 Theme2
的 MuiIcon-colorPrimary
較晚被引入,導致 Theme2
的 class (綠色) 覆蓋掉 Theme1
的 class (紅色)。
<!-- output HTML -->
<div>
<span class="material-icons MuiIcon-root MuiIcon-colorPrimary" aria-hidden="true">add_circle</span>
<span class="material-icons MuiIcon-root MuiIcon-colorPrimary" aria-hidden="true">add_circle</span>
</div>
// output CSS
.MuiIcon-colorPrimary {
color: #ef5350; // red[400]
}
.MuiIcon-colorPrimary {
color: #43a047; // green[600]
}
當同一頁面中的 ThemeProvider
數量越來越多時就越有可能發生 conflict,甚至隨著環境 (local or production)不同還會遇到不同類型的 conflict。
幸好 Material-UI 都有提供自訂「class 生成規則」的方法,我們也在踩雷試錯的過程中找到了各種 conflict 的解法 —— conflict 都可以透過 Material-UI 提供的 createGenerateClassName 及 style API 的 name 解決。
What's next?
工程團隊費時兩個月終於完成了 Material-UI 升級,儘管仍有許多未竟之事(如:將其他 UI 框架替換成 Material-UI),但對均一的前端開發環境來說已是一大福音,光是可使用的元件大幅增加,就為開發帶來極大的便利。
為了確保統一 UI 框架之路能繼續走下去,工程團隊仍持續努力在 migration to React 或進行重構時,從其他框架轉移到 Material-UI。同時,我們也正在規劃和設計團隊協作進行 Atomic Design(註五),並基於 Material-UI 規劃共用的元件庫。
如果對均一前端的未來發展感興趣,可以持續關注我們,一起體會均一前端技術的成長與突破。
註五:Atomic Design 是一種設計方法論,此方法論將元件區分為五個層級 (由小到大為:原子、分子、組織、模板、頁面) 以促進元件的一致性與可擴展性。
均一招募中!
如果你對前端軟體開發充滿熱忱,而且跟宜陞一樣期待用科技來影響教育,我們正在尋找資深前端工程師、軟體工程師,馬上到招募頁面查看職位詳情!