聊聊中後臺前端應用:上下文的那些事兒

發表於2023-09-23

經過《聊聊中後臺前端應用:模組相關的一些事》和《聊聊中後臺前端應用:業務中的元件體系》這兩篇文章的鋪墊,終於可以單獨寫一篇文章來專門講講「上下文」相關的事情了——

概念明晰

在進入正題之前,先試圖釐清與主題關係密切的幾個概念:狀態、狀態管理和上下文。

狀態

有時會聽到兩撥人在打嘴仗——有一撥人說:「前端都是狀態,沒有資料,是狀態驅動檢視而不是資料驅動檢視」。另一撥人反駁說:「狀態難道不是資料嗎?不是資料是啥?」——這兩撥人的說法都沒有錯,只不過是站在了不同的角度。

一般來說,「資料」是指存放在資料庫、檔案系統中的持久資料,是「靜態」的、「持久」的,常被當作「資料來源」的略稱來用;「狀態」則是保持在記憶體當中的瞬時資料,是「動態」的、「臨時」的,來源於透過 HTTP 請求或本地儲存讀取的資料以及終端使用者在介面上的操作——然而它們都是資料。

也就是說,可以認為在經典的三層架構中資料層往上的分層中的資料都是「狀態」:

「表現-領域-資料」分層架構

對於前端來說,資料層通訊的物件就是服務端和本地儲存。

狀態管理

「狀態管理」是什麼?顧名思義,就是對「狀態」的「管理」。雖然在前端圈兒內隨著 Redux 等的流行讓「狀態管理」成了熱詞,但它並不是什麼新鮮貨。

世上的任何事物都需要被管理,只不過當它還沒那麼複雜的時候,不需要作為一門學問或者說一套方法論拿出來供人們單獨討論。

由於前後端分離和單頁面應用的出現,使得前端的狀態複雜化,如何去有效地進行管理成為了問題,因此形成了現如今很多人去關注並討論「狀態管理」的局面。

我瞭解到的前端專案中,它們的狀態管理方案可以說是「兩極分化」——要麼分散在各個 UI 元件中,要麼集中到一個所謂的「全域性 store」中——這兩種我都不太認可。

上下文

在《聊聊中後臺前端應用:模組相關的一些事》和《聊聊中後臺前端應用:業務中的元件體系》這兩篇文章中都對「上下文」有所描述,簡單來說,它對於程式的作用就相當於幫助人去理解事物並做出相應反應的「語境」,畢竟它們的英文都是「context」。

在實際應用時,「上下文」很可能是一個帶有很多屬性及對其進行讀、寫操作的方法的物件。那些已經被暴露出來或沒有被暴露出來的變數,就是上文所說的「狀態」,而暴露出來的方法或函式就是對「狀態」進行管理用的——它們共同構成了「上下文」。

一個上下文可以用類的方式去實現:

class ValueContext {
  private value;

  constructor({ initialValue }) {
    this.value = initialValue;
  }

  public getValue() {
    return this.value;
  }

  public setValue(value) {
    return this.value = value;
  }
}

const context = new ValueContext({ initialValue: 'Hello, Ourai!' });

也可以用函式的方式:

function createValueContext({ initialValue }) {
  let value = initialValue;

  return {
    getValue: () => value,
    setValue: newValue => (value = newValue),
  };
}

const context = createValueContext({ initialValue: 'Hello, Ourai!' });

無論用哪種方式實現,無論實現的具體邏輯是什麼,對於上下文的消費者來說它就是一個 API——「上下文」是串起各部分邏輯,具有一定程度泛化的業務語義的介面。

元件與狀態

互動與狀態如影隨形,除了那些純展示用的 UI 元件,一般來說 UI 元件都會有與其關聯的狀態,只是維護的地方不同。

根據 UI 元件自身內部是否維護了狀態,可分為「無狀態元件」與「有狀態元件」。

那些純展示用的 UI 元件毋庸置疑都是「無狀態元件」,而有互動的 UI 元件若是將狀態維護外接,那就是「無狀態元件」,否則是「有狀態元件」——

<template>
  <input :value="value" @input="handleInput" />
</template>

<script lang="ts">
import { Vue, Component } from 'vue-property-decorator';

@Component
export default class StatefulInput extends Vue {
  private value: string = '';

  private handleInput(evt): void {
    this.value = evt.target.value;
  }
}
</script>

同樣是自定義的輸入框元件,上面的示例是有狀態元件,而下面的則是無狀態元件:

<template>
  <input :value="value" @input="handleInput" />
</template>

<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator';

@Component
export default class StatelessInput extends Vue {
  @Prop({ type: String, default: '' })
  private readonly value!: string;

  private handleInput(evt): void {
    this.$emit('input', evt.target.value);
  }
}
</script>

相較之下,有狀態元件在保證功能正常的情況下可以暴露更少的屬性和事件,但可複用性就降低了,無狀態元件正好相反。

控制元件通常會被設計為無狀態元件,尤其是互動簡單的和純展示的;為了保證基本功能可用,互動複雜的控制元件可能會被設計為有狀態元件。

在封裝部件時,大多數人的思路和封裝控制元件一樣,就會——

在相對大粒度的部件中,如果主要依賴屬性和事件進行通訊的話,它們的數量很容易變得失控,並且內部的結構和邏輯也會被改得面目全非,維護起來十分困難和難受——這就變成了一坨翔?!

歐雷《聊聊中後臺前端應用:業務中的元件體系

按照封裝控制元件的思路去封裝部件,很容易會讓屬性和事件的數量變得失控,或者內部各種邏輯膨脹,使部件變得十分臃腫——無論是哪種,都會加大維護成本。

私以為,部件內部儘量不要有互動邏輯和業務邏輯,也儘可能不去維護任何狀態——業務的狀態與邏輯上升至上下文中,互動邏輯下沉到控制元件中,展現狀態由業務狀態計算出來——部件中理論上只有業務與互動/展現間的轉換邏輯。

理想狀況下,部件中的各種依賴也是透過上下文獲取的,而不是自己從哪個位置 import 進來的。

說白了,在前端應用這個「有機體」中,控制元件和部件是具體產生功能的「組織」和「器官」,而上下文則是為它們傳遞資訊、輸送養分的「神經」和「血管」。

上下文概要

構成上下文的基本元素除了上文說過的要維護的狀態及對其進行讀、寫操作的方法/函式之外,大多還會有讓上下文內外資料保持一致的基本是基於觀察者模式實現的同步機制。

在本系列文章所闡述的體系中,上下文大概分為三類:應用上下文、模組上下文和資料上下文。

「應用上下文」是作用於整個應用的上下文,如果執行時只有一個應用,那麼可以把它視為是「全域性」的上下文;倘若執行時中存在多個應用,那就是每個應用對應一個應用上下文。

應用上下文中維護的是單一應用範圍內共享的狀態,如路由、主題、國際化等配置資訊,和使用者的基本資訊與許可權等。

「模組上下文」主要用來維護以「模組」為中心的狀態,像指定模組所依賴的其他模組的資源和它提供給其他模組的可用資源這類依賴資訊,以及該模組的模型、檢視、服務端動作(透過 HTTP 請求與服務端通訊的函式)等後設資料。

「資料上下文」則是前端應用中使資料流動起來的主力,稍後展開說。

資料上下文

在繼續往下說「資料上下文」之前,首先要理解在《聊聊中後臺前端應用:業務中的元件體系》中提到的「從資料的視角看前端」。

「資料上下文」又細分為「檢視上下文」和「搜尋上下文」,根據整個體系的複雜程度,它們可分別再往下劃分出「欄位上下文」和「過濾器上下文」。實際上,可以認為「搜尋上下文」是為了收集列表資料過濾條件而特化了的「(物件)檢視上下文」。

「值」的抽象

各種「資料上下文」的共同特點是對「值」的操作,因此可以圍繞著「值」進行一些抽象——

根據用途,有三種「值」的狀態——一直處於活動狀態的「當前值」,用 value 來表示;在初始化與重置時用來賦值的「初始值」和「預設值」,分別用 initialValuedefaultValue 來表示。

在不同的具體資料上下文中「當前值」的含義會有所差別。比如,在物件檢視上下文中它是指隨著使用者操作而變化的欄位的鍵值集合,而在列表檢視上下文中則是已選中的記錄。

在實際應用中,「初始值」與「預設值」的主要區別在於優先順序不同,「初始值」大於「預設值」,即在沒有「初始值」時才會用「預設值」。

與「值」相關的操作基本只有 4 個,分別是對「當前值」進行讀與寫的 getValue()setValue(),將「當前值」向外/上傳遞的 submit() 以及恢復「當前值」到「初始值」或「預設值」的 reset()

相應地,有 4 個「事件」供外界在不同時機進行資料同步用——代表資料已經準備好了的 ready 事件;「當前值」的每次變更都會觸發 change 事件;呼叫 submit()reset() 時會觸發對應的 submitreset 事件。

關於 ready 事件,有一個使用場景是:網頁載入完成後,列表資料要等過濾條件收集好了再發請求獲取,但過濾條件又得先從 URL 的查詢引數中恢復——這就需要列表檢視上下文去監聽搜尋上下文的 ready 事件,進而去發請求獲取資料。

「值」的校驗

為了保障資料的安全及純淨,在處理資料時先進行合法性或者說有效性校驗是基本操作,因此在呼叫 setValue() 時其內部會先校驗一波。

對「值」的校驗實際上就是按照優先順序執行一下各個約束條件。這裡隱含了一個資訊——約束是可以顯式定義並且可擴充套件的。

「值」的約束可分為來自資料型別和資料結構的自然性約束,以及源於模型關係與業務規則等的非自然性約束。這裡的「自然」與否是單純從資料的特性層面來說。

總結

提起「應用」這個詞,很多人的第一反應是:「這個東西好重、好龐大啊!」它在他們腦中的形象就像壓在孫悟空身上的五指山一樣的巨石。

而一個更好的視角是,把「應用」看作是「介面」與「實現」的組合。更準確地說,可能是「流水線」與「物料」——將不易變的、關係與規則相對固定的東西泛化並介面化,它們之間相互連線形成「流水線」;把易變的部分作為在「流水線」上流轉的「物料」存在。

就像在《聊聊中後臺前端應用:業務中的元件體系》的最後,我說——

理想情況下,最終會發現——除了業務邏輯,好像其餘部分幾乎都是介面(interface)——具體實現可以任意移除,隨意替換!

歐雷《聊聊中後臺前端應用:業務中的元件體系

如果不作進一步說明,上面的描述也許會有些讓人摸不著頭腦——

在一箇中後臺前端應用中,最易變的是業務邏輯和 UI 設計,最不易變的是「值」的自然性約束、「檢視」與「欄位」間的內在關係、控制元件的屬性和事件等。

構建一個體系,將易變的部分弄成作為「物料」存在的後設資料或配置,互相連通的上下文就成為了「流水線」。


本文其他閱讀地址:個人網站微信公眾號

相關文章