元件化通用模式

小擼發表於2017-12-28

一、前言

模式是一種規律或者說有效的方法,所以掌握某一種實踐總結出來的模式是快速學習和積累的較好方法,模式的對錯需要自己去把握,但是隻有量的積累才會發生質的改變,多思考總是好的。(下面的程式碼例項更多是 React 類似的虛擬碼,不一定能夠執行,函式類似的玩意更容易簡單描述問題)

二、前端的關注點遷移

這篇文章主要介紹現在元件化的一些模式,以及設計元件的一些思考,那麼為什麼是思考元件呢?因為現在前端開發過程是以元件為基本單位來開發。在元件化被普及(因為提及的時間是很早的或者說有些廠實現了自己的一套但是在整個前端還未是一種流行編寫頁面的單元)前,我們的大多數聚焦點是資源的分離,也就是 HTML、CSS、JavaScript,分別負責頁面資訊、頁面樣式、頁面行為,現在我們程式設計上的聚焦點更多的是聚焦在資料元件

關注點遷移

但是有時候會發現只關心到這一個層級的事情在某些業務情況下搞不定,比如元件之間的關係、通訊、可擴充套件性、複用的粒度、介面的友好度等問題,所以需要在元件上進行進一步的延伸,擴充套件一下元件所參考的視角,延伸到元件模組元件系統的概念來指導我們編寫程式碼。

元件級別

概念可能會比較生硬,但是你如果有趣的理解成搭積木的方式可能會更好擴充套件思路一點。

三、資料之於元件

在說元件之前,先來說下資料的事情,因為現在資料對於前端是很重要的,其實這是一個前、後端技術和工作方式演變形成的,以前的資料行為和操作都是後端處理完成之後,前端基本拿到的就是直接可用的 View 展示資料,但是隨著後端服務化,需要提供給多個端的資料以及前後端分離工作模式的形成,前端就變得越來越複雜了,其實 SPA 的形成也跟這些有一定關係,一是體驗可能對於使用者好,二是演變決定了這種方式。此時,前端的資料層就需要設計以及複用一些後端在這一層級的成熟模式,在這裡就產生了一種思想的交集。

比如現在有一個 RadioGroup 元件,然後有下面 2 種資料結構可以選擇:

items = [{
    id: 1,
    name: 'A',
    selected: true
}, {
    id: 2,
    name: 'B',
    selected: false
}];
data = {
    selected: 1
    items: [{
        id: 1,
        name: 'A'
    }, {
        id: 2,
        name: 'B'
    }]
};

那麼我們的元件描述(JSX)會怎麼寫呢?
第一種:

items.map(item =>
    return <CheckBox key={`checkbox-${item.id}`}
                label={item.name}
                selected={item.selected}
                onClick={this.handleClick} />
);

第二種:

data.items.map(item => 
    const isSelected = item.id === data.selected;
    
    return <Checkbox key={`checkbox-${item.id}`
                label={item.name}
                selected={isSelected}
                onClick={this.handleClick}/>
);

當然,資料結構的選擇上是根據需求,因為不同的資料結構有不同的優勢,比如這裡第二種類似 Dict 的查詢很方便,資料也很乾淨,第一種渲染是比較直接的,但是要理解元件的編寫方式其實很大程度上會跟資料產生一種關係,有時候編寫發現問題可以返過來思考是否換種結構就變簡單了。

資料就談這些吧,不然都能單獨開話題了,接下來看下元件,如果要學習模式就需要採集樣本然後去學習與總結,這裡我們來看下 Android && iOS 中的元件長什麼樣子,然後看是否能給我們日常編寫 Web 元件提供點靈感,篇幅有限,本來是應該先看下 GUI 的方式。

四、iOS 端的元件概覽

假設,先摒棄到 Web 元件的形態比其他端豐富,如果不假設那麼這套估計不是那麼適用。

4.1 iOS

iOS 的 View 宣告能夠通過一個故事板的方式,特別爽,比如這裡給按鈕的狀態設定高亮、選中、失效這種,方便得很。

iOS

看完介面,直接的感覺下,然後我們來看下這個故事板的原始碼,上面是 XML 的描述,描述了元件的 View 有哪些部件以及 ViewController 裡面對映的屬性,用來將 View 和 ViewController 進行解耦。

<!-- 結構描述 -->
<scenes>
  <scene sceneID="tne-QT-ifu">
    <objects>
      <viewController title=“Login" customClass="ViewController">
        ...
        <view key="view" contentMode="scaleToFill"></view>
        ...
        <!-- 這裡就是描述 vm 關聯物件的地方,ios 裡面可能稱之為 outlet -->
        <connections>
          <outlet property="passwordTextField"/>
          <outlet property="tipValidLabel"/>
        </connections>
      </viewController>
    </objects>
  </scene>
</scenes>

<!-- 狀態 & 樣式描述 -->
<!-- 單獨一個 button 元件描述 -->
<button>
  <state key="normal" title="Login">
      <color key="titleColor" red="1" green="1" blue="1" alpha="1"/>
  </state>
</button>

我這裡定義的按鈕狀態、顏色都在這裡,分別給他們命名:結構描述樣式描述

那麼具體怎麼給使用者互動,比較程式設計化的東西在 ViewController,來看下程式碼:

// 資料行為描述
// connection 中關聯的鉤子
@IBOutlet private weak var passwordTextField: UITextField!
@IBOutlet private weak var tipValidLabel: UILabel!

// 一個密碼輸入框的驗證邏輯,最後繫結給 tipValidLabel、loginButton 元件狀態上
let passwordValid: Observable<Bool> = passwordTextField.rx.text.orEmpty
    .map { newPassword in newPassword.characters.count > 5 }

passwordValid
    .bind(to: tipValidLabel.rx.isHidden)
    .disposed(by: disposeBag)

passwordValid
    .bind(to: loginButton.rx.isEnabled)
    .disposed(by: disposeBag)

上面程式碼整體可以看做是響應式的物件,繫結3個元件之間的互動,密碼不為空以及大於5個字元就執行 bind 地方,主要是同步另外2個元件的狀態。其實也不需要看懂程式碼,這只是為了體會客戶端元件的方式的例子,ViewController 我這裡就叫:資料行為描述。這樣就有元件最基本的三個描述了:結構、樣式、資料行為,雖然樣本不多,但是這裡直接描述它們就是一個元件的基本要素,整個故事板和 swift 程式碼很好的描述。

五、什麼是元件?

5.1 元件描述
  1. 結構描述
  2. 樣式描述
  3. 資料描述

對於元件來說,也是一份程式碼的集合,基本組成要素還是需要的,但是這三種要素存在和以前的 HTML, CSS, JS 三種資源的分離是不一樣的,到了元件開發,更多的是關注如何將這些要素連線起來,形成我們需要的元件。

比如 React 中對這三要素的描述用一個 .js 檔案全部描述或者將結構、資料包裹在一起,樣式描述分離成 .<style> 檔案,這裡就可能會形成下面 2 種形式的元件編寫。

=> 3 -> (JSX + styled-components)

// 元件樣式
const Title = styled.h1`
    font-size: 1.5em;
    text-align: center;
`;

// 元件內容
<Title>Hello World!</Title>

=> 2 + 1 -> (JSX + CSS Module)

export default function Button(props) {
    // 分離的樣式,通過結構化 className 來實現連線
    const buttonClass = getClassName(['lv-button', 'primary']);

    return (
        <button onClick={props.onClick} className={buttonClass}>
            {props.children}
        </button>
    );
}

可能最開始很多不習慣這樣寫,或者說不接受這類理念,那麼再看下 Angular 的實現方式,也有 2 種:

(1) 採用後設資料來裝飾一個元件行為,然後樣式和結構能夠通過匯入的方式連線具體實現檔案。

@Component({
    selector: 'app-root',
    // 結構模板
    templateUrl: './app.component.html',
    // 樣式模板
    styleUrls: ['./app.component.css']
})
// 等同於上面描述的 iOS 元件的 ViewController
export class AppComponent { }

(2) 與第一種方式不同的地方是能夠直接將結構和樣式寫到後設資料中。

@Component({
    selector: 'app-root',
    template: `
        <style>
            h1 { font-weight: normal; }
        </style>
        
        <h1>{{title}}</h1>
        <ul>
            <li *ngFor="let item of items">{{ item }}</li>
        </ul>
    `,
    // styles: ['h1 { font-weight: normal; }']
})
export class AppComponent {
    title = 'Hello Angular';
    items: number[] = [1, 2, 3];
}

無論實現的形式如何,其實基本不會影響太多寫程式碼的邏輯,樣式是目前前端工程化的難點和麻煩點,所以適合自己思維習慣即可。這裡需要理解的是學習一門以元件為核心的技術,都能夠先找到要素進行理解和學習,構造最簡單的部分。

5.2 元件特性

雖然有了描述一個元件的基本要素,但是還遠不足以讓我們開發一箇中大型應用,需要關注其他更多的點。這裡提取元件基本都有的特性:

1. 註冊元件

將元件拖到故事板

2. 元件介面(略)

別人家的程式碼能夠修改元件的部分

3. 元件自屬性

元件建立之初,就有的一些固定屬性

4. 元件生命週期

元件存在到消失如何控制以及資源的整合

5. 元件 Zone

元件存在於什麼空間下,或者說是上下文,很可能會影響到設計的介面和作用範圍,比如 React.js 可用於寫瀏覽器中的應用,React Native 可以用來寫類似原生的 App,在設計上大多數能雷同,但是平臺的特殊地方也許就會出現對應的程式碼措施)

這些主要就是拿來幫助去看一門不懂的技術的時候,只要是元件的範圍,就先看看有沒有這些東西的概念能不能聯想幫助理解。

具體來看下程式碼是如何來落地這些模式的。

1.元件註冊,其實註冊就是讓程式碼識別你寫的元件

(1) 宣告即定義,匯入即註冊

export SomeOneComponent {};
import {SomeOneComponent} from 'SomeOneComponent';

(2) 直接了當的體現註冊的模式

AppRegistry.registerComponent('ReactNativeApp', () => SomeComponent);

(3) 擁有模組來劃分元件,以模組為單位啟動元件

@NgModule({
    // 宣告要用的元件
    declarations: [
        AppComponent,
        TabComponent,
        TableComponent
    ],
    // 匯入需要的元件模組
    imports: [
        BrowserModule,
        HttpModule
    ],
    providers: [],
    // 啟動元件, 每種平臺的啟動方式可能不一樣
    bootstrap: [AppComponent]
})
export class AppModule { }

2.元件的自屬性

比如 Button 元件,在平時場景下使用基本需要繫結一些自身標記的屬性,這些屬效能夠認為是一個 Component Model 所應該擁有,下面用虛擬碼進行描述。

// 將使用者的 touch, click 等行為都抽象成 pointer 的操作
~PointerOperateModel {
    selected: boolean;
    disabled: boolean;
    highlighted: boolean;
    active: boolean;
}

ButtonModel extends PointerOperateModel { }
LinkModel extends PointerOperateModel { }
TabModel extends PointerOperateModel { }
...

// 或者是具有對立的操作模型
~ToggleModel {
    on: boolean;
}

OnOffModel extends ToggleModel { }
SwitchModel extends ToggleModel { }
MenuModel extends ToggleModel { }

...

// 元件的使用
this.ref.attribute = value;
this.ref.attribute = !value;

這些操作如果需要更少的程式碼,也許能夠這樣:

~ObserverState<T> {
    set: (value: T) => void;
    get: (value: T) => T;
    changed: () => void;
    cacheQueue: Map<string, T>;
    private ___observe: Observe;
}

Model extends ObserverState { }

基本上元件的這些屬性是遍佈在我們整個程式碼開發過程中,所以是很重要的點。這裡還有一個比較重要的思考,那就是表單的模型,這裡不擴充套件開來,可以單獨立一篇文章分析。

3.元件的宣告週期

與其說是生命週期,更多的是落地時候的程式碼鉤子,因為我們要讓元件與資料進行連線,也許需要在特定的時候去操作一份資料。在瀏覽器(宿主)中,要知道具體是否已經可用是一個關鍵的點,所以任何在這個平臺的元件都會有這類週期,如果沒有的話用的時候就會很蛋疼。

最簡單的路線是:

mounted => update => destory

但是往往實際專案會至少加一個東西,那就是異常,所以就能夠開分支了,但是更清晰的應該是平行的週期方式。

mounted => is error => update => destory

4.元件 Zone

元件在不同的 Zone 下可能會呈現不同的狀態,這基本上是受外界影響的,然後自己做出反應。這裡可以針對最基本的元件使用場景舉例,但是這個 Zone 是一種泛化概念。

比如我們要開發一個彈框元件:Modal,先只考慮一個最基本需求:彈框的位置,這個彈框到底掛載到哪兒?

  1. 掛載到元件內部;
  2. 掛載到最近的容器節點下;
  3. 掛載到更上層的容器,以至於 DOM 基礎節點。

每一種場景下的彈框,對於每種元件的方案影響是不同的:

  1. 元件內部,如果元件產生了 render,很可能受到影響;
  2. 掛載到最近的容器元件,看似問題不大,但是業務元件的拆、合是不定的,對於不定的需求很可能程式碼會改變,但是這種方案是不錯的,不用寫太遠,當然在 React 16 有了新的方案;
  3. 掛載到更高的層級,這種方案適合專案對彈框需求依賴比較強的情況吧,因為受到的影響更小,彈框其實對於前端更強調的是一種渲染或者說是一種互動。

5.元件的遞迴特性

元件能夠擁有遞迴是一個很重要的縱向擴充套件的特性,每一種庫或者框架都會支援,就要看支援對於開發的自然度,比如:

// React
this.props.children

// Angular
<ng-content></ng-content>
基本上可以認為現在面向元件的開發是更加貼近追求的設計即實現的理想,因為這是物件導向方法論不容易具備的,元件是一種更高抽象的方法,一個元件也許會有物件分析的插入,但是對外的表現是元件,一切皆元件後經過積累,這將大大提升開發的效率。

六、如何設計元件?

經過前面的描述,知道了元件的概念和簡單元件的編寫方法,但是掌握了這些東西在實際專案中還是容易陷入蛋痛的地步,因為元件只是組成一個元件模組的基礎單元,慢慢的開發程式碼的過程中,我們需要良好的去組織這些元件讓我們的模組即實現效果的同時也擁有一定的魯棒性和可擴充套件性。這裡將元件的設計方法分為 2 個打點:

  1. 橫向分類
  2. 縱向分層

其實這種思路是一直以來都有的,這裡套用到平時自己的元件設計過程中,讓它幫助我們更容易去設計元件。

元件設計

這種設計的方法論是一個比較容易掌握和把握的,因為它的模型是一個二維的(x, y)兩個方向去拆、合自己的元件。注意,這裡基本上的程式碼操作單元是元件,因為這裡我們要組裝的目標是模組^0^感覺很好玩的樣子,舉例來描述一下。

比如我們現在來設計比較常用的下拉選單元件(DropDownList),最簡單的有如下做法:

class DropDownList {
    render() {
        return (
            <div>
                <div>
                    <Button onClick={this.handleClick}>請選擇</Button>
                </div>
                <DropDownItems>
                {this.props.dataSource.map((itemData, index) => <DropDownItem></DropDownItem>)}
                </DropDownItems>
            </div>
        );
    }
}

現在自己玩的往上加點需求,現在我需要加一個列表前面都加一個統一的 icon, 首先我們要做的肯定是要有一個 Icon 的元件,這個設計也比較依賴場景,目前我們先設計下拉。現在就有2種方案:

  1. 在 DropDownList 元件裡面加一個判斷,動態加一個元件就行;
  2. 重新寫一個元件叫 DropDownIconList。

第一種方案比較省事,但是其實寫個 if...else... 算是一個邏輯分支的程式碼,以後萬一要加一個 CheckBox 或者 Radio 元件在前面...

第二種方案看上去美好,但是容易出現程式碼變多的情況,這時候就需要再重新分析需求變化以及變化的趨勢。

這時候按垂直和水平功能上,這裡拆分 DropDownIconList 元件可以看成一個水平的劃分,從垂直的情況來看,將下拉這一個行為做成一個元件叫 DropDown,最後就變成了下面的樣子:

class DropDown  {
    render() {
        <div>
            <div>
                <p onClick={this.handleClick}>請選擇</p>
            </div>
            <div>{this.props.children}</div>
        </div>
    }
}

class DropDownList {
    render() {
        return (
            <DropDown onClick={this.handleClick} selected={selectedItems}>
                <DropDownItems>
                    {this.props.dataSource.map((itemData, index) => <DropDownItem></DropDownItem>)}
                </DropDownItems>
            </DropDown>
        );
    }
}

class DropDownIconList {
    render() {
        return (
            <DropDown onClick={this.handleClick} selected={selectedItems}>
                <DropDownItems>
                    {this.props.dataSource.map((itemData, index) => <DropDownIconItem></DropDownIconItem>)}
                </DropDownItems>
            </DropDown>
        );
    }
}

這樣的缺點就是存在多個元件,也許會有冗餘程式碼,優點就是以後增加類似元件,不會將程式碼的複雜度都加到一份程式碼中,比如我要再加一個下拉里面分頁、加入選中的項、下拉內容分頁、下拉的無限滾動等等,都是不用影響之前那份程式碼的擴充套件。

七、讓元件連線起來

元件化的開發在結構上是一種分形架構的體現,是一個應用引向有序元件構成的過程。元件系統的複雜度可以理解成 f(x) = g(x) + u(x), g(x) 表示特有功能,u(x)表示功能的交集或者說有一定重合度的集合。元件彈性體現在 u(x) -> 0(趨近)的過程中,這個論點可參考:面向積木(BO)的方法論與分形架構

上面的過程中,有了元件元件模組,既然有了基礎的實體,那麼他們或多或少會有溝通的需求(活的模組)。基本上現在主流的方案可以用下面的圖來表示。

資料流

我們提取一下主要的元素:

  1. Component 實體
  2. Component 實體的集合:Container
  3. Action
  4. Action 的操作流
  5. Service Or Store
  6. 狀態同步中心
  7. Observable Effect

如果要說單向資料流和雙向繫結的體現基本可以理解成體現在虛線框選的位置,如果元件或者Store是一個觀察的模型,那麼方案實現後就很可能往雙向繫結靠近。如果是手動黨連線 ViewValue 和 ModelValue,按照一條流下來可以理解成單向流。雖然沒有按定義完全約束,但是程式碼的落地上會形成這種模式,這塊細講也會是一個單獨的話題,等之後文章再介紹各種模式。

元件的關係能夠體現在包含、組合、繼承、依賴等方面,如果要更好的鬆耦合,一般就體現在配置上,配置就是一種自然的宣告式,這是宣告式的優勢同時也是缺點。

以上是一些對元件的思考,碼字好累,不一定很深入,但是希望能夠幫助到剛踏入元件化前端開發的小夥伴~如果覺得有幫助請幫忙推薦,也可以閱讀原文:小擼的部落格

相關文章