從零開始的 React 組件開發之路 (一):表格篇

React 下的表格狂想曲

0. 前言

歡迎你們閱讀「從零開始的 React 組件開發之路」系列第一篇,表格篇。本系列的特點是從 需求分析、API 設計和代碼設計 三個遞進的過程當中,由簡到繁地開發一個 React 組件,並在講解過程當中穿插一些 React 組件開發的技巧和心得。 前端

爲何從表格開始呢?在企業系統中,表格是最多見但功能需求最豐富的組件之一,同時也是基於 React 數據驅動的思想受益最多的組件之一,十分具備表明性。這篇文章也是近期南京谷歌開發者大會前端專場的分享總結。UXCore table 組件 Demo 也能夠和本文行文思路相契合,可作參考。git

1. 一個簡單 React 表格的構造

1.1 需求分析

  • 有表頭,每行的展現方式相同,只是數據上有所不一樣github

  • 每一列可能有不一樣的對齊方式,可能有不一樣的展現類型,好比金額,好比手機號碼等ajax

1.2 API 設計

  • 由於每一列的展現類型不一樣,所以列配置應該做爲一個 Prop,因爲有多列應該是一個數組json

  • 數據源應該做爲基礎配置之一,應該做爲一個 prop,因爲有多行也應該是一個數組segmentfault

  • 如今的樣子:<Table columns={[]} data={[]} />數組

  • 基本思路是經過遍歷列配置來生成每一行瀏覽器

  • data 中的每個元素應該是一行的數據,是一個 hash 對象。數據結構

    {
        city: '北京',
        name: '小李'
    }
  • columns 中的每個元素是一列的配置,也是一個 hash 對象,至少應該包括以下幾部分:架構

    {
        title: '表頭',
        dataKey: 'city', // 該列使用行中的哪一個 key 進行顯示
    }
  • 易用性與通用性的平衡

    • 易用性與通用性互相制衡,但並非絕對矛盾。

    • 何爲易用?使用盡可能少的配置來完成最典型的場景。

    • 何爲通用?提供儘可能多的定製接口已適應各類不一樣場景。

    • 在 API 設計上儘可能開放保證通用性

    • 在默認值上提煉最典型的場景提升易用性。

    • 從易用性角度出發

    {
        align: 'left', // 默認左對齊
        type: 'money/action', // 提供 'money', 'card', 'cnmobile' 等經常使用格式化形式
        delimiter: ',', // 格式化時的分隔符,默認是空格
        actions: { // 表格中常見的操做列,不以數據進行渲染,只包含動做,hash 對象使配置最簡化
          "編輯": function() {doEdit();}
        }, 
    }
    • 從通用性角度出發

    {
        actions: [ // 相對繁瑣,但定製能力更強
          {
              title: '編輯',
              callback: function() {doEdit();},
              render: function(rowData) {
                  // 根據當前行數據,決定是否渲染,及渲染成定製的樣子
              }
          }
        ],
        render: function(cellData, rowData) {
            // 根據當前行數據,徹底由用戶決定如何渲染
            return <span>{`${rowData.city} - ${rowData.name}`}</span>
        }
    }
    • 提供定製化渲染的兩種方式:

      • 渲染函數 (更推薦)

      {
          render: function(rowData) {
              return <CustomComp url={rowData.url} />
          },
      }
      • 渲染組件

      {
          renderComp: <CustomComp />, // 內部接收 rowData 做爲參數
      }
      • 推薦渲染函數的緣由:

        1. 函數在作屬性比較時,更簡單

        2. 約定更少,渲染組件的方式須要配合 Table 預留好比 rowData 一類的接口,不夠靈活。

1.3 代碼設計

Table 分層

圖:Table 的分層設計

table 簡單架構

圖:最初的 Table 結構,詳細的分層爲後續的功能擴展作好準備。

2. 加入更多的內置功能

目前的表格能夠知足咱們的最簡單經常使用的場景,但仍然有不少常常須要使用的功能沒有支持,如列排序,分頁,搜索過濾、經常使用動做條、行選擇和行篩選等。

2.1 需求分析

  • 列排序:升序/降序/默認順序 Head/Cell 相關

  • 分頁:當表格須要展現的條數不少時,分頁展現固定的條數 Table/Pagination 相關,這裏假設已有 Pagination 組件

  • 搜索過濾:Table 相關

  • 經常使用操做:Table 相關

  • 行選擇:選中某些行,Row/Cell 相關

  • 行篩選:手動展現或者隱藏一些行,不屬於任何一列,所以是 Table 級

2.2 API 設計

根據上面對於功能的需求分析,咱們很容易定位 API 的位置,完成相應的擴展。

// table 配置,需求對應的模塊對應了他的配置在整個配置中的位置
{
    columns: [ // HEAD/ROW 相關
        {
            order: true, // 是否展現排序按鈕
            hidden: false, // 是否隱藏,行篩選須要
        }
    ],
    onOrder: function (activeColumn, order) { // 排序時的回調
        doOrder(activeColumn, order)
    }, 
    actionBar: { // 經常使用操做條
        "打印": function() {doPrint()}, 
    },
    showSeach: true, // 是否顯示搜索過濾,爲何不直接用下面的,這裏也是設計上的一個優化點
    onSearch: function(keyword) { doSearch(keyword) }, // 搜索時的回調
    showPager: true, // 是否顯示分頁
    onPagerChange: function(current, pageSize) {}, // 分頁改變時的回調
    rowSelection: { // 行選擇相關
        onSelect: function(isSelected, currentRow, selectedRows) { 
            doSelect() 
        }
    }
}
// data 結構
{
    data: [{
        city: 'xxx',
        name: 'xxx',
        __selected__: true, // 行選擇相關,用以標記該行是否被選中,用先後的 __ 來作特殊標記,另外一方面也儘量避免與用戶的字段重複
    }],
    currentPage: 1, // 當前頁數
    totalCount: 50, // 總條數
}

2.3 代碼設計

結構圖

圖:擴展後的 Table 結構

內部數據的處理

目前組件的數據流向還比較簡單,咱們彷佛能夠所有經過 props 來控制狀態,製做一個 stateless 的組件。

什麼時候該用 state?什麼時候該用 props?

UI=fn(state, props), 人們常說 React 組件是一個狀態機,但咱們應該清楚的是他是由 state 和 props 構成的雙狀態機;

props 和 state 的改變都會觸發組件的從新渲染,那麼咱們使用它們的時機分別是什麼呢?因爲 state 是組件自身維護的,並不與他的父級組件進行溝通,進而也沒法與他的兄弟組件進行溝通,所以咱們應該儘可能只在頁面的根節點組件或者複雜組件的根節點組件使用 state,而在其餘狀況下儘可能只使用 props,這能夠加強整個 React 項目的可預知性和可控性。

但凡事不是絕對的,全都使用 Props 當然可使組件可維護性變強,但所有交給用戶來操做會使用戶的使用成本大大提升,利用 state,咱們可讓組件本身維護一些狀態,從而減輕用戶使用的負擔。

咱們舉個簡單的例子

{/* 受控模式 */}
<input value="a" onChange={ function() {doChange()} } />
{/* 非受控模式 */}
<input onChange={ function() {doChange()} } />

value 配置時,input 的值由 value 控制,value 沒有配置時,input 的值由本身控制,若是把 <input /> 看作一個組件,那麼此時能夠認爲 input 此時有一個 state 是 value。顯然,無 value 狀態下的配置更少,下降了使用的成本,咱們在作組件時也能夠參考這種模式。

例如在咱們但願爲用戶提供 行選擇 的功能時,用戶一般是不但願本身去控制行的變化的,而只是關心行的變化時應該拿取的數據,此時咱們就能夠將 data 這個 prop 變成 state。有一點須要注意的是,用戶的 prop

class Table extends React.Component {
    constructor(props) {
        super(props);
        this.data = deepcopy(props.data);
        this.state = {
            data: this.data,
        };
    }
    
    /**
     * 在 data 發生改變時,更改對應的 state 值。
     */
    componentWillReceiveProps(nextProps, nextState) {
        if (!deepEqual(nextProps.data, this.data) {
            this.data = deepcopy(nextProps.data);
            this.setState({
                data: this.data,
            });
        }
    }
}

這裏涉及的一個很重要的點,就是如何處理一個複雜類型數據的 prop 做爲 state。由於 JS 對象傳地址的特性,若是咱們直接對比 nextProps.datathis.props.data 有些狀況下會永遠相等(當用戶直接修改 data 的狀況下),因此咱們須要對這個 prop 作一個備份。

生命週期的使用時機

圖:React 的生命週期

  • constructor: 儘可能簡潔,只作最基本的 state 初始化

  • willMount: 一些內部使用變量的初始化

  • render: 觸發很是頻繁,儘可能只作渲染相關的事情。

  • didMount: 一些不影響初始化的操做應該在這裏完成,好比根據瀏覽器不一樣進行操做,獲取數據,監聽 document 事件等(server render)。

  • willUnmount: 銷燬操做,銷燬計時器,銷燬本身的事件監聽等。

  • willReceiveProps: 當有 prop 作 state 時,監聽 prop 的變化去改變 state,在這個生命週期裏 setState 不會觸發兩次渲染。

  • shouldComponentUpdate: 手動判斷組件是否應該更新,避免由於頁面更新形成的無謂更新,組件的重要優化點之一。

  • willUpdate: 在 state 變化後若是須要修改一些變量,能夠在這裏執行。

  • didUpdate: 與 didMount 相似,進行一些不影響到 render 的操做,update 相關的生命週期裏最好不要作 setState 操做,不然容易形成死循環。

父子級組件間的通訊

父級向子級通訊不用多說,使用 prop 進行傳遞,那麼子級向父級通訊呢?有人會說,靠回調啊~ onChange等等,本質上是沒有錯誤的,但當組件比較複雜,存在多級結構時,若是每一級都去處理他的子級的回調的話,不只寫起來很是麻煩,並且不少時候是沒有意義的。

咱們採起的辦法是,只在頂級組件也就是 Table 這一層控制全部的 state,其餘的各個子層都是徹底由 prop 來控制,這樣一來,咱們只須要 Table 去操做數據,那麼咱們逐級向下傳遞一個屬於 Table 的回調函數,完成全部子級都只向 Table 作「彙報」,進行跨級通訊。

圖:父子級間的通訊

3. 自行獲取數據

3.1 需求分析

做爲一個儘量爲用戶提升效率的組件,除了手動傳入 data 外,咱們也應該有自行獲取數據的能力,用戶只須要配置 url 和相應的參數就能夠完成表格的配置,爲此咱們可能須要如下參數:

  • 數據源,返回的數據格式應和咱們以前定義的 data 數據結構一致。 (易用)

  • 隨請求一塊兒發出去的參數。(通用)

  • 在發請求前的回調,能夠在這裏調整發送的參數。(通用)

  • 請求回來後的回調,能夠在這裏調整數據結構以知足對 data 的要求。(通用)

  • 同時要考慮到內置功能的適配。(易用)

3.2 API 設計

// table 配置,需求對應的模塊對應了他的配置在整個配置中的位置
{
    url: "//fetchurl.com/data", // 數據源,只支持 json 和 jsonp
    fetchParams: { // 額外的一些參數
        token: "xxxabxc_sa"
    },
    beforeFetch: function(data, from) { // data 爲要發送的參數,from 參數用來區分發起 fetch 的來源(分頁,排序,搜索仍是其餘位置)
        return data; // 返回值爲真正發送的參數
    },
    afterFetch: function(result) { // result 爲請求回來的數據
        return process(result); // 返回值爲真正交給 table 進行展現的數據。
    },
}

3.3 代碼設計

基於前面良好的通訊模式,url 的擴展變得很是簡單,只須要在全部的回調中加入是否配置 url 的判斷便可。

class Table extends React.Component {
    constructor(props) {
        super(props);
        this.data = deepcopy(props.data);
        this.fetchParams = deepcopy(props.fetchParams);
        this.state = {
            data: this.data,
        };
    }
    
    /**
     * 獲取數據的方法
     */
    fetchData(props, from) {
        props = props || this.props;
        const otherParams = process(this.state);
        ajax(props.url, this.fetchParams, otherParams, from);
    }
    
    /**
     * 搜索時的回調
     */
    handleSearch(key) {
        if (this.props.url) {
            this.setState({
                searchKey: key,
            }, () => {
                this.fetchData();
            });
        } else {
            this.props.onSearch(key);
        }
        
    }
    
    componentDidMount() {
        if (this.props.url) {
            this.fetchData();
        }
    }

    componentWillReceiveProps(nextProps, nextState) {
        let newState = {};
        if (!deepEqual(nextProps.data, this.data) {
            this.data = deepcopy(nextProps.data);
            newState['data'] = this.data; 
        }
        if (!deepEqual(nextProps.fetchParams, this.fetchParams)) {
            this.fetchParams = deepcopy(nextProps.fetchParams);
            this.fetchData();
        }
        if (nextProps.url !== this.props.url) {
            this.fetchData(nextProps);
        }
        if (Object.keys(newState) !== 0) {
            this.setState(newState);
        }
    }
}

4. 行內編輯

4.1 需求分析

經過雙擊或者點擊編輯按鈕,實現行內可編輯狀態的切換。若是隻是變成普通的文本框那就太 low 了,有追求的咱們但願每一個列根據數據類型能夠有不一樣的編輯形式。既然是可編輯的,那麼關於表單的一套東西都適用,他要能夠驗證,能夠重置,也能夠聯動。

4.2 API 設計

// table 配置,需求對應的模塊對應了他的配置在整個配置中的位置,顯然行內編輯是和列相關的
{
    columns: [ // HEAD/ROW 相關
        {   
            dataKey: 'cityName', // 展現時操做的變量
            editKey: 'cityValue', // 編輯時操做的變量
            customField: SelectField, // 編輯狀態的類型
            config: {}, // 編輯狀態的一些配置
            renderChildren: function() {
                return [
                {id: 'bj', name: '北京'},
                {id: 'hz', name: '杭州'}].map((item) => {
                    return <Option key={item.id}>{item.name}</Option>
                });
            },
            rules: function(value) { // 校驗相關
                return true;
            }
        }
    ],
    onChange: function(result) {
        doSth(result); // result 包括 {data: 表格的全部數據, changedData: 變更行的數據, dataKey: xxx, editKey: xxx, pass: 正在編輯的域是否經過校驗}
    }
}
// data 結構
{
    data: [{
        cityName: 'xxx',
        cityValue: 'yyy',
        name: 'xxx',
        __selected__: true, 
        __mode__: "edit", // 用來區分當前行的狀態
    }],
    currentPage: 1, // 當前頁數
    totalCount: 50, // 總條數
}

4.3 代碼設計

table edit mode

圖:行內編輯模式下的表格架構

  • 全部的 CellField 繼承一個基類 Field,這個基類提供通用的與 Table 通訊,校驗的方式,具體的 Field 只負責交互部分的實現。

  • 下面是這部分設計的具體代碼實現,礙於篇幅,不在文章中直接貼出。

  • https://github.com/uxcore/uxc...

  • https://github.com/uxcore/uxc...

5. 總結

這篇文章以複雜表格組件的開發爲切入點,討論瞭如下內容:

  • 組件設計的通用流程

  • 組件分層架構與 API 的對應設計

  • 組件設計中易用性與通用性的權衡

  • State 和 Props 的正確使用

  • 生命週期的實戰應用

  • 父子級間組件通訊

礙於總體篇幅,有一些和這個組件相關的點未詳細討論,咱們會在本系列的後續文章中詳細說明。

  • 數據的 不可變性(immutability)

  • shouldComponentUpdate 和 pure render

  • 樹形表格 和 數據的遞歸處理

  • 在目前架構上進行摺疊面板的擴展

最後

慣例地來宣傳一下團隊開源的 React PC 組件庫 UXCore ,上面提到的點,在咱們的組件開發工具中都有體現,歡迎你們一塊兒討論,也歡迎在咱們的 SegmentFault 專題下進行提問討論。

uxcore

相關文章
相關標籤/搜索