前端團隊難免需要維護一些內部系統,有些內部系統由於開始的架構設計不合理,隨著業務複雜度的增加,“壞味道”程式碼也越來越多,從而導致認知和溝通成本上升,甚至問題頻出,此時,重構就自然成了一個選擇。但重構不是一時興起,也不是一蹴而就的,需要仔細的分析和有序的實施,以實驗平臺為例,介紹一下智聯大前端的重構經驗。
實驗平臺是智聯招聘自主研發的A/B實驗生態,依託於資料平臺,並結合公司的業務和技術特點量身定製,提供了豐富的實驗能力,科學的實驗機制和完整的流程管理。
Web端是使用了基於 Vue 實現的 Ant Design Vue元件庫開發實現;API層是基於Node.js開發,預先處理、組合、封裝後端微服務所返回的原始資料,有效降低UI與後端介面的耦合,實現並行開發和介面變更。
現狀
UI排版和佈局的整體設計不統一,前端互動複雜,功能冗餘,“壞味道”程式碼不斷增多更是加大了開發與維護的難度;Api層沒有遵循主流 RESTful Web API 標準,只負責了後端介面的轉發,邏輯全放在Web層實現,沒有有效降低UI與Api層介面的耦合,加重了Web層的負擔;
基於以上原因,我們決定對實驗平臺系統進行重構,進一步提高其易用性、內聚性和可維護性。
分析
首先逐個頁面分析一下實驗平臺功能及使用情況,以方便對接下來重構工作有初步的瞭解:
概覽頁:主要展現自實驗平臺上線以來使用情況的統計資訊,為了更好展現統計內容,以及日後方便對資料結構的維護,我們決定資料不再由後端介面提供,改由自己的Api層計算;
實驗列表頁:列表頁主要用於展示使用者關注的實驗的關鍵資訊,所以儘可能的精簡展示欄位以及優化資訊主次排版;同時,提供快速跳轉入口(直達統計、直達除錯),優化使用者體驗;新增可搜尋實驗名和建立人,優化搜尋體驗;對於實驗狀態,有些狀態不再需要(如申請釋出、同意釋出、已釋出、歸檔),同時還需要相容舊的實驗狀態,為此我們對實驗狀態做了新的調整:
- 草稿:新建
- 除錯:除錯狀態
- 執行:執行、申請釋出、同意釋出
- 停止:放棄、停止、已釋出、歸檔
- 世界概覽頁:基本功能不變,按原則重構程式碼即可
變數頁:經過考量,變數沒有必要再執行釋放、或者恢復等操作,所以只需要展示正在“執行”實驗的變數列表;
設定頁:主要目的是展示和新增管理員,所以沒有必要顯示所有的使用者,所以可以簡化為刪除和新增管理員即可;
基本資訊頁:此頁面基本功能不變,優化頁面佈局排版,統一使用者體驗,編輯許可權由Api層統一控制;
統計分析頁:此頁面基本功能不變,為了便於維護,統計資料全部由Api層計算生成;另外,經分析大盤指標頁面實時功能可以去掉;優化頁面整體佈局排版及重構程式碼;
操作記錄頁:需要新增克隆實驗id的資訊,優化使用者體驗;
控制頁:此頁面基本功能不變,統一使用者體驗,編輯許可權由Api層統一控制,優化頁面佈局排版及重構程式碼;
總結頁:用於總結實驗結果,這個頁面經過分析,已經不再需要;
原則
至此,根據之前的分析,我們已經對實驗平臺的現狀有了初步的認識。接下來,總結一下形成一些有用的指導原則:
分層,Web層和Api層應各司其職:
- Web層只負責UI的互動和展示;
- API層遵循Restful Web API標準,採用強型別檢查的Typescript開發,負責所有的功能邏輯處理及許可權控制;
佈局,整體佈局保持設計一致:
- 佈局自然化,提升可維護性;
- 版塊規範化,保持設計統一;
- 各版塊的上下左右間距一致,版塊間對齊;
模組,保持職責單一,方便維護原則:
- 按照職責拆分模組,並相互解耦;
- 盡最大可能不維護狀態(尤其是全域性狀態),而是通過與其他模組互動實現;
- 邏輯去中央化,分散到各功能元件;
- 元件化,保持元件職責單一;
- 未複用的元件均置放於父容器元件的目錄之下;
開發規範,遵循智聯前端開發規範及自定義原則:
- 樣式規範化,降低更新成本;
- 統一輸入輸出規範;
- 禁止所有魔法數字,而是通過變數實現;
- 禁止所有內聯樣式,而是通過更加通用的Class實現;
- 盡最大可能不使用絕對定位和浮動,而是通過a-layout元件、標準文件流或Flex實現;
流程,採用漸進式重構方式:
- 漸進式重構方式,分階段進行重構,每一階段都不破壞現有功能,具備單獨釋出的能力;
階段
接下來,我們將重構週期劃分幾個不同的階段進行有序實施。
第一步:js遷移到ts
眾所周知,JS是一門動態語言,執行時動態處理型別,使用非常靈活,這就是動態語言的魅力所在,然而,靈活的語言有一個弊端就是沒有固定資料型別,缺少靜態型別檢查,這就導致多人開發時亂賦值的現象,這樣就很難在編譯階段排除更多的問題,因此,對於需要長期迭代維護以及眾多開發者參與的專案,選一門型別嚴格的語言、可以在編譯期發現錯誤是非常有必要的,而TypeScript採用強型別約束和靜態檢查以及智慧IDE的提示,可以有效的降低軟體腐化的速度,提升程式碼的可讀性可維護性。
所以,這次重構工作首先從js遷移到ts開始,為後續模型梳理奠定語言基礎。
ts僅限於API工程的node層,因為,前端使用Vue2對ts支援不太友好,所以還保持使用原有js。
第二步:梳理資料模型
這個步驟比較簡單,主要是梳理現有的API介面請求的輸入和輸出的資訊,對後續梳理資料實體打好基礎。
首先,整理出實驗平臺系統所有的頁面,如下所示:
- 設定
- 變數
- 世界
- 概覽
- 實驗列表
- 建立實驗
- 檢視基本資訊
- 編輯基本資訊
- 檢視操作記錄
- 檢視統計
- 控制
然後,分別對每個頁面涉及的Api介面進行進一步統計,例如,設定頁:獲取使用者列表、新增和刪除使用者,設定使用者角色等API介面。
其次,根據上一步整理的結果,對每個API介面請求輸入輸出資訊進行歸納整理,例如,開啟實驗基本資訊頁,找到瀏覽器的開發工具並切換到【NetWork】,滑鼠右擊請求介面找到【Copy as fetch】複製請求結果,如下圖所示:
如下展示了API介面請求輸入輸出資訊的程式碼結構:
【示例】
// [分組]: 獲取實驗分組資訊列表
fetch(
"https://example.com/api/exp/groups?trialId=538",
{
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin"
},
referrer: "https://example.com/exps/538",
referrerPolicy: "no-referrer-when-downgrade",
body: null,
method: "GET",
mode: "cors"
}
);
const response = {
code: 200,
data: {
groups: [
{
desp: "c_app_default_baselinev",
flow: 20,
groupId: 1368,
imageUrl: "",
type: "A,對照組",
vars: ["c_app_default_baselinev"]
},
{
desp: "c_App_flowControl_baseline",
flow: 20,
groupId: 1369,
imageUrl: "",
type: "B",
vars: ["c_app_flowControl_baseline"]
}
],
varName: ["gray_router_prapi_deliver"]
},
time: "2019-12-20 17:25:37",
message: "成功",
taskId: "5f4419ea73d8437e9b851a0915232ff4"
};
同樣的,我們按照以上流程依次對所有頁面的對應API介面請求的輸入輸出分別進行整理,最後,得到如下檔案列表:
接下來,分析每個介面的返回值,提取UI層互動會用到的欄位,從而定義基本的資料模型,資料模型要能夠直觀的展示資料的基本組成結構,如下所示:
【示例】
// 分組
const group = {
id: 123,
name: 'A', // 組別
description: 'CAPP變數', // 描述
variableValue: 'c_app_baselinev', // 變數值
preview: 'http://abc.jpg', // 預覽圖
bandwidth: 10 // 流量
}
第三步:梳理資料實體
前面我們梳理了資料模型,接下來,我們需要根據資料模型進一步梳理模型對應的資料實體,如下所示:
【示例】
class ExperimentGroup {
id: number
name: string
description: string
variableValue: string
previewImage: string
bandwidth: number
constructor () {
this.id = null
this.name = null
this.description = null
this.variableValue = null
this.previewImage = null
this.bandwidth = 0
}
}
把需要經過計算處理才能得到欄位定義在Object.defineProperties中,如下所示:
【示例】
class ExperimentCompositeMetric extends ExperimentMetric {
unit: string
constructor () {
super()
this.unit = ''
Object.defineProperties(this, {
displayName: {
get: () => (this.unit ? `${this.title}(${this.unit})` : this.title),
enumerable: true
}
})
}
}
與此同時,根據介面的輸出,定義了統一的Api介面輸出規範的資料實體,如下所示:
【示例】
class Result {
error: boolean|string|Error
data: any
requestId: string|null
constructor () {
this.error = false
this.data = null
this.requestId = null
}
}
第四步:互動型別介面重新梳理
接下來,根據UI層的互動功能,定義介面的輸入規範(包含方法、路徑,及引數等),一邊就把介面關聯的實體豐富起來,最後,在實際開發中根據需要不斷調整優化實體,如下所示:
【示例】
// 獲取指標圖表資料
get('/api/v2/experiment/stats/trending', {
params: {
id: 123,
type: "key",
period: "day", // or hour
from: "2019-12-17",
to: "2019-12-18"
}
})
第五步:UI方面的重新梳理
這一步驟,主要是UI方面的重新梳理(頁面、佈局、元件等等),定義了頁面的基本展示形式及輸入輸出規範:
【示例】
<template>
<editable-section @click="onEdit">
<h2 slot="header">分組</h2>
<table>
<thead>
<tr>
<th>組別</th>
<th>組名</th>
<th>變數 {{ experiment.variable.name }} 的值</th>
<th>預覽圖</th>
</tr>
</thead>
<tbody>
<tr v-for="group in experiment.groups">
<td rowspan="group.name | toRowspan">group.name | toType</td>
<th>group.name</th>
<td>
<p>
{{ group.variableValue }}
<small>{{ group.description }}</small>
</p>
</td>
<td>
<img src="group.previewImage"/>
</td>
</tr>
</tbody>
</table>
</editable-section>
</template>
<script>
import BaseSection from 'shared/components/EditableSection'
export default {
props: {
experiment: Object
},
filters: {
toType (value) {
return value === 'A' ? '對照組' : '實驗組'
},
toRowspan (value) {
return value === 'A' ? 1 : this.experiment.groups.length - 1
}
},
methods: {
onEdit () {
alert('暫未實現,請使用V1。')
}
}
}
</script>
第六步:定義檔案佈局
在開始重構前,我們定義Web工程的基本檔案目錄,同時,根據以往經驗,我們還提取了常用公共的變數及方法,如下圖所示:
shared檔案存用於放公共檔案資源,其中,components檔案存放公共元件資源,api.js檔案存放所有的API URL資源,styles檔案存放公共css檔案資源,images檔案存放公共圖片,fonts存放公共字型等。
在variables.postcss檔案裡,定義了一些常用css變數,如下所示:
:root {
--font-family--code: cascadia, pingfang sc, microsoft yahei ui light, 微軟雅黑, arial, sans-serif;
--font-size--super: 70px;
--font-size--xl: 24px; /* 超大字號 */
--font-size--lg: 18px; /* 大字號 */
--font-size: 14px; /* 常規字號 */
--font-size--sm: 12px; /* 小字號 */
--space: 16px; /* 常規間距,適用於padding及margin */
--space--sm: 12px; /* 小間距 */
--space--xs: 8px; /* 超小間距 */
--color--white: #fff;
--color--black: #000;
--color--subtle: rgba(0, 0, 0, 0.45); /* 非顯著顏色 */
--color--message: #93a1a1;
--color--info: #859900;
--color--warning: #b58900;
--color--trace: #657b83;
--color--error: #dc322f;
--color--normal: #268bd2;
--color--lightgrey: #f0f2f5;
--color--active: #1890ff;
}
在global.postcss檔案裡,對第三方庫的樣式覆蓋及全域性樣式做了定義,如下所示
@import './variables.postcss';
@font-face {
font-family: 'Cascadia';
src: url('../fonts/cascadia.ttf');
}
// 全域性樣式
html,
body {
min-width: 1200px;
}
.text--description {
color: var(--color--subtle);
}
.text--mono {
font-family: var(--font-family)
}
// 第三方樣式覆蓋
.ant-modal-body {
max-height: calc(100vh - 240px);
overflow: auto;
}
.ant-table-body td {
vertical-align: top;
}
.ant-table-thead > tr > th {
background: #fafafa !important;
}
.ant-table-small > .ant-table-content > .ant-table-body {
margin: 0;
}
.ant-table {
& .ant-empty {
& .ant-empty-description {
display: none;
}
&::after {
content: '目前啥也沒有';
display: block;
}
}
}
而template.js檔案,用於存放獲取html模版的方法,如下所示:
import favicon from 'shared/images/favicon.png'
function generate ({
ctx, title, ...pageContexts
}) {
const prepareDataString = Object.entries(pageContexts)
.map(([key, value]) => `var ${key} = ${typeof value === 'object' ? JSON.stringify(value) : value}\n`)
.join('')
const template = `<!DOCTYPE html>
<html>
<head>
//自定義title
<title>${title ? `${title} | ` : ''}智聯實驗平臺</title>
<link rel='shortcut icon' href='${favicon}' />
// 資原始檔佔位
${ctx.template.placeholders.head.style}
${ctx.template.placeholders.head.link}
${ctx.template.placeholders.head.script}
<script>
//傳入自定義全域性頁面資料
${prepareDataString}
</script>
</head>
<body>
// 資原始檔佔位
${ctx.template.placeholders.body.root}
${ctx.template.placeholders.body.script}
</body>
</html>`
return template
}
export default {
generate
}
同樣的,我們也定義了Api工程的檔案目錄,如下圖所示:
shared用於存放公共檔案資源,utils存放一些公共的方法,models檔案裡的檔案就是我們前面所整理的資料模型。
第七步:漸進式開發
接下來,我們就可以正式進入下一階段,進行漸進式重構了。
根據專案的難易程度及功能的依賴關聯度,對所有頁面定義優先順序,如下所示:
- 框架
- 設定
- 變數
- 世界
- 概覽
- 實驗列表
- 檢視操作記錄
- 檢視統計
- 檢視基本資訊
- 控制
- 建立實驗
- 編輯基本資訊
從低到高依次進行重構,每一階段都不破壞現有功能,具備單獨釋出的能力,定義現版本為v1,重構版本v2,在url上進行區分,例如v2/exps。node層版本v1是js的,然後,逐步替換成用ts寫的版本v2(因為ts向下相容js,所以工程中可以同時存在);
隨著,重構工作的有序進行,我們會發現重構變的越來越得心應手,需要注意的依然是要控制重構範圍,完成一個功能測試之後,再開始下一個功能。
第八步:抽離公共元件
不難發現,這一步驟,其實和上一步驟同時進行的,在重構過程中,為了保證程式碼的簡潔、統一、易維護性,我們不斷的根據使用場景和功能抽離元件,來保障了程式碼和介面的統一,哪些場景可以抽離元件呢?
- 使用超過3次的重複程式碼;
- 使用場景類似;
- 邏輯比較接近,總是一起更新的程式碼;
並遵循以下原則:
- 保持元件職責單一,高內聚低耦合;
- 保持引數的配置簡單、靈活;
- 保持顆粒度合適,程式碼行數適中;
在此基礎上,我們抽離了佈局,編輯,頭像、選單元件,實驗狀態等常用元件,大大的減輕了繁瑣重複的工作量,如下:
BaseLayout:主框架佈局容器,其左側導航欄、右側上可巢狀 Header、右側下可巢狀section容器。
Header:頂部佈局,自帶預設樣式,左側標題Title,右側操作按鈕等。
BaseSection:只讀佈局容器,其左上可設定標題、下巢狀內容區域。
EditableSection:可編輯佈局容器,其左上可設定標題、右上設定操作按鈕、下巢狀內容區域。
第九步:統一整體佈局、互動體驗及提示資訊等
隨著重構工作的進行,我們需要對UI的一些細節做進一步的優化,形成一套統一設計體系:
整體佈局,導航及各版塊佔比排查/調整,例如:
- 間距
各版塊的上下左右間距一致,版塊間對齊;
版塊內部邊距(padding)統一;
相同元素的間距達到統一; - 佈局
同一元件的位置(比如居左或居右)統一;
採用左側導航選單、右側內容模式佈局,使用BaseLayout元件;
頁面右側頂部使用Header元件;
只讀內容模組使用BaseSection元件,帶編輯則使用EditableSection元件等; - 表格
表格資料一律使用緊湊模式;
表格首行鎖定、部分表格首列鎖定;
對於一次性返回所有資料的表格原則上不使用分頁;
對於必須有分頁的表格,分頁區域應顯示在可見區域內;
表格內容主要欄位自動列寬,次要內容列寬設定最小寬度;
- 間距
互動體驗,例如;
- 表單編輯使用EditableSection元件,點選右上角“編輯”按鈕彈出modal框進行編輯;
- “操作按鈕”在Header的右側;
- 刪除操作彈出提示框PopConfirm元件;
- 吐司提示框使用Tooltip元件;
- 統一message 提示,標題,點選詳情展示錯誤的具體資訊;
規範字型,同一級別同一場景的字號,顏色、字型樣式統一,例如:
- 頂部 header的標題的font-size: 24px;
- section的標題font-size: 20px;
- 統計數字、英文變數等內容設定寬字型text--mono;
- 描述等輔助文字內容設定樣式text–description;
message提示資訊統一
- 使用方式;
- 互動形式;
- 展示資訊;
第十步:漸進上線v2頁面
為了防止新上線的v2頁面,在某些情況出現錯誤無法正常使用時,我們在頁面右上角新增了“切換版本“功能,可以快速的切換到v1頁面,不影響使用者正常使用。且在一段時間自測和使用者的反饋情況下,逐漸優化v2功能。
第十一步:上線全部v2版本
所有的v2頁面和Api都全部上線後,通過一週自測和使用者使用反饋,不再有使用功能性問題,即可去掉右上角的版本“切換按鈕”。
第十二步:刪除v1版本
執行觀察一個月後,v2版本線上執行穩定,且使用無明顯功能問題後,對v1頁面和程式碼進行下線操作。自此,重構的大部分工作已經完成。
第十三步:抽離公共元件庫
現在重構工作已經接近尾聲,實驗平臺較之前從架構設計、編碼規範、佈局及使用者體驗等都有了脫胎換骨的變化。
在宣告重構完成之前,為了進一步統一使用者體驗、提高系統迭代效率,我們抽離了一個公共元件庫,公共元件是與業務無關的,可以服用在其他場景。
由此,AntNest 元件庫雛形就產生了,它基於Ant Design Vue元件庫實現,其中涵蓋了佈局、容器、卡片、提示、編輯、資料展示等功能的公共元件。並很快釋出v1.0.0版本,首先在實驗平臺和Ada工作臺、魔方管理系統進行應用替換,不斷的進行迭代優化,觀察一段時間後執行穩定。並在智聯的其他內部系統中逐漸推廣使用,目前已成功應用到多個系統,如鯤鵬、伏羲、運營平臺、效能監控平臺等十幾個專案中,未來其他內部系統都會替換成AntNest元件,形成一套統一管理系統體系,實現真正的統一使用者體驗。
第十四步:提供工程模版
為了進一步簡化開發流程,方便大家快速建立專案,為此,我們還提供了基於Web工程和Api工程的輕量級模版。
其中,Web工程模版中基於AntNest元件庫,除了基本的Web框架結構外,我們還在其中內建管理系統常用的概覽頁(普通概覽和瀑布流概覽)、列表頁、詳情頁(只讀和可編輯)、佈局頁及錯誤頁等頁面,滿足使用者基本需求。
Api工程模版是基於Node.js,並採用強型別檢查的Typescript開發,預製了和實驗平臺一致的的檔案目錄結構,方便大家快速進行開發。
總結
回顧一下整個重構過程,會發現我們做的第一件事情並不是編碼,而是對現狀進行深入的剖析。在這個過程中,求同存異,一些模式會自然而然地呈現出來,它們都是重構的“素材”。
在真正進行編碼時,我們採取了漸進式的策略,將整個過程分解成多個步驟。爭取做到每一個步驟完成後,整個模組都能達到釋出標準。這就意味著需要把每一步所涉及的改動都限定到一個可控的範圍內,並且每個步驟都需要包含完整的測試。
以上就是本次實驗平臺重構的歷程及經驗,希望給日後開發新專案或重構老專案提供幫助及借鑑。