从零开始的 React 组件开发之路 (一):表格篇
React 下的表格狂想曲
0. 前言
歡迎大家閱讀「從零開始的 React 組件開發之路」系列第一篇,表格篇。本系列的特色是從 需求分析、API 設計和代碼設計 三個遞進的過程中,由簡到繁地開發一個 React 組件,并在講解過程中穿插一些 React 組件開發的技巧和心得。
為什么從表格開始呢?在企業系統中,表格是最常見但功能需求最豐富的組件之一,同時也是基于 React 數據驅動的思想受益最多的組件之一,十分具有代表性。這篇文章也是近期南京谷歌開發者大會前端專場的分享總結。UXCore table 組件?Demo?也可以和本文行文思路相契合,可做參考。
1. 一個簡單 React 表格的構造
1.1 需求分析
-
有表頭,每行的展示方式相同,只是數據上有所不同
-
每一列可能有不同的對齊方式,可能有不同的展示類型,比如金額,比如手機號碼等
1.2 API 設計
-
因為每一列的展示類型不同,因此列配置應該作為一個 Prop,由于有多列應該是一個數組
-
數據源應該作為基礎配置之一,應該作為一個 prop,由于有多行也應該是一個數組
-
現在的樣子:<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 作為參數 } -
推薦渲染函數的原因:
-
函數在做屬性比較時,更簡單
-
約定更少,渲染組件的方式需要配合?Table?預留比如?rowData?一類的接口,不夠靈活。
-
-
1.3 代碼設計
圖: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.data?和?this.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 和 jsonpfetchParams: { // 額外的一些參數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 代碼設計
圖:行內編輯模式下的表格架構
-
所有的 CellField 繼承一個基類 Field,這個基類提供通用的與 Table 通信,校驗的方式,具體的 Field 只負責交互部分的實現。
-
下面是這部分設計的具體代碼實現,礙于篇幅,不在文章中直接貼出。
-
https://github.com/uxcore/uxcore-table/blob/master/src/Cell/CellField.js
-
https://github.com/uxcore/uxcore-table/blob/master/src/Cell/SelectField.js
5. 總結
這篇文章以復雜表格組件的開發為切入點,討論了以下內容:
-
組件設計的通用流程
-
組件分層架構與 API 的對應設計
-
組件設計中易用性與通用性的權衡
-
State 和 Props 的正確使用
-
生命周期的實戰應用
-
父子級間組件通信
礙于整體篇幅,有一些和這個組件相關的點未詳細討論,我們會在本系列的后續文章中詳細說明。
-
數據的 不可變性(immutability)
-
shouldComponentUpdate 和 pure render
-
樹形表格 和 數據的遞歸處理
-
在目前架構上進行折疊面板的擴展
總結
以上是生活随笔為你收集整理的从零开始的 React 组件开发之路 (一):表格篇的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: React 应用的性能优化思路
- 下一篇: 消息队列设计的精髓基本都藏在本文里了