我來聊聊前端應用表現層抽象

歐雷發表於2020-09-02
本文首發於歐雷流。由於我會時不時對文章進行補充、修正和潤色,為了保證所看到的是最新版本,請閱讀原文

我們處於變化很快的時代,無論是商業還是科技。一家公司看上去商業很成功,也許前腳剛上市,後腳就因為什麼而退市,甚至倒閉;一項看似高大上的技術橫空出世,各類媒體爭先恐後地撰文介紹,熱度炒得老高,沒準沒多久就出現了競爭者、替代者。

在這樣的大環境下,傳統的「web 前端開發」演變成了「泛客戶端開發」,前端開發者從「配置工程師」被「逼」成了「軟體工程師」。開發變得更復雜了,要處理的問題更多了,從業難度不知提升了多少倍——前端早就不再簡單。

在眾多必須要處理的問題中的一個,就是表現層執行環境的相容問題,像跨瀏覽器和跨端、平臺、技術棧。注意,這裡說的是「表現層」而不是「檢視層」。

「表現層」與「檢視層」

「表現層」的英文是「presentation tier」或「presentation layer」,具體是哪個取決於是物理上還是邏輯上劃分;而「檢視層」的英文是「view」。「表現層」是「檢視層」的超集,根據前端應用的架構設計,它們既可以不等又可以相等。

表現層

「表現層」這個詞出自經典的三層架構(或多層架構),是其中一個分層。三層架構包括資料層、邏輯層和表現層,一般用在 C/S 架構中。

三層架構

為什麼會在這篇講前端開發的文章中提到它?這是因為,雖然在一些前端應用中用不到,尤其是快餐式應用,但在企業級複雜前端應用中就十分需要一個前端的「三層架構」。

檢視層

「檢視層」則來自表現層常用的「model-view-whatever」模式中的「view」,即「檢視」。至於說的時候在「檢視」後面加個「層」字合不合適,就不在這裡討論了,文中皆使用「檢視層」這個詞。

執行環境相容

跨瀏覽器

由於各瀏覽器廠商對標準實現的不一致以及瀏覽器的版本等原因,會導致特性支援不同、介面顯示 bug 等問題的出現。但慶幸的是,他們基本是按照標準來的,所以在開發時原始碼的語法幾乎沒什麼不同。

所謂的「跨瀏覽器」實際上就是利用瀏覽器額外的私有特性和技術或輔以 JS 對瀏覽器的 bug 進行「修正」與功能支援。

跨端、平臺、技術棧

現在,絕大部分的前端開發者是在做泛客戶端開發——開發 web 應用、客戶端應用和各類小程式。

在做 web 應用時需要考慮 PC 端和移動端是分開還是適配?技術選型是用 React、Vue?還是用 Web Components?或是用其他的?做客戶端應用、各類小程式時這些也會面臨技術選型的問題。

如果公司某個業務的功能覆蓋了上述所有場景,該如何去支撐?與跨瀏覽器不同的是,不同端、平臺、技術棧的原始碼語法不一樣,要滿足業務需求就得各開發一遍。然而,這顯然成本過高,並且風險也有些大。

那麼,要怎麼解決這個問題呢?從源頭出發。根本的源頭是業務場景,然後是產品設計,但這些都不是開發人員可掌控的,幾乎無法改變。能夠完全被開發人員所左右的基本只有開發階段的事情,那就從這個階段的源頭入手——原始碼編寫。

若是與業務相關的程式碼只需編寫一次就能執行在不同的端、平臺、技術棧上,那真是太棒了!這將會大大地降低成本並減少風險!

表現層的抽象

為了達到跨端、平臺、技術棧的目的,需要將表現層再劃分為抽象層、執行層和適配層。其中,抽象層是為了統一原始碼的編寫方式,可以是 DSL、配置等,它是一種協議或約定;執行層就是需要被「跨」的端、平臺、技術棧;適配層則是將抽象層的產物轉換為執行層正常執行所需要的形式。

表現層中可以被抽象的大概有檢視結構、元件外觀、元件行為等。

檢視結構

在 web 前端開發中,HTML 就是一種檢視結構的抽象,描述了介面中都有什麼,以及它們之間的層級關係。最終的顯示需要瀏覽器解析 HTML 後呼叫作業系統的 GUI 工具庫。

對於業務支撐來說,無論是 HTML 還是其他什麼拼湊介面的方式,相對來說比較低階(是「low level」而不是「low」),檢視單元的劃分粒度比較細,在開發介面時就會花費更多的時間。

我們需要一種能夠遮蔽一些不必關注的細節的檢視結構抽象,在這個抽象中,每個檢視單元都有著其在業務上的意義,而不是有沒有都可以的角色。具體做法請看下文。

元件外觀

大部分已存在的元件的視覺呈現是固定的,即某個元件的尺寸、形狀、顏色、字型等無法被定製。如果同樣的互動只是因為視覺上有所差異就要重新寫元件,或者在元件外部重新寫份樣式進行覆蓋,那未免也太痛苦了……

我們可以將那些希望能夠被定製的視覺呈現抽象成「主題」的一部分,這部分可以被叫做「皮膚」。在進行定製時,分為線下和線上兩種方式。

「線下」是指在應用部署前的開發階段進行處理。在前端構建工具豐富的現在,寫頁面樣式時已經不會去直接寫 CSS,而是像 Sass 這種可程式設計式的前處理器。這樣就可以抽取出一些控制視覺呈現的 Sass 變數,需要定製時通過在外部對變數賦值進行覆蓋,而不需要費勁重寫元件或樣式。

「線上」則是部署後根據執行時資料動態改變。在皮膚定製即時預覽和低程式碼平臺等場景,是基本沒機會去修改 Sass 變數並走一遍構建流程的,即使技術上能夠辦到。藉助 CSS 自定義屬性(CSS 變數)的力量可以較為方便地做到視覺呈現的執行時變更。

元件行為

元件除了外觀,其行為也應當是可以定製的。看到「行為」這個詞,第一反應就是跟使用者操作相關的事情,然而這裡還包括與元件內部結構相關的。

對於元件的外部來說,元件內部就是個黑盒子,其自身結構的組成部分有的可以被上文所說的檢視結構所控制,有的則無能為力:

搜尋元件

上圖是一個比較複雜的搜尋元件,雖然外觀和佈局看起來有所不同,但「它們」確實是同一個元件。外觀不同的解決方案上面已經大體說明,這類檢視結構無法控制的佈局問題,需要列舉場景後在元件內進行支援,然後作為「主題」的一部分存在。

跟使用者操作相關的行為有元件自身的互動規則及與業務邏輯的結合這兩類。

互動規則又有兩種:一種是像表單是在欄位值發生改變時就校驗還是在點選按鈕時校驗這樣;另一種是像欄位值是在輸入框的值改變(input 事件)時更新還是失焦(change 事件)時更新這樣,或是像下拉選單的彈出層是在懸停(hover 事件)時出現還是點選(click 事件)時出現這樣。

前者的解決方式與上面說的檢視結構無法控制的佈局問題差不多,後者則是需要元件支援事件對映,即外部可以指定元件某些互動的觸發事件。當然,這兩者同樣也可以作為「主題」的一部分。

我們在寫元件時有件事是需要極力避免卻往往難以避免——元件中耦合業務邏輯。元件決定的應該只是外貌與互動形態,裡面只有互動邏輯及控制展現的狀態,不應該牽扯到任何具體業務相關的邏輯。只要長得一樣、操作一樣,那麼就應該是同一個元件,具體業務相關的邏輯注入進去。

這段十分「個性化」的業務邏輯,說白了就是響應使用者操作的變化以及業務資料的變化去更改元件內部的狀態:

{
  // 元件事件
  events: {
    // 元件的一個點選事件
    'click-a': function() {},
    // 元件的另一個點選事件
    'click-b': function() {},
    // 元件的一個改變事件
    'change-c': function() {},
  },
  // 業務資料變化的回撥
  watch: function( contextValue ) {},
}

執行時會注入一個上下文給上述物件方法的 this,元件還可以新增工具方法給上下文。該上下文的內建屬性與方法有:

interface IDomainSpecificComponentContext {
  getState(key: string): any;
  setState(key: string, value: any): void;
  setState(stateMap: { [key: string]: any }): void;
}

檢視結構描述

上面說了我們需要一種比 HTML 之類的更進一步的檢視結構抽象,下面就來說說這部分的大體思路。

技術選型

在做檢視結構抽象時最常用到的技術就是 XML-based 或 XML-like 以及 JSON-based 的某種技術。XML-base 和 XML-like 的技術都是符合 XML 語法的,唯一的區別是前者要完全符合 XML 的標準規範,像 Angular 和 Vue 的模板就是後者;同樣的,JSON-based 的技術是完全符合 JSON 的標準規範的技術,像 JSON Schema。

自從 React 問世以來,其帶來的 XML-like 的 JSX 也會被用於檢視結構抽象,但基本僅限於編輯時(edit time)。一段 JSX 程式碼並不是純宣告式的,作為檢視結構描述來說可讀性較低,解析難度較高,並且通用性很低。

JSON-based 的技術對前端執行時最為友好,解析成本幾乎為零;相反的,其可讀性很低,JSON 結構是縱向增長的,指定區域內的表達力十分受限,無法很直觀地看出層級關係與檢視單元的屬性:

{
  "tag": "view",
  "attrs": {
    "widget": "form"
  },
  "children": [{
    "tag": "group",
    "attrs": {
      "title": "基本資訊",
      "widget": "fieldset"
    },
    "children": [{
      "tag": "field",
      "attrs": {
        "name": "name",
        "label": "姓名",
        "widget": "input"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "gender",
        "label": "性別",
        "widget": "radio"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "age",
        "label": "年齡",
        "widget": "number"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "birthday",
        "label": "生日",
        "widget": "date-picker"
      }
    }]
  }, {
    "tag": "group",
    "attrs": {
      "title": "寵物",
      "widget": "fieldset"
    },
    "children": [{
      "tag": "field",
      "attrs": {
        "name": "dogs",
        "label": "?",
        "widget": "select"
      }
    }, {
      "tag": "field",
      "attrs": {
        "name": "cats",
        "label": "?",
        "widget": "select"
      }
    }]
  }]
}

如果一個應用的設計是不需要人工寫檢視結構描述的話,可以考慮使用 JSON-based 的技術。

像 Angular 和 Vue 的模板那種 XML-like 的技術是相對來說最適合做檢視結構描述的——純宣告式,結構是向水平與垂直兩個方向增長,無論是可讀性還是表達力都更強,解析難度適中,並且具備通用性。

下面的模板程式碼所描述的內容與上面那段 JSON 程式碼一模一樣,深呼吸,好好感受一下兩者之間的差異:

<view widget="form">
  <group title="基本資訊" widget="fieldset">
    <field name="name" label="姓名" widget="input" />
    <field name="gender" label="性別" widget="radio" />
    <field name="age" label="年齡" widget="number" />
    <field name="birthday" label="生日" widget="date-picker" />
  </group>
  <group title="寵物" widget="fieldset">
    <field name="dogs" label="?" widget="select" />
    <field name="cats" label="?" widget="select" />
  </group>
</view>

至此,檢視結構描述最終該選用哪種技術,想必無須多言。

雞哥(小雞)

設計思路

毋庸置疑,模板的語法要符合 XML 語法是前提,再在此基礎上根據需求進行定製、擴充套件。首先要定義標籤集。所謂的「標籤集」就是一個元素庫,其中的每個元素都要具備一定語義,使其在業務上有存在意義。然後是制定描述元素的 schema 並實現其對應的解析、校驗等邏輯。

元素 schema 大概是長這樣:

// 屬性值型別
type PropType = 'boolean' | 'number' | 'string' | 'regexp' | 'json';

// 屬性描述符
type PropDescriptor = {
  type: PropType | PropType[];
  required: boolean; // 是否必需
};

// 元素 schema
type ElementSchema = {
  name: string; // 元素名
  tag?: string; // 標籤名,不指定時取元素名
  props?: {
    [key: string]: PropDescriptor;
  };
  attrs?: {
    resolve: (key: string, val: any) => any;
  };
  // 節點行為,是作為父節點的子節點還是屬性存在
  behavior?: {
    type: 'append' | 'attach';
    // 以下都用於 `type` 是 `'attach'` 時
    host?: string; // 宿主(屬性名)
    keyed?: boolean; // 是否為鍵值對集合,值為 `true` 且 `merge` 為 `false` 時以節點 ID 為鍵
    merge?: boolean; // 當值為 `true` 時將 `reduce` 的返回值與 `host` 指定的屬性的值進行合併後重新賦值給 `host`
    reduce?: (node: ITemplateNode) => any; // 轉換節點資訊
    restore?: (reduced: any, node?: ITemplateNode) => ITemplateNode | Partial<ITemplateNode>;
  };
};

可以看到 schema 中有 propsattrs,它們共同組成了模板元素的屬性(XML attributes),區別是:模板解析後的屬性如果是在 props 中定義的並且滿足屬性描述符的 typerequired 所指定的限制條件,會成為模板節點的 props 屬性;剩餘沒在 props 中定義的則成為模板節點的 attrs 屬性,通過 resolve 方法能夠對屬性根據自己的規則進行值的轉換。

雖然在模板中元素總是以巢狀的形式展示出層級關係,但一個元素並不一定就是其父級的結構,還可能是配置。因此,元素 schema 中的 behavior 用於設定當前元素在模板解析後是作為一個節點的子節點存在還是作為某個屬性存在。

上述的模板設計是純檢視結構描述的,並且只對元素這種「塊」進行處理,我認為這樣夠用了。根據情況,可以擴充套件為像 Angular 和 Vue 的模板那樣支援文字、插值和指令等。

如果懶癌發作並且沒什麼特殊需求,模板解析的工作可以交給魔改後的 Vue 2.6 編譯器,再適配為模板節點樹。

每個模板節點的結構大致為:

interface ITemplateNode {
  id: string;
  name: string;
  tag: string;
  props: {
    [key: string]: any;
  };
  attrs: {
    [key: string]: any;
  };
  parent: ITemplateNode | null;
  children: ITemplateNode[];
}

最後,通過適配層將模板節點樹轉為執行層的元件樹,並把渲染的控制權也轉交給了最終的執行環境。

總結

在一個複雜的前端應用中,如果不對其進行分層,那它的擴充套件性和可維護性等真的會不忍直視……通常是採用經典的三層架構,從下到上分別為資料層、邏輯層和表現層。本文以表現層為例,將其再次劃分出抽象層、執行層和適配層這三層,實際上資料層和邏輯層也可以套用這種模式——就像在生日蛋糕上切上四刀——我稱其為「九宮格」模型。

「九宮格」模型

在表現層的各種抽象中,本文著重闡述了檢視結構描述的技術選型與設計思路,可以看出 XML-like 的模板從編寫到解析再到渲染這一整條流程,與 Angular 和 Vue 的模板及 HTML 大體上一致;其他抽象只是稍微提了提,以後有機會再展開來說。

之前也寫過幾篇與模板相關的文章:從提效角度與「面向元件」做對比的《我來聊聊面向模板的前端開發》;從可定製性角度講的《我來聊聊配置驅動的檢視開發》;從低程式碼平臺的核心理念「模型驅動」出發的《我來聊聊模型驅動的前端開發》。可以說,本文的內容是它們有關表現層描述的「根基」。

無論一家公司是不是做低程式碼平臺的,或者內部有沒有低程式碼平臺,都應該從表現層抽象出檢視結構描述,至少要有如此意識。


歡迎關注微信公眾號以及時閱讀最新的技術文章:

Coding as Hobby

相關文章