前端服務化——頁面搭建工具的死與生


引言

我有個非常犀利的朋友,在得知我要去做可視化的頁面搭建工具時問了我一個問題:

“你自己會用這樣的工具嗎?”

同時帶着意味深長的笑。

然而這個問題並沒有如他所願改變我的想法。早在 jquery ui、bootstrap 盛行的時代,就有過無數這樣的工具,我沒有用過,也不會去用。原因有一萬個:

  • 業務需求太靈活,工具都是基於已有的組件庫,個性化的東西搭不出來。
  • 前端技術發展太快,工具整合沒有那么迅速。
  • 有學習成本,不如手寫快,靈活性沒有手寫高。

在包括我的很多前端看來,這條路上屍骨累累,甚至有很多連痕跡都沒有留下。但是失敗者最多的路,並不一定是死路。如果都沒有拋開過頭腦里的成見,沒有進行過獨立思考就放棄了,未免太盲目。這篇文章就當做我在求生之路上的記錄。也請讀者暫且忘掉所有的經驗,輕裝上陣,這趟旅途不會讓你失望。對具體設計不感興趣的讀者可以直接閱讀《生門》一章,讀完那一章后或許你會迫不及待再從頭讀起。

起點和方向

在接下來的兩章中,我們將從項目背景一直討論到關鍵技術的實踐。這其中既會包括各種技術也會包括產品和交互的思考。

項目的背景是,公司業務迅速擴張,有大量對內的系統頁面需要搭建。而前端人力是瓶頸,所以我們希望能以服務化的方式輸出前端能力,讓公司內所有非前端出身但有編程能力的人都能使用這種服務快速地開發出較高質量的頁面。 從產品角度來說,它的目標已經很明確了:

  • 使用人群:非前端的開發者
  • 要提供的服務:能以中上等開發速度開發出中上等可維護性頁面的集成開發環境(以下簡稱開發環境)

有了這個目標,我們就可以開始設計產品形態了。

頁面分為視圖和邏輯兩部分,在目前組件化的大背景下,視圖基本上可以等同於組件樹。首先,什么樣的頁面編輯方式學習成本最低同時最快速?當然是所見即所得,拖拽或者編輯樹型結構的數據這兩種方式都可以接受。實際測試中拖拽最容易上手,熟悉了快捷鍵的情況下則編輯組件樹更快。

接着,怎樣讓用戶編寫頁面邏輯既能學習成本低,又能保障質量?學習成本低意味着概念要少,或者都是用戶已知的概念。保障質量這個概念比較大,我們可以從開發的兩個階段來考慮:

  • 一是開發時最好有保障,例如前端開發時的 eslint 加上編輯器提示就能很好地提前避免一些低級錯誤。
  • 二是在開發完之后,代碼應該“有跡可循”,無論是排查問題,還是擴展需求,都要讓用戶在頭腦里第一時間就知道應該怎樣寫邏輯,寫在哪里。也就意味着概念要完善,職責分明。同時,工具層面也可以有些輔助功能,例如傳統編輯器的變量搜索等。

為了給讀者一個更直觀的影響,我們暫且來看一張兩張圖。

頁面編輯:
頁面編輯
邏輯編輯:
邏輯編輯

接下來分部分細化形態,梳理關系,來得到一個明確的架構圖。目前看來可先拆分成三個部分:

  • 一個編輯頁面和邏輯的工具,以下暫稱 IDE。
  • 搭建頁面所需的基礎組件。
  • 運行時框架(以下簡稱框架),由它將頁面的組件樹、和頁面邏輯結合在一起渲染成最終的頁面。

很容易發現這三者的關系並不是平行的。首先,IDE 在這三者中是直接給用戶使用的產物,它代表着我們最終想要呈現給用戶什么樣的東西。對其他部分來說,它算是需求來源。

來看它和頁面以及組件的的關系。我們最終希望用戶在點擊頁面上的某個組件或者組件樹上的節點時,就能查看、配置這個組件上的屬性,邏輯綁定到它觸發的事件上。

組建與屬性

因此它對組件的需求是:組件必須暴露出自己的所有屬性和事件,讓外部可讀。

再看 IDE 和框架的關系。用戶在編寫邏輯時,需要理解的概念都是屬於框架的, IDE 只是編輯工具。當然 IDE 可以提供很多輔助功能,例如語法校驗,例如可視化地展示邏輯與組件的綁定關系。框架為主,IDE 為輔。

最后,框架和組件的關系。這里很有意思,按技術發展的現狀來說,一直都是先有組件庫,才有上層應用框架。然而,組件規范其實應該是應用框架規范的一部分。舉個實際例子,如果應用框架要建立全局數據源(方便做回滾等高級功能),來保存所有狀態。那么組件就不再需要內部狀態,只要渲染就夠了,實現上簡單很多。這種上層建築與基礎設施的關系,很像高樓與磚瓦。摩天大樓需要鋼筋混凝土,負責燒土磚的工人一開始是想不到的。所以實施中,框架和組件庫之間通常還會有適配層。優秀的架構能力就體現在一開始就看到了足夠多的上層需求,提前避免了發展中的人力損耗。

理清了所有關系后,來看看整體架構:

整體架構

這其中將 IDE 底層和業務層進行了拆分,IDE 底層提供窗口、快捷鍵、Tab 等常用功能,IDE 上業務層才用來處理和可視化相關的內容。其中也包括為了提供更好體驗,卻又不適合放到組件、和應用框架中的膠水代碼,例如組件屬性的說明,示例等等。IDE 的架構設計將會在另一篇文章中介紹。

龍骨

整體的架構有了后,接下來就是關鍵技術——運行時框架的設計了。

在數據驅動的大背景下,應用框架處理的問題實際上只有一個:數據管理。其中“數據”既包括組件數據也包括業務數據,而“管理”既包括如何保存數據,也包括以何種方式讓用戶來讀寫數據。我們仍然從使用場景出發,來分析出數據管理的應用場景,最后再考慮設計實現。在前端領域內,用戶對交互的需求是漸進增長的,業務的需求是漸進的,因此應用的復雜度整體看來也是漸進的。所以我們只需要明確出最簡單和最復雜的情況,就可以勾勒出框架需要支持的范圍了:

  • 按業務經驗,最簡單的情況無非就是純展示的“詳情頁” 或 “列表頁”。最符合本能的邏輯寫法應該是:
    • 拼好組件樹。如果是靜態的數據,直接在每個組件上的屬性里設置好即可,流程結束。
    • 如果是動態數據,那么使用 ajax 獲取到數據。將獲取的數據格式化成組件所能接受的格式,然后使用 api set 進去。

在這個場景中用戶需要了解兩件事情:

  • 組件的數據格式,實際上就是組件的屬性,在 IDE 中已經是直接暴露出來的。
  • 設置數據的 api 。

再接着看最復雜的場景,我所接觸過的最復雜的前端應用都是業務關聯極強的工具,例如雲計算平台的控制台,客服系統的控制台,包括這個 IDE 也算。這類產品的復雜體現在兩個方面:

  • 有大量的交互細節,例如組件狀態要和權限結合(例如 按鈕的 disable 狀態)、組件要根據需求動態顯示或隱藏,表單的校驗,異步狀態的提示或管理(例如發送請求后,按鈕上出現loading)。
  • 除了組件數據,還有大量的業務數據要管理,並且是其中有很多聯動關系。例如在雲計算控制台里面有 ECS、LBS 等概念,ECS 和 LBS 有關聯關系,ECS如果改名了。不僅要更新ECS自己的詳情顯示,還要自動更新關聯的LBS的顯示等。

有了這兩個端點,就找到了要提供的能力的上限和下限,接下來就是框架設計中最有意思也最困難的部分了——如何提供漸進式地開發體驗。這幾乎也是所有優秀框架的共有的一個品質。漸進式的體驗意味着用戶只要了解最基本的功能就能馬上開始工作,當要處理更高級的需求時才需要再學習高級的功能。更進一步話,最好這些高級功能也是用一種可擴展的機制來實現的,如中間件,學習一次機制,即可解決無限的問題。

在最簡場景里可以看到,用戶所需的最基本的功能就是一個可讀寫的,包含所有組件數據的數據源即可(以下簡稱組件數據源)。為了便於讓用戶理解,這個數據源的數據格式最好與組件樹存在類似的對應關系。舉個注冊頁面的例子,我們的組件樹可能長這樣:

<div>
    <Title>注冊</Title>
    <Input label="姓名"/>
    <Input label="密碼" type="password"/>
    <Button text="提交"/>
</div>

那么組件數據源可表述為:

{
  0: {
    text: '注冊',
    size: 'large'
  },
  1: {
    value: '',
    label: '姓名'
    type: 'text',
  },
  2: {
    value: '',
    label: '密碼'
    type: 'password',
  },
  3: {
    text: '提交',
    type: 'normal'
  }
}

用戶的讀寫操作可以設計成這樣:

// 借用 redux 中的 store 作為數據源的名字
store.get('1.value') // 讀取第一個 Input 的值
store.set('3.type', 'loading') // 將 Button 設為 loading 狀態

這個寫法可以實現需求,但有兩個問題:

  • 用組件的位置作為索引不友好,不能適應變化。例如組件的位置調整了一下順序,代碼里就得相應改動。
  • 在用戶的業務邏輯中,並不是所有組件的數據用戶都需要,例如Title。

為何不讓用戶自己給想要數據的組件取名?這可以一次性解決這兩個問題。

<div>
    <Title>注冊</Title>
    <Input bind="name" label="姓名"/>
    <Input bind="password" label="密碼" type="password"/>
    <Button bind="submit" text="提交"/>
</div>

得到的數據源:

{
  name: {
    value: '',
    label: '姓名'
    type: 'text',
  },
  password: {
    value: '',
    label: '密碼'
    type: 'password',
  },
  submit: {
    text: '提交',
    disabled: false
  }
}

再看看用戶的提交邏輯如何寫(這個邏輯綁定在 Button 的 onClick 事件上):

// 通過注入的方式把數據源管理對象交給用戶
function({store}){
  store.set('submit', {disabled: true}) // 為了防止重復提交
  ajax({
    url : 'xxx',
    data: {
      name: store.get('name').value,
      password: store.get('password').value
    }
  }).finally(() => {
      store.set('submit', {disabled: false})
  })
}

稍微好了一點,但是任何開發者都仍然會覺得這段代碼太臟,它既處理了業務邏輯又處理了渲染邏輯,項目膨脹之后這樣的代碼不利於維護。

我們需要一種機制來分離不同類型的處理邏輯,讓代碼更易維護。這個出發點也正是啟發后面設計的關鍵!

為什么這樣說?讓我們來看看之前談到的復雜場景,其中提到了大量的交互狀態是復雜場景的特點之一,常見的交互有:

  • 異步狀態控制,如上面 button 在發請求時要設為 disable 防止重復提交
  • 權限控制
  • 表單驗證狀態

如何分離這些交互細節?或者換個更具體的問題,你覺得用戶怎樣寫這些邏輯會最爽?仍然以上面的場景為例子,用戶當然希望他代碼中的ajax一發送,按鈕就自動變成 disable,一結束又自動變回來。這對我們來說不就是 ajax 狀態和組件狀態之間的自動映射嗎?我們能不能提供一種機制讓用戶給 ajax 命名,同時可以寫映射關系,如:

ajax('login', {name: 'xxx', password: 'xxx'})

映射關系:

function mapAjaxToButton({ajaxStates}){ // ajaxStates 由框架提供,保存着所有的ajax 狀態
  return {
    disabled: ajaxStates.login === 'pending'
  }
}

這樣,剛才處理 ajax 的臟代碼就完全分離出來了。我們再看看這個方案中幾個概念的關系。

數據源架構1

打開這個思路后,你會發現幾乎其他所有問題,都可以用這個方案來解了!為專有的問題領域建立專有的數據源,同時建立數據源到組件數據源的映射關系。即能擴展能力,又能分離代碼。

我們再看權限控制的例子。如果用戶不具有某權限時就把button disable 掉,映射關系我們可以寫成:

function mapAuthToButton({auth}){
  return {
    disabled: !auth.has('xxx')
  }
}

非常直觀。

再看表單驗證狀態。建立驗證數據結果的數據源,讓用戶配置哪些組件需要進行校驗,校驗時機(例如正在輸入或者離開焦點時)。例如:

<Input bind='name' onBlur={state => {validation.validate(state)}} />

validator 映射寫法的和前面的例子異區同工,用戶希望的當然是我只需要告訴你什么情況下是通過,什么不通過即可,同時也可以加上一些必要的message:

function validateRule(state) {
  return {
  	valid: state.value !== 'xxx',
  	message: state.value !== 'xxx' ? 'success' : 'value must be xxx',
  }
}

有了輸入源,接下來仍然按之前思路將驗證數據源映射到組件數據源上:

function mapValidationToInput({validation}) {
  const hasFeedback = validation.get('name') !== undefined
  return {
    status: hasFeedback ? (validation.get('name').valid ? 'valid': 'invalid') : 'normal', 
    help: hasFeedback ? validation.get('name').message : ''
  }
}

到這里,我們已經完全看到用專屬的數據源處理專有問題,最后映射到組件數據源上去所產生的效果了。它能很好地將所有將交互細節和業務邏輯划分。

我們進一步注意到,無論異步控制、表單驗證還是權限,只要組件遵循某種屬性命名規則,那么所有的映射函數就都可以寫成固定的!

因此,如果我們為組件制定一個屬性接口規范,就可以利用提供更有好的方式自動生成映射代碼了。例如,規定帶驗證功能的表單類的屬性接口必須有:

  • status: 'normal' | 'valid' | 'invalid'
  • help : ''

那么上面例子里面的映射函數,就只需要用戶填寫 validateRule 就夠了,映射函數將 valid/message 字段映射到 組件的 status/help 屬性上。

至此,最后剩下的處理復雜場景中的大量業務數據的這一問題也迎刃而解了,同樣建立一個業務數據源,聲明業務數據與組件數據的映射關系即可。

數據源架構2

講完了邏輯的設計,最后再提一下組件的規范,正如前面所說,所有的組件狀態是由應用框架保存的。這和我們現實中常見的經驗相悖。現實中的組件通常是數據、行為、渲染邏輯三部分寫在一起,使用 class 或者工廠方法來創建。如果是全面由框架接管,則應該打散,全部寫成聲明式。雖然不符經驗,但是聲明式的組件定義解決了《理想的應用框架》中提到的組件庫的兩個終極問題,“覆寫和擴展”。具體可參見以開源的組件規范 github.com/sskyy/react-lego,這里不再展開。

生門!

在還沒有開始項目之前玉伯就提醒過我,IDE做得再酷炫,組件做得再豐富都不是活路。可視化的集成框架真正的問題在於:雖然對沒有前端能力的人來說,它更簡單。但相比手寫代碼它缺少了靈活性,那么在用戶前端能力增強后,你拿什么來補償用戶,讓他仍然離不開你?這里我可以再清晰的回答一次。

任何一個有一定復雜度、會持續增長的應用最重視的,其實並不是開發速度,而是可維護性和可擴展性。 這也是框架設計者們擺在首位的事情。可擴展性的好壞取決於框架的擴展機制。在我們的上面的設計中需要擴展的有兩部分,組件和功能。組件的擴展可以通過允許用戶提交自定義組件來實現。功能的擴展主要由框架開發者完成,但是也可以考慮讓用戶能仿照異步管理數據源一樣建立自己專用的數據源來解決業務專有問題。

可維護性,在數據驅動的前提下,實際上等於”框架能不能很好的回答兩個問題“:

  • 數據現在是什么樣的
  • 數據在哪里被修改了,或者更細致地分解為“運行時告訴我數據這次在哪里被修改了”,和“開發時告訴我數據有可能在哪里被修改”。

第一個問題容易解決,建立統一的全局數據源,正如我們所設計的。不僅方便調試,還可以做回滾,做應用快照等功能。

第二個問題,在已知的框架中有兩種常見的答案:

一種是利用某種設計模式,讓用戶將數據的變化集中在一個抽象里。例如 redux 狀態機中的 reducer。這種方式的好處在於直接看代碼就可以了解數據所有可能發生的變化。但靠代碼組織的問題在於它本身受文件系統影響,一但代碼拆分不合理還是容易不好找。

另一種方式則更常見,就是運行時記錄調用棧。在 《理想的應用框架》中也提到過。以”響應業務事件的聲明式代碼“作為基礎單位,框架來控制調用流程,這樣框架即可產出一個和業務事件一致的調用棧,同時因為這種一致性,無論代碼拆分得多不合理,都可以展示合理的信息。但調用棧的方式也有個缺點,就是一定要運行,出問題時一定要運行到相應的那一步才能找到問題相應的信息。同時會受到循環、條件語句的影響,這在多步調試或者非冪等操作的場景下非常不好用。它只能回答“數據這次在哪里被修改了”,不能回答“數據都可能在哪里被修改”。

有沒有一種方式,既是靜態的,又能產出像調用棧一樣的數據結構方便做輔助工具呢?當然有!語法分析就可以,它絕對准確,不受條件語句、異常等影響,甚至能做到提前預知人為錯誤。Rust 在提前預知人為錯誤這個方面上達到了一個新高度。它做到了”能編譯通過就不會出錯“ ,這讓工程質量產生了質的提升。舉個我們系統中可以理解的例子,在前面的設計中已經提到,組件是聲明式的,所以數據格式是已知並且可讀的,包括每個字段的類型。在實現中我們的后端使用了 graphQL 作為接口層,因此接口返回的數據結構和字段類型也是已知的,當用戶在代碼中調用后端接口並嘗試把接口返回的數據塞到組件上來展示時,通過語法分析、變量追蹤,我們就可以在“運行前”自動檢測到用戶是否傳錯了接口參數,是否把不符合組件數據格式的數據塞給了組件等等。這樣強度的檢測幾乎可以幫我們避免日常開發中絕大多數人為失誤。除了診斷,語法分析當然還能用來提供全局的依賴視圖,例如哪些接口在哪些邏輯里被調用了。哪些數據被哪些邏輯修改了,會引起視圖的哪些部分改變等等。可以完美地回答“數據在哪里被修改了” 。

接下來就是如何實現的問題了。稍微想想就會發現,基於手寫代碼的方式分析成本有點高,而且很有可能實現不了。這里面有兩個點比較麻煩:

  • 分析程序首先要理解基於文件系統的包管理機制,才能做全局的分析。
  • 如果用戶在代碼中做了二次抽象,分析程序的復雜度會翻倍。試想分析 store.set('xxx', 'yyy') 和 分析 store[method](name) 的復雜度。

但是,我們剛剛設計的系統不是放棄了靈活性嗎?用戶在使用 IDE 時不需要文件系統的概念,只需要如填空一般在函數中寫邏輯,所有依賴的變量也不需要自己關系,都是框架通過函數參數注入的。在這個背景下,用戶邏輯的目的提前知道了,所有的入參出參的用途也提前知道了,那么要實現上述的“數據在哪里被修改了”等功能,是不是只需要追蹤用戶代碼里的變量就夠了?!上面說的難點在我們這里不存在了。

到這里,死門竟然變成了生門!“開發環境通過對邏輯使用的限制,實現了對整個應用的控制達到了 100% 的狀態“!具體可以從兩個方面來進一步理解:

  • ”對邏輯使用的限制“指的是具體做某件事的代碼寫在哪里,必須怎么寫都是由開發環境完全指定的。這意味着開發環境完全控制了所有代碼的語意背景。但同時也是因為這樣,開發環境說做不了的事情,就一定做不了,限制了用戶的自由發揮。
  • ”控制達到 100%“ 指的是開發環境可以分析理解所有用戶邏輯,你提的所有“什么數據/接口/組件,在哪里/什么時候,怎么了?”這樣的問題它都可以回答。實際上 js 在這里只是一種DSL了。舉幾個更具體的說法來表示 100%:
    • 除了用戶自己對業務理解的錯誤,開發環境幾乎可以提前阻止所有人為失誤,如前面所說的數據類型不匹配,ajax 參數錯誤等等。注意,這里說的是提前阻止,不需要到運行時調試才發現
    • 開發環境可將所有邏輯和其中的依賴可視化,例如可完整地列舉出所有操作了某一數據的邏輯代碼。
    • 開發環境有足夠能力對用戶代碼進行自動升級轉換等工作。例如將 js 里的所有數據操作自動變成 immutable,排除潛在的對象引用錯誤等。
    • 開發環境可以深度分析運行時框架,提前注入運行時數據,提升運行時性能。例如提前分析哪些數據修改會導致哪些組件屬性,靜態注入這種依賴關系,這樣框架就不再需要運行時再去判斷。這種數據到視圖的依賴綁定也正是過去 MVVM 類框架花了很大力氣去做的事情。

運行時分析示例:
運行時分析示例

靜態依賴分析示例:
靜態依賴分析示例

想到了這里,才算真正找到了活路。文章的前半部分,我強調過從頭思考,原因很簡單,任何時候經驗都是可能成為束縛的。就像從框架開發者的角度來說,放棄了靈活性,把自己局限在一定范圍內簡直是逆行倒施,但正是這樣的局限才有可能在開發速度上和可維護性上帶來質的飛升。

在這兩年做框架開發的同時我也在做全棧教學的工作。這個過程中也發現對公司來說”授人以魚”和“授人以漁“同樣重要。因為無論教學做得多么成功,最后的產出物的質量仍然會受到受到學生的自身素質、工作內容等影響。特別是團隊人員變化快時,教學的收益會特別低。而將能力服務化再提供給受眾,可以抵御這種風險,因為服務自身可以不斷沉淀、升級。后來在學習FBP時,與作者 J.P.Morrison 通信了解到 IBM 時代的 FBP 可視化工具的應用場景和這個項目非常像,而 FBP 當時在 IBM 內部取得了成功,他們甚至成功把全部可視化編輯的系統賣給了一家銀行。這些信息也讓我進一步意識到團隊越大,構建上層建築越有意義。在很多大公司里,光內部系統就有上百個,有大量復雜度在一定范圍內的頁面要開發,前端服務化的意義遠大於我們站在自己固有的經驗中所看到的程度。

到這里這一篇可以先告一段路了,之后組件庫的碰到的常見問題和設計還有基於 web 的 IDE 通用架構會有另外的文章來說明。相比這些具體的技術實現,我更希望后面這些關於質變,以及如何形成質變的思考能帶給讀者更多收益。感謝閱讀。

最后放出幾張用戶制作的頁面:

答讀者問

  • 為什么社區從來沒有流行過這樣的東西?
    • 這個問題其實比較模糊,這和問”怎樣做產品能成功”性質一樣,我非常建議讀者先讀讀純銀的文章,不管是技術還是產品都有收益。但是我仍然嘗試回答一下。首先要做一套這樣的開發環境設計到的技術棧太多,組件庫、渲染引擎、IDE、分析引擎、后端服務每一個方面都要耗費相當大的人力。就算社區有這樣的東西做出來了,也很少有團隊有同等人力能拿去用,任何嚴肅地投入生產的項目都不可能拿一個自己掌握不住的工具去用的。其次,這樣的東西就算有公司做出來並且在內部很流行,也很難為外界所知,因為這種集成開發環境首先需要大量內部系統的積累,在我們這里就是組件庫、后端服務等。另外公司戰略上的支持也是必不可少的。實際上據我了解,不管流不流行,每個大公司都有至少一套這樣的系統。
  • 框架部分為什么不用 redux ,現在的看起來像砍掉了 action 的 redux?
    • redux 本質上是個狀態機,action 的設計能夠約束變化來源,屏蔽來源細節,同時寫代碼時能把所有變化和數據本身寫在一起,解決“數據在哪里,被怎么了”的問題。然而我們更傾向於像用戶暴露更少的概念,讓他用直覺來使用,由開發環境解決可維護性等問題。這是其實是產品策略,和技術爭論無關。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM