引言
「快頁面」是知乎内部一個快速搭建背景管理頁面的平台,使用者僅用半小時即可将一個正常複雜度的背景頁面開發完成。
「快頁面」平台的基石是它的「渲染器」,一個能将 JSON 配置渲染成頁面的 React 元件。
這篇文章将會提供一種配置化渲染器實作思路。
不過在開始介紹原理之前,想先對這類工具的存在價值做一個簡單的分析評估,搞明白我們為什麼要做它。
核心目标 - 提升開發效率
一些質疑
一開始産生做「快頁面」這個平台的想法時,我也在懷疑這樣的東西真的能提高效率嗎? 它所帶來的學習成本難道不會實際上高過它帶來的收益嗎?
其實配置化頁面渲染是一個非常老舊的話題了,因為一般情況下,一個十幾人規模的前端團隊,隻要不斷接到大量高度相似的管理背景需求,内部都會催生出一個這樣的頁面配置化工具。
隻是如今社群内也并沒有誕生出一個已經被廣泛使用的類似工具,大多隻是作為各公司内部系統内部使用。
在「快頁面」平台内部上線一年後的今天,我能确定它真的能提高開發效率。
許多項目一期的背景需求都很簡單,一個表單用于建立和編輯,一個表格用來查詢,然後在表格上加個公開按鈕,這種需求使用快頁面開發,平均每個頁面用半小時,最多一小時就完成上線了。
不過同時無法忽視的一點是,為了抵消它所帶來的學習成本,必然需要做很多文檔,智能編輯器,版本管理等輔助性工作。 這将經曆一個比做出渲染器和專屬元件更為漫長和曲折的過程。
效率提升關鍵點
其實這類工具提升效率的關鍵點各不相同,「快頁面」則是通過以下三點提高效率:
- 限制需求範圍,約定優于配置
- 省去建構部署環節,快速上線
- 非前端參與前端頁面開發成為可能
限制需求範圍,約定優于配置
把頁面從用代碼表達改為用配置表達,相當于建立了一種 DSL,省去了 import 語句,對效率的提升有限。
提升效率的關鍵是分析高頻業務需求,簡化成固定流程,限制需求範圍,要放棄支援過于靈活的需求。
比如通用的表單需求,我們把它拆解成以下幾個部分:
「請求資料」→「設定初始值」→「指定 POST 位址」→「使用者與表單互動」→「校驗」→「送出」→「成功後跳轉」
其他細枝末節的比如「送出按鈕放哪」「送出按鈕文案」等低頻需求不考慮。
一些難以用配置表達的需求,比如「拿到請求資料後先處理下對象結構」「送出的時候發兩個接口」「送出的時候删除一些字段」等等,它們其實是一種回調函數,變化多端,無窮無盡,除非是高頻需求,否則盡量放棄支援。
我們抽象了高頻需求中的公共部分,用一目了然的配置表達,放棄了靈活性,得到了效率的提升。 這就是「約定優于配置」。
省去建構部署環節,快速上線
我們的配置是以 JSON 的形式存在的,差別于 js 代碼,它的好處在于簡短,可通信,可存儲。
既然一份 JSON 對應一個頁面,如果把它存到資料庫中,用接口讀取和修改,再做一個線上編輯器,應該就能脫離項目的建構,部署流程,做到開發完成後立刻上線了。
「快頁面」省去了建構和部署流程,實際上,在本項目中,也就是省去了原本每次代碼合并後所需要的 10 分鐘以上的等待時間,間接省去了 git clone 代碼,安裝依賴,啟動項目等等開發前的必要工作。
非前端參與前端頁面開發成為可能
有了線上智能編輯器和文檔,後端也能照着其他頁面的配置樣例,快速開發一個正常複雜度的前端頁面了。
如果這個線上智能編輯器更強大一些,擺脫了對編輯 JSON 的依賴,轉為可視化互動,它就能成為一個草圖編輯器,進而使得更多的人參與到前端頁面的開發過程中去。
當後端能借助線上智能編輯器獨立完成前端頁面開發時,這其中的溝通聯調成本也就大大降低了。
配置樣例
這是一個簡化的表格查詢需求配置樣例
{
"component": "Layout",
"title": "頁面标題",
"children": {
"component": "AutoTable",
"source": "/projects/{{match.params.id}}/resources",
"columns": [
{
"header": "ID",
"accessor": "id"
},
{
"header": "名稱",
"accessor": "name",
"render": {
"component": "Enter",
"caption": "{{value}}",
"href": "https://example.xxx.com/resources/{{record.id}}"
}
},
{
"header": "字段拼接",
"render": "{{record.id}}: {{record.community_name}}"
},
{
"header": "類别",
"accessor": "type",
"render": {
"component": "MapBadge",
"map": {
"a": "類型 A",
"b": "類型 B",
"c": "類型 C"
}
}
},
{
"header": "更新時間",
"accessor": "updated_at",
"unit": "second"
}
]
}
}
如樣例所示,用 component 字段表示要使用的元件,元件嵌套元件形成一份能表達整個頁面内容的 JSON 配置。
頁面渲染結果
注意到配置中有些值含有「雙花括号」,如 {{record.id}}: {{record.community_name}}
這種格式表示它們是動态變化的,讓元件具備随狀态變化顯示不同 UI 的能力,支援表達式計算。 文章後面會詳細介紹這一功能。
這個樣例中的元件樹可簡化為下圖(僅顯示有 component 的部分)
元件樹
其中 Layout 影響頁面的标題,邊距;
AutoTable 是強大的表格元件,負責發請求,表格分頁等邏輯;
Enter 是一個連結按鈕;
MapBadge 常用于顯示各種狀态或類型,在 UI 上比普通文字更醒目一些。
這份 JSON 很精煉地表達了一個頁面的内容,Layout(頁面布局) AutoTable(表格),Enter 和 MapBadge(表格中的兩列,一列是連結,一列是類型),比起原先 JSX 的寫法,代碼量大大減少了。
渲染流程
我們可以把渲染流程粗略地分為「React 元件渲染」和「雙花括号表達式渲染」
React 元件渲染
配置單元
仔細觀察配置結構可以發現,嵌套的關鍵是 component,與 component 同級的那些字段将會作為元件的屬性傳入,即
{
"component": "Layout",
"width": "750px",
"title": "标題",
"children": "内容"
}
// 相當于 JSX
<Layout title="标題" width="750px">内容</Layout>
// 相當于 JS 代碼
React.createElement(Layout,{ width: '750px', title: '标題', children: '内容'})
我們把含有 component 的 Object 叫做一個「配置單元」,就像 React 元件可以自由作為其他元件的任意屬性傳入一樣,「配置單元」之間也可以作為對方的一個屬性形成嵌套。
那麼對每一個配置單元的基本操作就是,調用 React.createElement() 将其執行個體化為 React Element。
自底向上
當我們對一個有兩層嵌套的配置單元嘗試 React.createElement() 時便會發現,我們好像需要确定一個渲染順序。
這個順序就是自底向上。 以上面的 Layout - AutoTable 為例:
假設是自頂向下,那就是
React.createElement(
Layout,
{
title: '頁面标題',
children: {
component: 'AutoTable',
source: '',
columns: []
}
}
)
其實 Layout 就是個簡單 UI 元件,沒有任何複雜邏輯,會把外界傳給它的 children 原封不動地傳給 React 的 API,這時毫無疑問會報錯。
回過頭來看,其實自底向上的順序理是所當然的,因為 JSX 轉譯出來的 JS 代碼本來就是自底向上的,想想「函數執行棧」就明白了。
是以渲染順序是: 自底向上。
深度優先周遊
知道了渲染順序,知道了每一層都是在執行 React.createElement(),接下來寫一個深度優先周遊就行了。 代碼簡化如下:
function dfs(configUnit, path = []) {
for (const key of configUnit) {
dfs(configUnit[key], path.concat(key))
if (configUnit.component) {
collect(configUnit, path)
}
}
}
常見的遞歸周遊而已,通過 dfs 收集到一個遵循元件自底向上順序的數組,接下來對其中元素逐個執行 React.createElement() 并替換即可。
// config 是整個頁面的配置,paths 是深度優先周遊時收集到的配置單元路徑
paths.forEach(path => {
const configUnit = config.get(path)
const { component: name, ...props } = configUnit
const component = getComponentByName(name)
const element = React.createElement(component, props)
config.set(path, element)
})
其中 getComponentByName 是根據元件名找到元件的方法,也就是接下來要說的。
根據元件名找到元件類
先實作一個元件引用緩存管理器
// componentProvider
function ComponentProvider() {
this.cached = {}
this.add = (name, component) => {
Object.assign(this.cached, { [name]: component })
}
this.get = name => this.cached[name]
}
const componentProvider = new ComponentProvider()
export default componentProvider
接着注入所有元件
// injectComponents.js 檔案
import * as components from 'components'
function injectComponents(provider) {
for (const name in components) {
provider.add(name, components[name])
}
}
export default injectComponents
根據元件名取用元件
import provider from 'componentProvider'
provider.get('Layout')
都是非常簡單直白的邏輯
到這裡,一個基本的靜态配置渲染流程已經實作了,如果我們的頁面是像寫靜态 HTML 标簽一般沒有任何動态需求,這樣就足夠了。
但背景需求不會這麼簡單,實際使用後我們會發現,比起寫 JSX,這種 JSON 配置有一個緻命的缺陷,那就是資料在被傳給 UI 元件前,我們連對它進行一點點計算都做不到,也沒法寫回調函數。 是以就需要下面這第二部分「雙花括号表達式渲染」」。
雙花括号表達式渲染
表達式扮演什麼角色
首先要明白,「雙花括号表達式」在頁面配置中究竟扮演了一個什麼樣的角色,我們能在傳統寫 JSX 的過程中,找到與之對應的角色嗎?
在本項目中,「雙花括号表達式」滿足了
- 對資料的計算處理的需要
- 實作部分的回調函數的需要
對資料的計算處理
最常見的例子,往往頁面中表單請求的 HTTP 接口位址,需要受頁面目前路由的影響
比如我們要在
https://example.xxx.com/projects/:id
這個頁面中請求
https://api.xxx.com/projects/:id
這個接口位址
很明顯接口位址中的參數 id 是從頁面路由中得到的
那麼寫成「雙花括号表達式」就是
'https://api.xxx.com/projects/{{match.params.id}}'
這類計算邏輯很常見,非常重要, 而「雙花括号表達式」就可以滿足這類需求。
部分的回調函數
JSON 配置中隻能寫數字,字元串,布爾值這些簡單類型,不能寫函數。
那通過 eval 生成函數行不行呢? 在 JSON 中就以字元串的形式存在。
這個思路被我們放棄了,因為它過于複雜,過于靈活了。
我們依然是隻針對高頻需求做支援
不過這意味着我們需要做一些特殊元件,将原本需要傳入回調函數才能實作的邏輯變成僅需一小段 JSON,比如點選按鈕後彈框填寫表單,或要求使用者确認危險操作等等
表達式計算的實作
實作表達式計算靠 eval 生成一個立即執行函數就可以了,這裡需注意幾個關鍵點:
- 屏蔽全局變量
- eval 生成的函數變量命名空間與全局變量可能有交集
- 全局變量中可能有變量名并不符合辨別符命名規則
- 列印計算過程中的報錯
屏蔽全局變量
這裡的全局變量其實指的就是 window 對象上的屬性,由于我們利用了立即執行函數的閉包特性,是以它在執行過程中會受到 window 對象上屬性的影響,導緻奇怪的計算結果。
這種情況一旦發生,不容易發現原因,安全起見還是屏蔽掉的好。
屏蔽的方式就是循環枚舉出 window 上的屬性,然後執行。
let windowProp = undefined
eval 生成的函數變量命名空間與全局變量可能有交集
表達式的資料源中可能與全局變量有同名屬性,就不能和上面一樣賦為 undefined 了,舉例:
// 表達式中系統預先定義了一個 prompt 變量,它和 window.prompt 重名了
let prompt = _data.prompt
全局變量中可能有變量名并不符合辨別符命名規則
某些第三方庫可能會在 window 上注入它自定義的辨別變量,但卻沒有遵循變量命名規則,使用了諸如「減号 -」等特殊符号。
這種辨別符可能會讓屏蔽全局變量的語句報錯,是以記得過濾下。
列印計算過程中的報錯
表達式計算是非常有可能失敗的,比如下面這個報錯大家肯定見的太多了。
TypeError: Cannot read property 'someProp' of undefined
通過 try catch 捕獲到并列印出來,可以極大地幫助使用者調試。
計算表達式時的資料來源
我們的「雙括号表達式」要影響的是 UI,
而在 React 中,能夠即時影響 UI 的資料隻有三種來源,state,props 和 context。
state
state 是元件的一些内部屬性,比如表格的分頁,是由表格内部自行管理的。
props
props 是我們給元件傳入的屬性,其實就是「配置單元」裡寫死的。
context
借助一些狀态管理庫,如 redux + react redux,context 就變成了元件的 props。
這三種資料源隻有在元件的 render 方法中可以全部拿到,并且還能随資料的變化立即影響 UI。
自底向上的局限
仍是以上面展示的樣例為例,假設目前 JSON 配置中元件樹的結構有如下三層。
Layout
|-- AutoTable
|-- Enter ( href = https://example.xxx.com/resources/{{record.id}} )
// record 是表格任意一行的資料
表達的意思很簡單,頁面中有個表格,表格中有一列要放個連結入口。
按照自底向上的順序,應當是先執行 createElement(Enter) ,再執行 createElement(AutoTable)
可我們給 Enter 傳入的 href 屬性是一個「雙花括号表達式」,表達式中依賴的 record 是自身所處表格那一整行的資料,屬于 AutoTable 元件私有的變量。
我們原先的自底向上流程無視了私有關系,在嘗試計算表達式時發現缺少了一些私有變量。
這就是原先自底向上的局限,看來,想要支援表達式計算,渲染流程還需要再改進。
自底向上流程之間的接力
既然那些變量是私有的,那就應該在遵循私有關系的前提下進行自底向上的渲染。
怎麼遵循呢? 那就是在自底向上的過程中,忽略一些元件的子級配置,由該元件自己負責子級配置的自底向上渲染。
這樣一來,原本隻有一次的自底向上渲染,由于 AutoTable 元件的存在,這個流程被分割成了兩次,好像兩次接力一般。
我們把那些類似 AutoTable 這種負責接力的元件稱作「接力元件」。
仍是以上面的 Layout - AutoTable - Enter 為例
在這個流程中,由于有一個「接力元件」AutoTable 存在,需要兩次自底向上的渲染
第一次自底向上把 AutoTable 及其所有子級字段視為一整個配置單元,這樣 AutoTable 便成了最底部的那個「配置單元」。
第二次自底向上由 AutoTable 接力,對其所有子級字段進行自底向上的渲染。
以此類推。 即使有更多「接力元件」,流程都是一樣的。
本文最後會有圖檔形式的流程詳細介紹。
接力元件
哪些元件是接力元件
主要是那些需要提供私有變量給「雙花括号表達式」的元件,比如表格需要提供表格每一行的資料,表單需要提供表單目前值,等等其他有類似需求的元件。
渲染器怎樣知道目前元件是不是「接力元件」
白名單是個辦法,但這樣做的話,每新增一個「接力元件」,都需要更改白名單,渲染器群組件之間存在耦合。
是以更好的辦法是做個 HOC
我們把「周遊計算并替換雙花括号表達式」「自底向上調用 React.createElement」兩個公共邏輯合并成一個方法抽象出來,就叫它 autoRender 吧。
做一個 HOC,它有兩個功能:
- 标記被包裝的元件是一個「接力元件」
- 提供上面提到的 autoRender 方法給被包裝的元件,由被包裝元件使用 autoRender 渲染剩下的配置完成接力
這樣一來,做一個「接力元件」就變得很簡單,隻要拿這個 HOC 包裝一下,然後在被包裝的元件中随自己想法調用 HOC 提供的 autoRender 方法即可。
渲染器群組件之間實作了解耦。
形如閉包的表達式變量作用域
既然「接力元件」擁有一些私有變量,那麼符合直覺的作用域應該是:
父級不能讀取「接力元件」子級的變量,但「接力元件」可以使用父級的變量。
就像閉包的作用域一樣,目前函數可以使用外層函數的變量,外層函數卻不可以使用目前函數的變量。
這個的實作也不難, 一句話概括就是: 每個「接力元件」向它子級的所有「接力元件」注入資料。
所謂注入資料就是給子級的「接力元件」添加一個特定字段,比如 __injectedData
在本例中,就是要向 AutoTable 這個「接力元件」注入 __injectedData,内容是頁面的路由資訊等資料。
{
"component": "AutoTable",
"columns": []
}
(假定頁面路由中參數 id 為 20) 注入後變為
{
"component": "AutoTable",
"columns": [],
"__injectedData": {
"match": { "params": { "id": 20 } }
}
}
之後 AutoTable 使用 autoRender 方法時便會把這份被注入的資料和自身私有的資料合并,來渲染子級配置中的「雙花括号表達式」
流程圖解
上面純文字描述很不直覺,下面是一個圖檔形式的完整流程。
在這個例子中,共存在兩個「接力元件」: Page 和 AutoTable
Page 可以為表達式提供頁面路由資料,包括參數比對結果,即 match。
假設頁面路由中存在參數 id,值為 3,即 match.params.id = 3。
開始
啟動渲染
計算表達式,注入資料
接力元件被視為一個整體
createElement(AutoTable)
開始接力
(AutoRender 筆誤,是 AutoTable)
Text 元件的 children 屬性的值是一個表達式,表達式中使用了 record 和 match 兩個變量
record 是表格中每一行的資料,由 AutoTable 提供,假設 record.type = 'typeA'
match 是頁面路由參數比對結果,顯然 AutoTable 本身無法提供 match 資料
但之前 Page 已向 AutoTable 注入了 injectedData,其中含有 match 變量
是以 Text 元件的 children 屬性表達式可以計算出結果
計算表達式
createElement(Text)
AutoTable 已被執行個體化,隻剩 Layout
createElement(Layout),流程結束
總結
本篇文章介紹了知乎内部一個背景頁面搭建平台「快頁面」,主要内容是渲染器的實作原理。
在介紹原理之前,首先對這類工具的存在意義做了一些初步的分析;
随後以一份配置樣例為例,介紹了渲染器的實作原理,包括「React 元件渲染」和「雙花括号表達式渲染」兩部分。
每一個配置化工具應該都是深度結合了業務方向,項目基礎,團隊投入等實際情況得到的結果。
是以理論上,在業界,同類工具應該有很多,是以本文也隻是一種實作思路。
歡迎對這類工具感興趣的小夥伴在評論區交流。
作者:馬良良良君
出處:https://zhuanlan.zhihu.com/p/100708653