1. 程式人生 > 其它 >瘋了吧!這幫人居然用 Go 寫“前端”?(二)

瘋了吧!這幫人居然用 Go 寫“前端”?(二)


作者 | 鄭嘉濤(群青)
來源|爾達 Erda 公眾號

前言

上篇我們講了故事發生的背景,也簡單闡述了元件及協議的設想:

一、豐富的通用元件庫。
二、元件渲染能力,將業務元件渲染成通用元件。
三、協議渲染能力,以處理複雜互動。

以及這種開發模式帶來的好處:

這樣的設計初衷旨在大量減少前端工作,尤其是前後端對接方面,甚至可以認為對接是“反轉”的,體現在兩個層面:介面定義的反轉和開發時序的變化。

如果你對我們的設計思路還不夠了解,可以先閱讀上篇:《瘋了吧!這幫人居然用 Go 寫“前端”?(一)》

本篇我將更細緻地介紹元件渲染和協議渲染,以及如何通過這兩種渲染做到前端徹底不關注業務。

當然最後你會發現是否 REST 並非重要,重要的是合理的切分關注點,而框架只是運用切分的幫助手段。

元件渲染

具體而言,針對一個通用元件,如何完成業務邏輯?

比如說下面同樣的一個卡片元件(Card),它由通用的元素構成和呈現:

cardComp:
  props:
    titleIcon: bug-icon
    title: Title
    subContent: Sub Content
    description: Description

但是,通過不同的 props,可以渲染出不同的場景。

場景 1:需求卡片

kanbanCardComp:
  props:
    titleIcon: requirement-icon
    title: 一個簡單的需求
    subContent: 完成容器擴容不抖動
    description: 需要儲存記錄使用者的擴容改動,通過呼叫內部封裝的 k8s 介面以實現。

場景 2:打包任務卡片

taskCardComp:
  props:
    titleIcon: flow-task-icon
    title: buildpack (java)
    subContent: ✅ success
    description: time 02:09, begin at 10:21 am ...

對於後端來說,只需要遵循通用元件的資料定義,根據元件渲染器的規則,實現渲染方法即可(需要強調的是,後端不需要知道 UI 的長相,後端面對的始終是資料)。

func Render(ctx Context, c *Comp) error {
  // 1. query db or internal service
  // 2. construct comp
  return nil
}

在互動方面,我們也需要通用元件定義所有的操作,操作(operation)可以認為是互動的影響或者說結果。舉個例子,其實查詢渲染就是最基礎的一種操作;而對於需求卡片來說,點選檢視詳情,右上角的刪除、編輯等都是操作:

不過在通用元件層面,無需感知業務,定義的都是通用的 click, menu-list 等操作,由業務元件實現具體的業務。

前端在呈現層表述的互動(比如懸浮、點選、釋放等),最終都會對應到通用元件定義的操作,而操作即是一次標準的元件渲染請求。可以這麼思考:假設頁面已經呈現在使用者面前了,使用者通過滑鼠(也可能是觸控板)觸發的瀏覽器互動事件,都由前端“呈現器”翻譯成元件操作(operation),比如說刪除操作,一旦執行操作元件便會觸發重新渲染。

下面的偽程式碼表述了操作在渲染中的體現:

// 虛擬碼,精簡了資料結構和條件判斷
func Render(ctx Context, c *Comp, ops string) error {
  if ops != "view" {
    doOps()
  }
  // continue render (aka re-render)
  return nil
}

是不是缺了點什麼?沒錯,後端也無法憑空變出一個卡片。元件渲染必須要有輸入的部分,可能是使用者直接或者間接的輸入。比如使用者說:“我想要看 id=42 的需求卡片”,這就是直接的輸入,一般會在 url 上體現。另一種情況則是間接的輸入:“我想要看 status = DONE 的所有需求卡片“,那麼針對某一張需求卡片而言,它所需的 id,是從另一個元件 - 需求列表中獲得的。

具體這個資料怎麼在元件間繫結,我們會在後續章節(協議渲染)中詳細闡述。現在只需要知道,對於單個元件的渲染(也就是業務元件)而言,我們規範了開發者只需要定義元件渲染必要的輸入。這是一個很有吸引力的做法,通過引數遮蔽外界邏輯,能夠有效地做到高內聚和低耦合。

當然有輸入就有輸出(要知道資料繫結肯定是把一個元件的輸出繫結在另一個元件的輸入)。當然互動其有狀態的特性(在協議渲染中會詳細闡述),我們最終讓輸入輸出合併在一個 state 中體現,仍然是需求卡片的例子:

kanbanCardComp:
  props:
    titleIcon: requirement-icon
    title: 一個簡單的需求
    subContent: 完成容器擴容不抖動
    description: 需要儲存記錄使用者的擴容改動,通過呼叫內部封裝的 k8s 介面以實現。
  state:
    ticketId: 42

最後一張大圖來總結一下元件的渲染過程:

協議渲染

這裡我們需要引申一個實際的問題,以 web ui 為例:當用戶訪問一個頁面時,這個頁面並非只有一個元件,比如事項看板頁面,就有諸如過濾器、看板甬道、事項卡片、型別切換器等多個元件。

並且,有個頭疼的問題:元件之間顯然是有聯動的。比如過濾器的過濾條件控制了看板甬道的列表結果。
傳統的 web 開發,這些聯動肯定是由前端程式碼來實現的。但如果前端來實現這些聯動關係,顯然就需要深度理解和參與業務了,這與我們整個設計思路是違背的。

這裡需要我們有個清晰的認知:在實際的場景中,絕不是標準化單個元件的結構後,前後端就能徹底分離的。換言之,僅將結構的定義由後端轉移到前端,只達成了一半:在靜態層面解耦了前後端。

而另一半,需要我們將元件間聯動、對元件的操作、操作導致重新渲染等,也能由渲染器進行合適處理,也就是在動態層面解耦前後端。

在講元件渲染的時候我們刻意留了一個懸念:為了保持元件的高內聚低耦合,我們將元件需要的所有輸入都引數化,並將輸入和輸出引數合稱為“狀態”(state)。那如何將引數、狀態串聯起來,完成整個頁面的邏輯呢?

想想其實也很簡單,我們需要有一個協議去規範定義這些依賴關係和傳遞方式,詳見如下形式。

protocol.yaml:

// 元件初始值
component:
  kanbanCardComp:
	  state:
      // ticketId: ??
    operations:
      click:
        reload: true
  ticketDetailDrawerComp:
    state:
      visible: false
      // ticketId: ??
    operations:
      close:
        reload: true
// 渲染過程
rendering:
  __Trigger__:
    kanbanCardComp:
      operations:
        click: set ticketDetailDrawerComp.state.visible = true
    ticketDetailDrawerComp:
      operations:
        close: set ticketDetailDrawerComp.state.visible = false
  __Default__:
    kanbanCardComp:
      state:
        ticketId: {{ url.path.2 }}
    ticketDetailDrawerComp:
      state:
        ticketId: {{ kanbanCardComp.state.ticketId }}

在進行協議渲染時,首先執行 __Trigger__ 部分,操作型別的渲染會臨時性地修改部分元件的狀態;其次執行 __Default__ 部分,進行元件之間的資料繫結;最後會進行單個業務元件的渲染,這部分在第一篇文章中已經詳細闡述。

不過最終需要將這個協議渲染之後給到前端,因為 rendering 不過只是過程資料,最終需要轉化成平凡的值。以這個例子而言,(假設使用者進行了卡片的 click 操作)協議最終渲染成:

component:
  kanbanCardComp:
    props:
      // 後端元件基於 ticketId=42 渲染出的具體資料
      titleIcon: requirement-icon
      title: 一個簡單的需求
      subContent: 完成容器擴容不抖動
      description: 需要儲存記錄使用者的擴容改動,通過呼叫內部封裝的 k8s 介面以實現。
	  state:
      ticketId: 42
    operations:
      click:
        reload: true
  ticketDetailDrawerComp:
    props:
      // 後端元件基於 ticketId=42 渲染出的具體資料
      ...
    state:
      visible: true
      ticketId: 42
    operations:
      close:
        reload: true

值得強調的一點是,前端不需要知道元件之間的聯動。所有的聯動,都通過重新渲染來實現。這意味著,每次操作,會導致重新渲染這個協議。而從內部來說,則是先進行操作的落實(比如刪除、更新),即呼叫確定的介面執行操作,然後進行場景的重新渲染。

簡單的說就是前端每次發生操作,只要告訴後端我操作了什麼(operation),後端執行操作之後立刻重新整理頁面,當然實際的流程會稍微複雜。

從上圖中我們可以看到,每次的操作是非常“短視”的,尤其是前端可以說只需要“告訴”後端做了什麼操作,別的一概無需知曉。那麼就會有人問了:如果某次操作需要傳遞資料怎麼辦?比如傳統的對接方式,如果要刪除一個資源,前端就必須傳入後端資源的 ID。那就需要講到協議必須要有的一個特性:狀態。

RESTful API 是無狀態的,但是業務邏輯需要有先後順序,勢必就需要存在狀態。傳統的做法是由前端維繫這個狀態,尤其是 SPA 更是將所有的狀態都維繫在記憶體。

舉個例子,比如一個編輯表單,首先開啟表單之後,前端需要呼叫後端介面傳入資源 ID 取得資料,並將資料 copy 進表單進行渲染;當儲存按鈕 click 觸發時,需要取得表單中當前值,並呼叫後端 save 介面進行儲存。

我們知道,當前端不關心業務時,狀態的維繫也隨之破碎。這個狀態必須要下沉到和渲染同一個位置,準確的說是協議渲染這一層(因為元件單體我們刻意設計成內聚和無狀態)。

如何做到狀態的下移呢?其實也非常簡單,我們知道一個事實,那就是操作之前必定渲染(也就是隻有訪問了頁面才能在頁面上點選)。我們只需要在渲染的時候提前預判之後操作所需要的全部資料,提前內建在協議中;而前端在執行操作時,將協議以及操作的物件等資訊悉數上報即可。當元件渲染器接收到這個協議的時候,是可以拿到所有需要的引數的(因為本來就是我自己為自己準備的),此時執行完操作後,就開啟下一個預判,並重新渲染協議給予前端進行介面呈現。

下面的例子中,可以看到當用戶進入第一頁(currentPageNo = 1)時,我們早已料到使用者會進行下一頁(next)操作,就已經把這個操作所需要的引數(pageNo = 2)置於協議之中了;隨後使用者針對元件 paginationBar 進行了一次操作 next,操作處理時便能拿到所需資料。

components:
  paginationBar:
    state:
      currentPageNo: 1
    operations:
      next:
        reload: true
        meta:
          pageNo: 2

所謂的“早已想到”並非難事,因為各個業務元件中會定義此業務元件實現了通用元件的那些操作,我們要求在定義這些操作的時候,必須要定義這些操作所必須要的外界傳入引數(之所以說外界,是因為有些業務引數在元件內部就可以自行處理,而無需依賴外部元件,比如 state 或者 props 的資料資訊已經充足)。

最後針對呈現而言,還需要補充元件之間的層級關係,最終形成一個樹形的關係,為了佈局也需要填充一些“無意義”的元件像 Container、LRContainer 等:

不過這些都是靜態的資料,可以直接放入協議,也無需渲染:

hierarchy:
  root: ticketManage
  structure:
    ticketManage:
      - head
      - ticketKanban
    head:
      left: ticketFilter
      right: ticketViewGroup

components:
  ticketManage:
    type: Container
  head:
    type: LRContainer
  ...

暫時告一段落

我們通過元件渲染、協議渲染以及一個通用元件庫完成了徹底的前後端分離。不過我們在實踐中發現,很多時候徹底的前後端分離會帶來一定的困難,這也是我們將認為協議承載的是場景而非頁面。

如果是徹底的前後端分離,那勢必整個頁面甚至整個網站就應該是一個協議,因為只要跳出協議或者說頁面間切換,就會有業務含義。但真實情況是,如果一個協議中有太多的元件需要編排,這個複雜編排對於開發者而言是非常繁瑣的,並且這個複雜性帶來的損失完全淹沒徹底前後端分離帶來的優勢。

從務實角度出發,我們更應該實踐“關注點分離”而非是徹底的“前後端分離”。在設計元件以及協議時,我們總是問自己:

  • 前端關注什麼?
  • 後端關注什麼?
  • 框架/協議應該關注什麼?

最終我們框架選擇和傳統對接方式共存的形式,並且能夠友好地互相操作。

比如前端在呈現一個元件的時候,可以選擇“偷偷”呼叫一些 RESTful API 來完成特定的事情,也可以在一個頁面中“拼湊“多個協議進行聯動等等。

我們也發現,當大量業務邏輯能夠從前端下沉到後端時,前端呈現層的邏輯將變得非常簡單(數量有限的元件)。我們意外獲得了多端支援能力,比如可以實現 CLI 的呈現層,也可以實現 IDE 外掛的呈現層等等。

當然我們現在並沒有實現這些,不過相信如果是聰明的你,實現這個不難吧~

目前 Erda 的所有程式碼均已開源,真摯的希望你也能夠參與進來!