記一個複雜組件(Filter)的從設計到開發

  • 2019 年 10 月 3 日
  • 筆記

此文前端框架使用 rax,全篇代碼暫未開源(待開源)
原文鏈接地址:Nealyang/PersonalBlog

前言

貌似在面試中,你如果設計一個 react/vue 組件,貌似已經是司空見慣的問題了。本文不是理論片,更多的是自己的一步步思考和實踐。文中會有很多筆者的思考過程,歡迎評論區多多交流和討論。

從需求討論、技術方案探討到編碼、到最終的測試,經歷過了很多次的腦暴,也遇到過非常多的坑,其中有可能跟業務有關、也有可能跟框架有關,基於這些坑,又討論了很多解決方案和非常 hack(歪門邪道)的對策。但是隨着時間的推移,再回頭看看當時的 hack 代碼,很多都不太記得為什麼這麼寫了,所以這裡簡單記錄下,Filter 組件的開發過程。以便後面查詢,更希望能大家一起探討,以求得更優質的代碼架構和實現思路。

由於代碼編寫使用基於底層 weex 的 rax 框架,所以有些坑,或許對於正在使用 react 或者 vue 的你並不會遇到,可以直接忽略

說說業務

Filter,已經常見的不可再常見的組件了,顧名思義,就是個篩選過濾器。我們先看看現有 app 上的一些 filter 展現 形式。既然做組件,我們就需要它足夠的通用,足夠的易於擴展。

  • 阿里拍賣的 Filter

paimai

  • 飛豬的 Filter

feizhu

在說 Filter 的業務特徵之前,我們先約束下每一部分的命名,以便於你更好的閱讀此文:

IMAGE

上面分別是拍賣和飛豬的 filter 頁面,從這兩個頁面中,我們大概可以總結出關於 Filter 的一下幾點業務畫像:

  • 隨着頁面滾動,Filter 可能具有吸附能力,但是可能距離頂部存在一定的距離
  • Panel 面板多樣性(點擊navItem 展開的面板)
  • Panel 面板以及 navItem 都可能會有動畫
  • navBar 內容可變
  • panel 面板展示形式不定
  • panel 面板內容可能非常複雜,需要考慮性能優化
  • navBar 上可能存在非 Filter 的內容(關注按鈕)
  • 有的navBar 的 navItem 沒有對應的 panel 面板
  • Filter 上存在影響搜索結果但是沒有影響的」快排「按鈕
  • filter 配置參數能夠指定
  • 通過 url 傳入相關篩選 id 能夠初始化面板選中

最終組件產出

由於 rax 1.0 ts+hooks 開源版本還在開發中,所以倉庫鏈接暫時就不放上了

  • rax-pui-filter-utils : Filter 的內部工具庫,僅供 Filter 開發者提供的工具庫
  • rax-pui-filter-tools:配合使用 Filter 的一些工具集,比如 提高性能的 HOC 組件、佔位符組件等(可用可不用,根據自己業務需求來),思考原由:並不是每一個 Filter 的使用者都需要這些功能,做成可插拔式,為了降低沒必要的 bundle 大小
  • pui-filter:Filter 核心功能開發庫

效果圖:

console 處可見拋出的查詢參數

設計與思考

前端組件架構圖(初版)

組件架構圖(終板)

src  ├─ Filter.js    //Filter 最外層父容器  ├─ constant.js  //項目代碼常量定義  ├─ index.js     //入口文件  ├─ navbar       // navBar 文件夾  │    ├─ NavBase.js    //navBar 基類 NavQuickSearch 和 NavRelatePanel 父類  │    ├─ NavQuickSearch.js   // 快速搜索(無 panel)的 navBar  │    ├─ NavRelatePanel.js   // 帶有 panel 的 navBar  │    └─ index.js  // 導出文件  ├─ panel  │    └─ index.js  // panel 面板組件代碼  └─ style.js

組件功能 Feature

  • 篩選頭 UI 可動態配置擴展,支持點擊動畫,提供三種篩選項類型
    • RelatePanel篩選項關聯Panel型,即篩選頭和 Panel 是一對一關係,點擊篩選頭展示 Panel
    • QuickSearch篩選項快速搜索排序型,即篩選頭沒有對應 Panel,點擊篩選頭直接觸發搜索
    • PureUI純 UI佔位類型,即純 UI 放置,不涉及搜索,比如訂閱按鈕場景
  • 篩選面板顯示隱藏統一管理,支持下拉和左滑展示隱藏動畫,統一搜索回調函數
  • Filter 組件在和業務面板隔離,支持任意組件接入,業務組件里搜索變更通過 onChange(params)回調函數來觸發
  • 提供了三種業務通用的面板組件
    • rax-pui-list-select,列表選擇業務面板
    • rax-pui-location-select,省市區級聯選擇業務面板
    • rax-pui-multi-selection-panel,多選業務面板,查看組件使用文檔

這裡指的是 Filter 的功能 Feature,跟上文提及的 Filter 組件功能可能並不能完全覆蓋,但是我們提供解決方案,組件的設計始終秉持着不侵入業務的原則,所有與業務相關均給予配置入口。

期望組件使用形式

 import Filter from 'rax-pui-filter';      render(      <Filter      navConfig={[]}      onChange={()=>{}}>        <Filter.Panel>            <業務組件1 />        </Filter.Panel>        <Filter.Panel>            <業務組件2 />        </Filter.Panel>      </Filter>    );    

組件功能與業務需求邊界劃分

何為業務功能何為組件功能,這個需要具體的探討,其實也沒有嚴格意義上的區分。說白了,就是你買個手機,他都會送你充電器。但是。。。為什麼很多手機也送手機殼(小米、華為、榮耀)但是 iPhone 卻不送呢?所以到底是不是標配?

對於我們這個組件,簡而言之:我們能做到的,我們都做!但是其中我們還是梳理出某些功能還是數據業務功能:

  • navBar 上每一個 navItem 展示什麼文案、樣式屬於業務功能
  • 整個 Filter 的數據處理,包括 url 上的查詢參數需要拋給對應 navItem要展示的文案也是業務功能
  • Filter 是否點擊滾動到頂部也是業務功能,畢竟很多搜索頁 Filter 本身置頂。而且,對於 rax 而言,不同容器滾動方式還不同(但是我們提供這樣的方法給你去調用)
  • panel 面板裏面數據請求、邏輯處理都是你自己的業務邏輯。Filter 只提供基本的容器能力和接口

換言之,Filter 裏面任何功能都可以說為業務功能。但是我們需要提供 80%業務都需要的功能封裝作為 Filter 的 Future。這就是我們的目的。

根據上面的業務功能和組件功能的區分,我們就知道在使用 Filter 的時候,你應該給我傳遞什麼配置,以及什麼方法。

Filter API

參數 說明 類型 默認值(是否必填)
navConfig 篩選頭配置, 點擊查看詳細配置項

效果圖
undefined

Array<Object> – (必填)
offsetTop Filter組件展開面板狀態下距離頁面頂部的高度,有兩種狀態:固定位置跟隨頁面滾動吸附置頂

固定位置 狀態下距離頁面頂部的高度
跟隨頁面滾動吸附置頂: 狀態下距離頁面頂部的高度

效果圖
undefined

Number 0
styles 配置樣式,Filter中所有樣式都可使用styles集合對象來配置覆蓋
styles 格式
undefined
Object {}
getStickyRef 獲取 Sticky 節點的 ref 實例,用於滾動吸附場景,內部配合 pm-app-plus 容器組件點擊 Filter 時自動吸附置頂

示例圖
undefined

Function
keepHighlight 篩選條件改變後是否需要在篩選頭保持高亮

效果圖
undefined

Boolean false
clickMaskClosable 開啟 mask 背景的點擊隱藏 Boolean true
onChange Filter 搜索變更回調函數
簽名: Function(params:Object,index:Number, urlQuery: Object) => void
參數:
params: Object 搜索參數
index:Number 觸發搜索的 Panel 搜索
urlQuery:Object URL query 對象
Function
onPanelVisibleChange Panel 顯示隱藏回調函數
簽名: Function({ visible:Boolean, triggerIndex:Number, triggerType:String }) => void
參數:
visible:Boolean 顯示隱藏標誌量
triggerIndex:Number觸發的篩選項索引值
triggerType:String 觸發類型

triggerType詳解 包含三種觸發類型
Navbar:來自篩選頭的點擊觸發
Mask:來自背景層的點擊觸發
Panel:來自Panel 的 onChange 回調觸發

Function

Filter prop navConfig 數組配置詳解

篩選項類型 type

  • RelatePanel篩選項關聯Panel型,即篩選頭和 Panel 是一對一關係,點擊篩選頭展示 Panel
  • QuickSearch篩選項快速搜索排序型,即篩選頭沒有對應 Panel,點擊篩選頭直接觸發搜索
  • PureUI純 UI佔位類型,即純 UI 放置,不涉及搜索,比如訂閱按鈕場景

注意 如果 navConfig 內置的UI參數不滿足您的需求,請使用renderItem自定義渲染函數來控制篩選頭 UI

參數 說明 類型 默認值(是否必填)
type 篩選項類型

三種類型
RelatePanel: 篩選項關聯數據面板類型
QuickSearch: 篩選項快速搜索排序類型
PureUI: 純 UI佔位類型

String ‘RelatePanel’
text

注意 RelatePanel類型生效

篩選頭顯示文案
文字溢出用...展示
String – (必填)
icons

注意 RelatePanel類型生效

篩選頭 icon:normal 正常態 和 active 激活態 圖標
數據格式
Object類型 :
undefined
String類型 :
undefined

效果圖
undefined

Object or String
options

注意 QuickSearch類型生效

快速搜索排序類型的數據源
數據格式
undefined
Array (必填)
optionsIndex

注意 QuickSearch類型生效

快速搜索排序類型默認選中的索引 String 0
optionsKey

注意 QuickSearch類型生效

指定快速搜索排序對應的搜索 key,用到 onChange 回調中 String 不提供默認使用當前篩選項的索引
formatText 文案格式化函數
簽名:Function(text:String) => text
參數:
text: String 篩選頭文案
Function (text)=>text
disabled 禁用篩選頭點擊 Boolean true
hasSeperator 是否展示右側分隔符

效果圖
undefined

Boolean false
hasPanel 當前篩選頭是否有對應的 panel Boolean true
renderItem 自定義渲染
注意
提供的配置項無法滿足你的 UI 需求時使用
簽名:Function(isActive:Boolean, this:Element) => Element
參數:
isActive:Boolean 篩選頭是否為激活狀態
this:Element 篩選頭this實例
Function
animation 動畫配置,採用內置的動畫
參數說明
undefined
注意 目前只內置了一種rotate動畫類型
Object
animationHook 用戶自定義動畫的鉤子函數,內置動畫無法滿足需求時使用
簽名:Function(refImg:Element, isActive:Boolean) => text
參數:
refImg:Element 篩選頭圖標的 ref 實例
isActive:Boolean 篩選頭是否為激活狀態
Function

Filter.Panel API

參數 說明 類型 默認值(是否必填)
styles 配置樣式
Filter中所有樣式都可使用styles集合對象來配置覆蓋
Object {}
displayMode Panel 展現形式:全屏、下拉
參數說明
全屏:Fullscreen
下拉:Dropdown
String ‘Dropdown’
noAnimation 禁止動畫 Boolean true
highPerformance 內部通過 Panel 的顯示隱藏控制 panel 的 render 次數,避免不必要的 render,高性能模式下,只會在 Panel 展示 或者 展示隱藏狀態變化時才會重新 render Boolean true
animation Panel 展示動畫配置,內置上下左右動畫
參數說明
undefined
direction 控制動畫方向,分別有 updownleftright
Object

Filter 的代碼使用

  • Filter 的參數配置
  navConfig: [          {            type: 'RelatePanel', // type可以不提供,默認值為'RelatePanel'            text: '向下', // 配置篩選頭文案            icons: {              // 配置 icon,分為正常形態和點擊選中形態              normal: '//gw.alicdn.com/tfs/TB1a7BSeY9YBuNjy0FgXXcxcXXa-27-30.png',              active: '//gw.alicdn.com/tfs/TB1NDpme9CWBuNjy0FhXXb6EVXa-27-30.png',            },            hasSeperator: true, // 展示豎線分隔符            formatText: text => text + '↓', // 篩選文案的格式化函數          },          {            type: 'QuickSearch',            optionsIndex: 0,            optionsKey: 'price',            options: [              // 快速排序列表              {                text: '價格',                icon: '',                value: '0',              },              {                text: '升序',                icon: '//gw.alicdn.com/tfs/TB1PuVHXeL2gK0jSZFmXXc7iXXa-20-20.png',                value: '1',              },              {                text: '降序',                icon: '//gw.alicdn.com/tfs/TB1a7BSeY9YBuNjy0FgXXcxcXXa-27-30.png',                value: '2',              },            ],          },          {            type: 'RelatePanel', // type可以不提供,默認值為'RelatePanel'            text: '旋轉',            icons: {              // 配置 icon,分為正常形態和點擊選中形態              normal: '//gw.alicdn.com/tfs/TB1PuVHXeL2gK0jSZFmXXc7iXXa-20-20.png',              active: '//gw.alicdn.com/tfs/TB1l4lIXhv1gK0jSZFFXXb0sXXa-20-20.png',            },            animation: { type: 'rotate' }, // 配置動畫點擊後旋轉圖片,默認沒有動畫          },          {            type: 'RelatePanel', // type可以不提供,默認值為'RelatePanel'            text: '向左',          },          {            type: 'PureUI',            text: '訂閱',            renderItem: () => {              // 渲染自定義的 UI              return (                <Image                  style={{                    width: 120,                    height: 92,                  }}                  source={{ uri: 'https://gw.alicdn.com/tfs/TB1eubQakL0gK0jSZFAXXcA9pXa-60-45.png' }}                />              );            },          },        ]            // ...            <Filter                offsetTop={100} // offsetTop = RecycleView上面的組件的高度,當前為 100                navConfig={this.state.navConfig} // Filter Navbar 配置項                keepHighlight={true} // 保持變更的高亮                styles={styles} // 配置覆蓋內置樣式,大樣式對象集合                onChange={this.handleSearchChange}                // Panel 面板顯示隱藏變更事件                onPanelVisibleChange={this.handlePanelVisibleChange}>                <Panel highPerformance={true}>                  <ListSelect {...this.state.data1} />                </Panel>                <Panel>                  <LocationSelect {...this.state.data2} />                </Panel>                <Panel                  displayMode={'Fullscreen'} // 配置 Panel 全屏展示,默認為下拉展示                  animation={{                    // 動畫配置                    timingFunction: 'cubic-bezier(0.22, 0.61, 0.36, 1)',                    duration: 200,                    direction: 'left', // 動畫方向:從右往左方向滑出                  }}>                  <MultiSelect {...this.state.data3} />                </Panel>              </Filter>

代碼運行效果圖如上截圖。下面,簡單說下代碼的實現。

核心源碼展示

開源版本(Ts+hooks+lerna)還未公布,所以目前還是採用 rax 0.x 的版本編寫的代碼。這裡只做,有坑的地方代碼處理講解。歡迎各位大佬評論留出各位想法

Filter.js

先從 render 方法看起

  render() {      const { style = {}, styles = {}, navConfig, keepHighlight } = this.props;      const { windowHeight, activeIndex } = this.state;      if (!windowHeight) return null;        return (        <View style={[defaultStyle.container, styles.container, style]}>          {this.renderPanels()}          <Navbar            ref={r => {              this.refNavbar = r;            }}            navConfig={navConfig}            styles={styles}            keepHighlight={keepHighlight}            activeIndex={activeIndex}            onNavbarPress={this.handleNavbarPress}            onChange={this.handleSearchChange}          />        </View>      );    }

獲取一些基本配置,以及 windowHeight(屏幕高度)和 activeIndex(當前第幾個item 處於 active 狀態(被點開))。

之所以我們的 renderPanels 寫在 NavBar 上面,是因為在 weex 中,zIndex 是不生效的。若想 A 元素在 B 元素上面,則 render 的時候,A 必須在 B 後面。這樣寫是為了 panel 面板展開的下拉動畫,看起來是從 navBar 下面出來的。

renderPanel 方法就是渲染對應的 panel

  /**     * 渲染 Panel     */    renderPanels = () => {      const { activeIndex, windowHeight } = this.state;      let { children } = this.props;        if (!Array.isArray(children)) {        children = [children];      }        let index = 0;      return children.map(child => {        let panelChild = null;        let hasPanel = this.panelIndexes[index];        if (!hasPanel) {          index++;        }        if (!this.panelManager[index]) {          this.panelManager[index] = {};        }        let injectProps = {          index,          visible: activeIndex === index,          windowHeight,          filterBarHeight: this.filterBarHeight,          maxHeight: this.filterPanelMaxHeight,          shouldInitialRender: this.panelManager[index].shouldInitialRender,          onChange: this.handleSearchChange.bind(this, index),          onNavTextChange: this.handleNavTextChange.bind(this, index),          onHidePanel: this.setPanelVisible.bind(this, false, index),          onMaskClick: this.handleMaskClick,          disableNavbarClick: this.disableNavbarClick,        };        if (child.type !== Panel) {          panelChild = <Panel {...injectProps}>{child}</Panel>;        } else {          panelChild = cloneElement(child, injectProps);        }        index++;        return panelChild;      });    };

準確的說,這是一個 HOC,我們將代理、翻譯傳給 Filter 的影響或者 panel 面板需要使用的 props 傳遞給 Panel 面板。比如 onChange 回調,或者面板隱藏的回調以及當前哪一個 panel 需要展開等。

由於 Panel 的面板複雜度我們未知。為了避免不斷的展開和收齊不必要的 render,我們採用 transform的方式,將面板不需要顯示的面板移除屏幕外,需要展示的在移入到屏幕內部。具體可見 Panel 的render return

  return (        <View          ref={r => {            this.refPanelContainer = r;          }}          style={[            defaultStyle.panel,            styles.panel,            this.panelContainerStyle,            {              transform: `translateX(-${this.containerTransformDes})`,              opacity: 0,            },          ]}>          <View            ref="mask"            style={[              defaultStyle.mask,              styles.mask,              showStyle,              isWeb ? { top: 0, zIndex: -1 } : { top: 0 },            ]}            onClick={this.handleMaskClick}            onTouchMove={this.handleMaskTouchMove}          />          {cloneElement(child, injectProps)}        </View>      );

注意: Panel 面板的坑遠不止這些,比如,我們都知道,render 是最消耗頁面性能的,而頁面初始化進來,面板名沒有展示出來(此時面板 Panel 在屏幕外),那麼是否需要走 Panel 面板的 render 呢?但是目前的這種寫法,Panel 組件的生命周期是會都走到的。但是如果遇到 Panel 裏面需要請求數據,然後頁面 url 里查詢參數有 locationId=123 ,navItem 需要展示對應的地理位置.如果不渲染 Panel 如何根據 id 拿到對應的地名傳遞給 navItem 去展示?對,我們可以攔截 Panel 面板的 render 方法,讓 Panel render null,然後別的生命周期照樣運行。但是,如果 render 中用戶有對 ref 的使用,那麼就可能會造成難以排查的 bug。

所以最終,為了提高頁面的可交互率但是又不影響頁面需求的情況下,我們提供了一個可選的工具:Performance HOC 。 注意,是可選。

export default function performance(Comp) {    return class Performance extends Comp {      static displayName = `Performance(${Comp.displayName})`;      render() {        const { shouldInitialRender } = this.props.panelAttributes;        if (shouldInitialRender) {          return super.render();        } else {          return <View />;        }      }    };  }

通過配置Panel 的 shouldInitialRender 屬性來告訴我,是否第一次進來,攔截 render。

當然,Panel 也有很多別的坑,比如,現在 Panel 為了重複 render,將 Panel 移除屏幕外,那麼,動畫從上而下展開設置初始動畫閃屏如何處理?

Filter 的代碼就是初始化、format、檢查校驗各種傳參,以及 Panel 和 NavBar 通信中轉 比如 format、比如 handleNavbarPress

核心代碼

從架構圖中大概可以看出,NavBar 中通過不同的配置,展示不同的 NavBarItem 的類型,NavQuickSearch,NavRelatePanel

這裡需要注意的是: NavBar 的數據是通過 Filter props 傳入的,如果狀態放到 Filter 也就是 NavBar 的父組件管理的話,會導致 Panel 組件不必要的渲染(雖然已經提供 Panel 層的 shouldComponentUpdate 的配置參數),同時也是為了組件設計的高內聚、低耦合,我們將傳入的 props 封裝到 NavBar 的 state 中,自己管理狀態。

  constructor(props) {      super(props);      const navConfig = formatNavConfig(props.navConfig);        this.state = {        navConfig,      };    }    // 這裡我們提供內部的 formatNavConfig 方法,具體內容根據不同組件業務需求不同代碼邏輯不同,這裡就不展開說明了    

NavBar 中還需要注意的就是被動更新:Panel 層點擊後,NavBar 上文字的更新,因為這裡我們利用父組件來進行 Panel 和 NavBar 的通信

  //Filter.js 調用 NavBar 的方法      /**     * 更新 Navbar 文案     */    handleNavTextChange = (index, navText, isChange = true) => {      // Navbar 的 render 抽離到內部處理,可以減少一次 Filter.Panel 的額外 render      this.asyncTask(() => {        this.refNavbar.updateOptions(index, navText, isChange);      });    };      //NavBar.js 提供給 Filter.js 調用的 updateOptions        /**     * 更新 navConfig,Filter 組件調用     * 異步 setState 規避 rax 框架 bug: 用戶在 componentDidMount 函數中調用中 this.props.onChange 回調     * 重現Code:https://jsplayground.taobao.org/raxplayground/cefec50a-dfe5-4e77-a29a-af2bbfcfcda3     * @param index     * @param text     * @param isChange     */    updateOptions = (index, text, isChange = true) => {      setTimeout(() => {        const { navConfig } = this.state;        this.setState({          navConfig: navConfig.map((item, i) => {            if (index === i) {              return {                ...item,                text,                isChange,              };            }            return item;          }),        });      }, 0);    };

最後 NavBar 中的 item 分為 快速搜索和帶有 panel 的 NavBarItem兩種,但是對於其公共功能,比如渲染的 UI 邏輯等,這裡我們採用的方法是抽離 NavBase 組件,供給 NavQuickSearchNavRelatePanel 調用:

  • NavBase 部分代碼
  renderDefaultItem = ({ text, icons, active }) => {      const { formatText, hasSeperator, length, keepHighlight, isChange } = this.props;        const hasChange = keepHighlight && isChange;      const iconWidth = icons ? this.getStyle('navIcon').width || 18 : 0;        return [        <Text          numberOfLines={1}          style={[            this.getStyle('navText'),            ifElse(active || hasChange, this.getStyle('activeNavText')),            { maxWidth: 750 / length - iconWidth },          ]}>          {ifElse(is('Function')(formatText), formatText(text), text)}        </Text>,        ifElse(          icons,          <Image            ref={r => {              this.refImg = r;            }}            style={this.getStyle('navIcon')}            source={{              uri: ifElse(active || hasChange, icons && icons.active, icons && icons.normal),            }}          />,          null,        ),        ifElse(hasSeperator, <View style={this.navSeperatorStyle} />),      ];    };
  • NavRelatePanel.js
  export default class NavRelatePanel extends NavBase {      static displayName = 'NavRelatePanel';        handleClick = () => {        const { disabled, onNavbarPress } = this.props;        if (disabled) return false;        onNavbarPress(NAV_TYPE.RelatePanel);      };        render() {        const { renderItem, active, text, icons } = this.props;          return (          <View            style={[this.getStyle('navItem'), ifElse(active, this.getStyle('activeNavItem'))]}            onClick={this.handleClick}>            {ifElse(              is('Function')(renderItem),              renderItem && renderItem({ active, instance: this }),              this.renderDefaultItem({ text, icons, active }),            )}          </View>        );      }    }

Panel 核心代碼

Panel 的核心功能是對用戶定義的 Panel.child 進行基本的功能添加,比如背景 mask 遮罩、動畫時機的處理.

Panel 的使用:

              <Panel                  displayMode={'Fullscreen'} // 配置 Panel 全屏展示,默認為下拉展示                  animation={{                    // 動畫配置                    timingFunction: 'cubic-bezier(0.22, 0.61, 0.36, 1)',                    duration: 200,                    direction: 'left', // 動畫方向:從右往左方向滑出                  }}>                  <MultiSelect {...this.state.data3} />                </Panel>

我們提供基礎的動畫配置,但是同時,也提供動畫的 functionHook,這些都取決於動畫的觸發時機

    get animationConfig() {      const { animation } = this.props;      if (!animation || !is('Object')(animation)) {        return PANEL_ANIMATION_CONFIG;      }      return Object.assign({}, PANEL_ANIMATION_CONFIG, animation);    }      // ...        /**     * 執行動畫     * @param nextProps     */    componentWillReceiveProps(nextProps) {      if (nextProps.visible !== this.props.visible) {        if (nextProps.visible) {          setNativeProps(findDOMNode(this.refPanelContainer), {            style: {              transform: `translateX(-${rem2px(750)})`,            },          });          this.props.disableNavbarClick(true);          this.enterAnimate(this.currentChildref, () => {            this.props.disableNavbarClick(false);          });          this.handleMaskAnimate(true);        } else {          this.handleMaskAnimate(false);          this.props.disableNavbarClick(true);          this.leaveAnimate(this.currentChildref, () => {            this.props.disableNavbarClick(false);            setNativeProps(findDOMNode(this.refPanelContainer), {              style: {                transform: 'translateX(0)',              },            });          });        }      }    }

由於動畫的執行需要時間,所以這個時間段,我們應該給 Filter 中的 NavBar 加鎖 ,鎖的概念也同樣提供給用戶,畢竟業務邏輯我們是不會侵入的,在上一次的搜索沒有結果返回時候,應該給 NavBar 加鎖,禁止再次點擊(雖然用戶可以再 onchange 回調函數中處理,但是作為組件,同樣應該考慮並且提供這個能力),同樣對於動畫也是如此,在該動畫正在執行的時候,應該禁止 NavBar 的再次點擊。上面的動畫配置效果如下:

Panel 中還有核心的處理或許就是關於動畫時機的處理。比如在觸發動畫前,我們需要設置動畫初始狀態,但是如若如下寫法,會出現 Panel 閃動的現象,畢竟我們通過第二次的事件輪訓回來才執行初始化,所以這裡,如果用戶配置啟動動畫,那麼我們需要在 Panel 的最外層添加一個可見的 flag:默認進來 opacity 設置為 0,當動畫初始狀態設置完畢後,在將最外層容器的 opacity 設置為 1,其實 Panel 還是閃了一下,只是你看不到而已。

      // 設置動畫初始樣式        setTimeout(() => {          setNativeProps(node, {            style: {              transform: !visible ? 'translate(0, 0)' : v,            },          });        }, 0);        // 執行動畫        setTimeout(() => {          transition(            node,            {              transform: visible ? 'translate(0, 0)' : v,            },            {              timingFunction: timingFunction,              duration: duration,              delay: 0,            },            cb,          );        }, 50);

設置動畫初始化樣式中添加:

        setNativeProps(findDOMNode(this.refPanelContainer), {            style: {              opacity: 1,            },          });

結束語

Filter 的組件看似簡單,但是如果想寫一個市場上較為通用和廣泛的 Filter 組件,不僅僅是組件的顆粒度、耦合度和性能需要考慮,更多的是其中還是有太多的業務邏輯需要去思考。對於目前的初版(還未修改成正式開源版),已經基本涵蓋了目前我們能夠想到的業務場景,也已經有相關業務落地使用。

當然,對於如果是直接放到業務中使用而不作為開源組件的話,我們可已經 Panel下的 child 通過 renderPortal 降低層級,通過 EventBus 或者 redux、mobx 等管理數據狀態。那樣會讓整個代碼邏輯看起來清晰很多。但是為了降低bundle 大小,我們儘可能的減少通用包的使用以及第三方插件的依賴。

關於文章中沒有提及的想法或者對於這些Filter業務需求(坑)你有更好的處理方法和想法都歡迎在評論區交流~

學習交流

關注公眾號: 【全棧前端精選】 每日獲取好文推薦。

公眾號內回復 【1】,加入全棧前端學習群,一起交流。