天天看點

「快頁面」動态配置化頁面渲染器原理介紹

作者:閃念基因

引言

「快頁面」是知乎内部一個快速搭建背景管理頁面的平台,使用者僅用半小時即可将一個正常複雜度的背景頁面開發完成。

「快頁面」平台的基石是它的「渲染器」,一個能将 JSON 配置渲染成頁面的 React 元件。

這篇文章将會提供一種配置化渲染器實作思路。

不過在開始介紹原理之前,想先對這類工具的存在價值做一個簡單的分析評估,搞明白我們為什麼要做它。

核心目标 - 提升開發效率

一些質疑

一開始産生做「快頁面」這個平台的想法時,我也在懷疑這樣的東西真的能提高效率嗎? 它所帶來的學習成本難道不會實際上高過它帶來的收益嗎?

其實配置化頁面渲染是一個非常老舊的話題了,因為一般情況下,一個十幾人規模的前端團隊,隻要不斷接到大量高度相似的管理背景需求,内部都會催生出一個這樣的頁面配置化工具。

隻是如今社群内也并沒有誕生出一個已經被廣泛使用的類似工具,大多隻是作為各公司内部系統内部使用。

在「快頁面」平台内部上線一年後的今天,我能确定它真的能提高開發效率。

許多項目一期的背景需求都很簡單,一個表單用于建立和編輯,一個表格用來查詢,然後在表格上加個公開按鈕,這種需求使用快頁面開發,平均每個頁面用半小時,最多一小時就完成上線了。

不過同時無法忽視的一點是,為了抵消它所帶來的學習成本,必然需要做很多文檔,智能編輯器,版本管理等輔助性工作。 這将經曆一個比做出渲染器和專屬元件更為漫長和曲折的過程。

效率提升關鍵點

其實這類工具提升效率的關鍵點各不相同,「快頁面」則是通過以下三點提高效率:

  1. 限制需求範圍,約定優于配置
  2. 省去建構部署環節,快速上線
  3. 非前端參與前端頁面開發成為可能

限制需求範圍,約定優于配置

把頁面從用代碼表達改為用配置表達,相當于建立了一種 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,它有兩個功能:

  1. 标記被包裝的元件是一個「接力元件」
  2. 提供上面提到的 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