concent 騷操作之組件創建&狀態更新

  • 2019 年 11 月 3 日
  • 筆記

❤ star me if you like concent ^_^

進化中的組件

隨著react 16.8發布了穩定版本的hook特性,原來官網文檔里對SFC的描述也修改為了FC,即無狀態函數組件變更為了函數組件,官方代言人Dan Abramov也在各種場合開始向社區力推hook,將其解讀為下一個5年React與時俱進的開端。

仔細想想,其實hook只是改變了我們組織程式碼的方式,因為hook的存在,我們原來在類組件里的各種套路都可以在函數組件里找到一一對應的寫法,但是依託於class組件建立起來一系列最佳實踐在hook組件里全部都要改寫,所以官方也是推薦如非必要,為了穩妥起見老項目里依然使用class組件

任何新技術的出現一定都是有相關利益在驅動的,hook也不例外,官網對hook出現的動機給了3點重要解釋

  • 在組件之間復用狀態邏輯很難
  • 複雜組件變得難以理解
  • 難以理解的 class

當然class組件最為詬病的包裹地獄因為hook獨特的實現方式被消除了,所以class組件似乎在未來的日子裡將慢慢被冷落掉,而hook本質只是一個個函數,對函數式編程將變得更加友好,同時還能繼續推進組合大於繼承的中心思想,讓更多的開發者受益於這種全新的開始思路並提升開發體驗。

按照官方的願意表達,Hook既擁抱了函數,同時也沒有犧牲 React 的精神原則,提供了問題的解決方案,無需學習複雜的函數式或響應式編程技術。

concent如何看待組件

前面有一句話提到「任何新技術的出現一定都是有相關利益在驅動的」,所以concent的誕生的動機也是非常明確:

  • 讓類組件和函數組件擁有完全一致的編碼思路和使用體驗
  • 用最少的程式碼表達狀態共享、邏輯復用等問題
  • 從組件層面搭建一個更優的最小化更新機制
  • 增強組件,賦予組件更多的強大特性

上面提到的第一點其實說白了統一類組件和函數組件,得益於concent能為組件注入實例上下文的運行機制,無論是從api使用層面還是渲染結果層面,都將高度給你一致的體驗,所以在concent眼裡,類與函數都是ui表達的載體而已,不再區分對待它們,給用戶更多的選擇餘地。

那麼廢話少說,我們直接開整,看看concent提供了多少種創建組件很更新狀態的方式。

在展示和解讀組件創建和狀態更新程式碼之前,我們先使用run介面載入一個示例的業務model名為demo,在以下程式碼結構處於models文件夾。

這裡一個示例項目文件組織結構,不同的人可能有不同的理解方式和組織習慣,這裡只是以一個基本上社區上公認的通用結構作為範本來為後面的程式碼解讀做基礎,實際的文件組件方式用戶可以根據自己的情況做調節|____runConcent.js # concent啟動腳本 |____App.css |____index.js # 項目的入口文件 |____models # 業務models | |____index.js | |____demo # demo模組定義 | | |____reducer.js # 更新狀態(可選) | | |____index.js # 負責導出demo model | | |____computed.js # 定義計算函數(可選) | | |____init.js # 定義非同步的狀態初始化函數(可選) | | |____state.js # 定義初始狀態(必需) | |____… | |____components # 基礎組件 | |____layout # 布局組件 | |____bizsmart # 業務邏輯組件(可以含有自己的model) | |____bizdumb # 業務展示組件 | |____smart # 平台邏輯組件(可以含有自己的model) | |____pure # 平台展示組件 | |____assets # 會被一起打包的資源文件 |____pages # 路由對應的頁面組件(可以含有自己的model,即page model) | |____… | | |____App.js |____base | |____config # 配置 | |____constant # 常量 |____services # 業務相關服務 |____utils # 通用工具函數demo的state定義

export function getInitialState(){      return {          name: 'hello, concent',          age: 19,          visible: true,          infos: [],      }  }

使用run介面載入模組定義

// code in runConcent.js  import models from 'models';  import { run } from 'concent';    run(models);

對以上實例程式碼有疑問可以參考往期文章:

聊一聊狀態管理&Concent設計理念

使用concent,體驗一把漸進式地重構react應用之旅

或者直接查看官網文檔了解更多細節

創建類組件

使用register介面直接將一個普通類組件註冊為concent類組件

import { register } from 'concent';  import React, { Component } from 'react';    @register('demo')  export default class ClassComp extends Component {    render() {      const { name, age, visible, infos } = this.state;      return <div>...your ui</div>    }  }

是的你沒看錯,這就完成了concent類組件的註冊,它屬於demo模組,state里將自動注入demo模組的所有數據,讓我們把它渲染出來,看看結果

 function App() {    return (      <div>        <ClassComp />      </div>    );  }    ReactDOM.render(<App />, document.getElementById('root'));

打開ReactDevTool查看dom結構

可以看到頂層沒有任何Provider,數據直接打入組件內部,同時組件本身沒有任何包裹,只有一層,因為默認採用反向繼承的hoc策略,你的渲染的組件不再產生大量Wrapper Hell

或許有小夥伴會問這樣會不會打破了hoc模式的約定,因為大家都是使用屬性代理方式來做組件修飾,不破壞組件原有的任何結構,同時還能復用邏輯,可是這裡我們需要多思考一下,如果邏輯復用不一定非要從屬性上穿透下來,而是直接能從實例上下文里提供,那為何我們非要墨守成規的使用屬性代理的hoc模式呢?

當然concent對於類的修飾雖然默認使用了反向繼承,但是也允許用戶使用屬性代理,只需要開啟一個標記即可

@register({ module: 'demo', isPropsProxy: true })  export default class ClassComp extends Component{    constructor(props, context){      super(props, context);      this.props.$$attach(this);// 屬性代理模式需要補上這句話,方便包裹層接管組件this    }    render(){      const {name, age, visible, infos} = this.state;      return <div>...your ui</div>    }  }

顯而易見的,我們發現已經多了一層包裹,之所以提供isPropsProxy參數,是因為有些組件用到了多重裝飾器的用法,所以為了不破壞多重裝飾器下的使用方式而提供,但大多數時候,你都應該忘記這種用法,讓react dom樹保持乾淨清爽何樂而不為呢?

圖中我們看到組件名時$$CcClass1,這是一個當用戶沒有顯示指定組件名時,concent自己起的名字,大多數時候我們可以給一個與目標包裹組件同名的名字作為concent組件的名字

//第二個可選參數是concent組件名  @register('demo', 'ClassComp')  export default class ClassComp extends Component{...}

創建CcFragment組件

CcFragment是concent提供的內置組件,可以讓你不用定義和註冊組件,而是直接在視圖裡聲明一個組件實例來完成快速消費某個模組數據的實例。

我們在剛才的App里直接聲明一個視圖消費demo模組的數據

 function App() {    return (      <div>        <ClassComp />        <CcFragment register="demo" render={ctx => {          const { name, age, visible, infos } = ctx.state;          return <div>...your ui</div>        }} />      </div>    );  }

渲染結果如下圖所示:

CcFragment採用的是Render Props方式來書寫組件,特別適合一些臨時多個模組數據的視圖片段

      <CcFragment register={{connect:['bar', 'baz']}} render={ctx => {          // 改片段連接了bar,baz兩個模組,消費它們的數據          const { bar, baz } = ctx.connectedState;          return <div>...your ui</div>        }} />

基於registerDumb創建組件

用戶通常在某些場合會基於CcFragment做經一步的封裝來滿足一些高緯抽象的需求,concent本身也提供了一個介面registerDumb來創建組件,它本質上是CcFragment的淺封裝

const MyFragment = registerDumb('demo', 'MyFragment')(ctx=>{    const { name, age, visible, infos } = ctx.state;    return  <div>...I am MyFragment</div>  })

渲染結果如下圖所示:

可以看到react dom tree上,出現了3層結構,最裡面層是無狀態組件實例。

基於hook創建組件

雖然registerDumb寫起來像函數組件了,但實際上出現了3層結構不是我們希望看到的,我們來使用hook方式重構此組件吧,concent提供了useConcent介面來創建組件,抹平類組件與函數組件之間的差異性。

function HookComp(){    const ctx = useConcent('demo', 'HookComp');    const { name, age, visible, infos } = ctx.state;    return  <div>...I am HookComp</div>  }

渲染結果如下圖所示:

基於registerHookComp創建組件

registerHookComp本質上是useConcent的淺封裝,自動幫你使用React.memo包裹

const MemoHookComp = registerHookComp({    module:'demo',    render: ctx=>{      const { name, age, visible, infos } = ctx.state;      return  <div>...I am MemoHookComp</div>    }  });

渲染結果圖裡我們可以看到tag上有一個Memo,那是React.memo包裹組件後DevTool的顯示結果。

concent如何看待狀態更新

上面的所有組件示例里,我們都只是完成的模組狀態的獲取和展示,並沒有做任何更新操作,接下來我們將對組件加入狀態更新操作行為。

利用setState完成狀態更新

因為concent已接管了setState行為,所以對於使用者來說,setState就可以完成你想要的狀態更新與狀態同步。

在替換setState前,concent會保持一份引用reactSetState指向原始的setState,所以你大可不必擔心setState會影響react的各種新特性諸如fiber 調度time slicing非同步渲染等,因為concent只是利用接管setState後完成自己的狀態分發調度工作,本身是不會去破壞或者影響react自身的調度機制。

// 改寫ClassComp  @register('demo')  export default class ClassComp extends Component {    changeName = (e)=> this.setState({name:e.currentTarget.value})    render() {      const { name } = this.state;      return <input value={name} onChange={this.changeName} />    }  }
// 改寫ClassComp    <CcFragment register="demo" render={ctx => {      const changeName = (e)=> ctx.setState({name:e.currentTarget.value});      const { name, age, visible, infos } = ctx.state;      return <input value={name} onChange={changeName} />    }} />
// 改寫MyFragment  registerDumb('demo', 'MyFragment')(ctx=>{    const changeName = (e)=> ctx.setState({name:e.currentTarget.value});    const { name, age, visible, infos } = ctx.state;    return <input value={name} onChange={changeName} />  })
// 改寫HookComp  function HookComp(){    const ctx = useConcent('demo', 'HookComp');    const { name, age, visible, infos } = ctx.state;    const changeName = (e)=> ctx.setState({name:e.currentTarget.value});    return <input value={name} onChange={changeName} />  }
// 改寫MemoHookComp  const MemoHookComp = registerHookComp({    module:'demo',    render: ctx=>{      const { name, age, visible, infos } = ctx.state;      const changeName = (e)=> ctx.setState({name:e.currentTarget.value});      return  <input value={name} onChange={changeName} />    }  });

可以看到,所以的組件都是一樣的寫法,不同的是類組件還存在著一個this關鍵字,而函數最組件里都交給ctx去操作了。

現在讓我們看看實際效果吧

因為這些實例都是屬於demo模組的組件,所以無論我修改任何一處,其他地方視圖都會同步被更新,是不是將特別方便呢?

使用sync更新

當然如果對於這種單個key的更新,我們也可以不用寫setState,而是直接使用concent提供的工具函數sync來完成值的提取與更新

// 改寫HookComp使用sync來更新,其他組件寫法都一樣,class組件通過this.ctx.sync來更新  function HookComp(){    const ctx = useConcent('demo', 'HookComp');    const { name, age, visible, infos } = ctx.state;    return <input value={name} onChange={sync('name')} />  }

使用dispatch更新

當我們的業務邏輯複雜的時候,在真正更新之前要做很多數據的處理工作,這時我們可以將其抽到reducer

// 定義reducer,code in models/demo/reducer.js    export updateName(name, moduleState, actionCtx){    return {name, loading: false};  }    export updateNameComplex(name, moduleState, actionCtx){     // concent會自動在reducer文件內生成一個名為setState的reducer函數,免去用戶聲明一次     await actionCtx.setState({loading:true});     await api.updateName(name);     // 在同一個reducer文件類的函數,可以直接基於函數引用調用     await actionCtx.dispatch(updateName, name);  }

在組件內部使用dispatch觸發更新

function HookComp(){    const ctx = useConcent('demo', 'HookComp');    const { name, age, visible, infos } = ctx.state;    const updateNameComplex = (e)=>ctx.dispatch('updateNameComplex', e.currentTarget.value);    return <input value={name} onChange={updateNameComplex} />  }

當然,這裡有更優的寫法,使用setup靜態的定義相關介面。了解更多關於setup

const setup = ctx=>{    return {      updateNameComplex: (e)=>ctx.dispatch('updateNameComplex',e.currentTarget.value),    }  }  function HookComp(){    const ctx = useConcent('demo', 'HookComp');    const { name, age, visible, infos } = ctx.state;    return <input value={name} onChange={ctx.settings.updateNameComplex} />  }

使用invoke更新

invoke給予用戶更自由的靈活程度來更新視圖數據,因為本質來說concent的reducer函數就是一個個片段狀態生成函數,所以invoke讓用戶可以不需要走dispatch套路來更新數據。

因為reducer定義是跟著model走的,為了規範起見,實際編碼過程中定義reducer函數比invoke更能夠統一數據更新流程,很方便查看和排除bug。

function updateName(name, moduleState, actionCtx){    return {name, loading: false};  }    function updateNameComplex(name, moduleState, actionCtx){     await actionCtx.setState({loading:true});     await api.updateName(name);     await actionCtx.invoke(updateName, name);  }    const setup = ctx=>{    return {      updateNameComplex: (e)=>ctx.invoke(updateNameComplex,e.currentTarget.value),    }  }  function HookComp(){    const ctx = useConcent('demo', 'HookComp');    const { name, age, visible, infos } = ctx.state;    return <input value={name} onChange={ctx.settings.updateNameComplex} />  }

結語

通過以上示例,讀者應該能體會到統一類組件和函數組件的好處,那就是滿足你任何時段漸進式的書寫你的應用,無論是組件的定義方式和數據的修改方式,你都可以按需採取不同的策略,而且concent里的hook使用方式是遵循著reducer承載核心業務邏輯,dispatch派發修改狀態的經典組織程式碼方式的,但是並沒有強制約束你一定要怎麼寫,給予了你最大的自由度和靈活度,沉澱你個人的最佳實踐,甚至你可以通過修改少量的程式碼來100%複製社區里現有的公認最佳實踐到你的concent應用。

(下2期預告:1 探究setup帶來的變革;2 concent love typescript,期望讀者多多支援,concent,沖鴨,to be the apple of your eyes)

❤ star me if you like concent ^_^