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

❤ star me if you like concent ^_^前端

狀態管理是一個前端界老生常談的話題了,全部前端框架的發展歷程中都離不開狀態管理的迭代與更替,對於react來講呢,整個狀態管理的發展也隨着react架構的變動和新特性的加入而不停的作調整,做爲一個一塊兒伴隨react成長了快5年的開發者,經歷過reflux、redux、mobx,以及其餘redux衍生方案dva、mirror、rematch等等後,我以爲它們都不是我想要的狀態管理的終極形態,因此爲了打造一個和react結合得最優雅、使用起來最簡單、運行起來最高效的狀態管理方案,踏上了追夢旅途。vue

爲什麼須要狀態管理

爲什麼須要在前端引用裏引入狀態管理,基本上你們都達成了共識,在此我總結爲3點:node

  • 隨着應用的規模愈來愈大,功能愈來愈複雜,組件的抽象粒度會愈來愈細,在視圖中組合起來後層級也會愈來愈深,可以方便的跨組件共享狀態成爲迫切的需求。
  • 狀態也須要按模塊切分,狀態的變動邏輯背後其實就是咱們的業務邏輯,將其抽離出來可以完全解耦ui和業務,有利於邏輯複用,以及持續的維護和迭代。
  • 狀態若是可以被集中的管理起來,併合理的派發有利於組件按需更新,縮小渲染範圍,從而提升渲染性能

已有狀態管理方案現狀

redux

遵循react不可變思路的狀態管理方案,不管從git的star排名仍是社區的繁榮度,首推的必定是redux這個react界狀態管理一哥,約束使用惟一路徑reducer純函數去修改store的數據,從而達到整個應用的狀態流轉清晰、可追溯。react

image.png

mbox

遵循響應式的後期之秀mbox,提出了computedreaction的概念,其官方的口號就是任何能夠從應用程序狀態派生的內容都應該派生出來,經過將原始的普通json對象轉變爲可觀察對象,咱們能夠直接修改狀態,mbox會自動驅動ui渲染更新,因其響應式的理念和vue很相近,在react裏搭配mobx-react使用後,不少人戲稱mobx是一個將react變成了類vue開發體驗的狀態管理方案。git

image.png

固然由於mbox操做數據很方便,不知足大型應用裏對狀態流轉路徑清晰可追溯的訴求,爲了約束用戶的更新行爲,配套出了一個mobx-state-tree,總而言之,mobx成爲了響應式的表明。github

其餘

剩下的狀態管理方案,主要有3類。編程

一類是不知足redux代碼冗餘囉嗦,接口不夠友好等缺點,進而在redux之上作2次封裝,典型的表明國外的有如rematch,國內有如dvamirror等,我將它們稱爲redux衍生的家族做品,或者是解讀了redux源碼,整合本身的思路從新設計一個庫,如final-stateretalkhydux等,我將它們稱爲類redux做品。json

一類是走響應式道路的方案,和mobx同樣,劫持普通狀態對象轉變爲可觀察對象,如dob,我將它們稱爲類mobx做品。redux

剩下的就是利用react context api或者最新的hook特性,主打輕量,上手簡單,概念少的方案,如unstated-nextreactnsmoxreact-model等。小程序

我心中的理想方案

上述相關的各類方案,都各自在必定程度上能知足咱們的需求,可是對於追求完美的水瓶座程序猿,我以爲它們終究都不是我理想的方案,它們或小而美、或大而全,但仍是不夠強,不夠友好,因此決定開始自研狀態管理方案。

我知道小和 美、全、強自己是相沖突的,我能接受必定量的大,gzip後10kb到20kb都是我接受的範圍,在此基礎上,去逐步地實現美、全、強,以便達到如下目的,從而體現出和現有狀態管理框架的差別性、優越性。

  • 讓新手使用的時候,無需瞭解新的特性api,無感知狀態管理的存在,使其遁於無形之中,僅按照react的思路組織代碼,就能享受到狀態管理帶來的福利。
  • 讓老手能夠結合對狀態管理的已有認知來使用新提供的特性api,還原各類社區公認的最佳實踐,同時還能向上繼續探索和提煉,挖掘狀態管理帶來的更多收益。
  • react有了hook特性以後,讓class組件和function組件都可以享有一致的思路、一致的api接入狀態管理,不產生割裂感。
  • 在保持以上3點的基礎上,讓用戶可以使用更精簡且更符合思惟直覺的組織方式書寫代碼,同時還可以得到巨大的性能提高收益。

爲了達成以上目標,立項concent,將其定義爲一個可預測、零入侵、漸進式、高性能的加強型狀態管理方案,期待能把他打磨成爲一個真真實實讓用戶用起來感受到美麗、全面、強大的框架。

說人話就是:理解起來夠簡單、代碼寫起來夠優雅、工程架構起來夠健壯、性能用起來夠卓越...... ^_^

concent.png

可預測

react是一個基於pull based來作變化偵測的ui框架,對於用戶來講,須要顯式的調用setState來讓react感知到狀態變化,因此concent遵循react經典的不可變原則來體現可預測,不使用劫持對象將轉變爲可觀察對象的方式來感知狀態變化(要否則又成爲了一個類mobx......), 也不使用時全局pub&sub的模式來驅動相關視圖更新,同時還要配置各類reselectredux-saga等中間件來解決計算緩存、異步action等等問題(若是這樣,豈不是又邁向了一個redux全家桶輪子的不歸路..... )

吐槽一下:redux粗放的訂閱粒度在組件愈來愈多,狀態愈來愈複雜的時候,常常由於組件訂閱了不須要的數據而形成冗餘更新,並且各類手寫mapXXXToYYY很煩啊有木有啊有木有,傷不起啊傷不起......

零入侵

上面提到了指望新手僅按照react的思路組織代碼,就可以享受到狀態管理帶來的福利,因此必然只能在setState之上作文章,其實咱們能夠把setState當作一個下達渲染指令重要入口(除此以外,還有forceUpdate)。

setState,下達更新指令

仔細看看上圖,有沒有發現有什麼描述不太準確的地方,咱們看看官方的setState函數簽名描述:

setState<K extends keyof S>(
    state: ((prevState: Readonly<S>, props: Readonly<P>) => (Pick<S, K> | S | null)) | (Pick<S, K> | S | null), callback?: () => void
): void;
複製代碼

經過簽名描述,咱們能夠看出傳遞給setState的是一個部分狀態(片斷狀態),實際上咱們在調用setState也是常常這麼作的,修改了誰就傳遞對應的stateKey和值。

傳遞部分狀態

react自動將部分狀態合併到原來的整個狀態對象裏從而覆蓋掉其對應的舊值,而後驅動對應的視圖更新。

merge partial state

因此我只要可以讓setState提交的狀態給本身的同時,也可以將其提交到store並分發到其餘對應的實例上就達到了個人目的。

set state Intelligently

顯而易見咱們須要劫持setState,來注入一些本身的邏輯,而後再調用原生setState

//僞代碼實現
class Foo extends Component{
  constructor(props, context){
    this.state = { ... };
    this.reactSetState = this.setState.bind(this);
    this.setState = (partialState, callback){
      //commit partialState to store .....
      this.reactSetState(partialState, callback);
    }
  }
}
複製代碼

固然做爲框架提供者,確定不會讓用戶在constructor去完成這些額外的注入邏輯,因此設計了兩個關鍵的接口runregisterrun負責載入模塊配置,register負責註冊組件設定其所屬模塊,被註冊的組件其setState就獲得了加強,其提交的狀態不只可以觸發渲染更新,還可以直接提交到store,同時分發到這個模塊的其餘實例上。

store雖然是一顆單一的狀態樹,可是實際業務邏輯是由不少模塊的,因此我將store的第一層key當作模塊名(相似命名空間),這樣就產生了模塊的概念

//concent代碼示意
import { run, register } from 'concent';

run({
  foo:{//foo模塊定義
    state:{
      name: 'concent',
    }
  }
})

@register('foo')
class Foo extends Component {
  changeName = ()=> {
    this.setState({ name: e.currentTarget.value });//修改name
  }
  render(){
    const { name } = this.state;//讀取name
    return <input value={name} onChange={this.changeName} /> } } 複製代碼

在線示例代碼見此處

如今咱們來看看上面這段代碼,除了沒有顯示的在Foo組件裏聲明state,其餘地方看起來是否是給你一種感受:這不就是一個地地道道的react組件標準寫法嗎?concent將接入狀態管理的成本下降到了幾乎可忽略不計的地步。

固然,也容許你在組件裏聲明其餘的非模塊狀態,這樣的話它們就至關於私有狀態了,若是setState提交的狀態既包含模塊的也包含非模塊的,模塊狀態會被當作sharedState提取出來分發到其餘實例,privName僅提交給本身。

@register('foo')
class Foo extends Component {
  state = { privName: 'i am private, not from store' };
  fooMethod = ()=>{
    //name會被當作sharedState分發到其餘實例,privName僅提交給本身
    this.setState({name: 'newName', privName: 'vewPrivName' });
  }
  render(){
    const { name, privName } = this.state;//讀取name, privName
  }
}
複製代碼

考慮到這種合成的狀態在對接ts時會有必定模糊性,concent容許你顯示的聲明完整的狀態,當你的狀態裏含有和所屬模擬同名的stateKey時,在首次渲染以前這些stateKey的值會被模塊狀態裏對應的值覆蓋掉。

@register('foo')
class Foo extends Component {
  // name對應的值在首次渲染前被替換爲模塊狀態裏name對應的值
  state = { name:'', privName: 'i am private, not from store' };
  render(){
    // name: 'concent', privName: 'i am private, not from store'
    const { name, privName } = this.state;
  }
}
複製代碼

在這樣的模式下,你能夠在任何地方實例化多個Foo,任何一個實例改變name的值,其餘實例都會被更新,並且你也不須要在頂層的根組件處包裹相似Provider的輔助標籤來注入store上下文。

之因此可以達到此效果,得益於concent的核心工做原理依賴標記引用收集狀態分發,它們將在下文敘述中被逐個提到。

漸進式

可以經過做爲setState做爲入口接入狀態管理,且還能區分出共享狀態和私有狀態,的確大大的提升了咱們操做模塊數據的便利性,可是這樣就足夠用和足夠好了嗎?

更細粒度的控制數據消費

組件對消費模塊狀態的粒度並不老是很粗的和模塊直接對應的關係,即屬於模塊foo的組件CompA可能只消費模塊foo裏的f1f2f3三個字段對應的值,而屬於模塊foo的組件CompB可能只消費模塊foo裏另外的f4f5f6三個字段對應的值,咱們固然不指望CompA的實例只修改了f2f3時卻觸發了的CompB實例渲染。

大多數時候咱們指望組件和模塊保持的是一對一的關係,即一個組件只消費某一個模塊提供的數據,可是現實狀況的確存在一個組件消費多個模塊的數據。

因此針對register接口,咱們須要傳入更多的信息來知足更細粒度的數據消費需求

  • 經過module標記組件屬於哪一個具體的模塊

這是一個可選項,不指定的話就讓其屬於內置的$$default模塊(一個空模塊),有了module,就可以讓concent在其組件實例化以後將模塊的狀態注入到實例的state上了。

  • 經過watchedKeys標記組件觀察所屬模塊的stateKey範圍

這是一個可選項,不傳入的話,默認就是觀察所屬模塊全部stateKey的變化,經過watchedKeys來定義一個stateKey列表,控制同模塊的其餘組件提交新狀態時,本身需不須要被渲染更新。

  • 經過connect標記鏈接的其餘模塊

這是一個可選項,讓用戶使用connect參數去標記鏈接的其餘模塊,設定在其餘模塊裏的觀察stateKey範圍。

  • 經過ccClassKey設定當前組件類名

這是一個可選項,設定後方便在react dom tree上查看具名的concent組件節點,若是不設定的話,concent會自動更根據其moduleconnect參數的值算出一個,此時註冊了同一個模塊標記了相同connect參數的不一樣react組件在react dom tree上看到的就是相同的標籤名字。

經過以上register提供的這些關鍵參數爲組件打上標記,完成了concent核心工做原理裏很重要的一環:依賴標記,因此當這些組件實例化後,它們做爲數據消費者,身上已經攜帶了足夠多的信息,以更細的粒度來消費所須要的數據。

store的角度看類與模塊的關係

image.png

實例的state做爲數據容器已經盛放了所屬模塊的狀態,那麼當使用connect讓組件鏈接到其餘多個模塊時,這些數據又該怎麼注入呢?跟着這個問題咱們回想一下上面提到過的,某個實例調用setState時提交的狀態會被concent提取出其所屬模塊狀態,將它做爲sharedState精確的分發到其餘實例。

可以作到精確分發,是由於當這些註冊過的組件在實例化的時候,concent就會爲其構建了一個實例上下文ctx,一個實例對應着一個惟一的ctx,而後concent這些ctx引用精心保管在全局上下文ccContext裏(一個單例對象,在run的時候建立),因此說組件的實例化過程完成了concent核心工做原理裏很重要的一環:引用收集,固然了,實例銷燬後,對應的ctx也會被刪除。

有了ctx對象,concent就能夠很天然將各類功能在上面實現了,上面提到的鏈接了多個模塊的組件,其模塊數據將注入到ctx.connectedState下,經過具體的模塊名去獲取對應的數據。

ctx.png

咱們能夠在代碼裏很方便的構建跨多個模塊消費數據的組件,並按照stateKey控制消費粒度

//concent代碼示意
import { run, register, getState } from 'concent';

run({
  foo:{//foo模塊定義
    state:{
      name: 'concent',
      age: 19,
      info: { addr: 'bj', mail: 'xxxx@qq.com' },
    }
  },
  bar: { ... },
  baz: { ... },
})

//不設定watchedKeys,觀察foo模塊全部stateKey的值變化
//等同於寫爲 @register({module:'foo', watchedKeys:'*' })
@register('foo')
class Foo1 extends Component { ... }

//當前組件只有在foo模塊的'name', 'info'值發生變化時才觸發更新
//顯示的設定ccClassKey名稱,方便查看引用池時知道來自哪一個類
@register({module:'foo', watchedKeys:['name', 'info'] }, 'Foo2')
class Foo2 extends Component { ... }

//鏈接bar、baz兩個模塊,並定義其鏈接模塊的watchKeys
@register({
  module:'foo', 
  watchedKeys:['name', 'info'] ,
  connect: { bar:['bar_f1', 'bar_f2'], baz:'*' }
}, 'Foo2')
class Foo2 extends Component {
  render(){
    //獲取到bar,baz兩個模塊的數據
    const { bar, baz } = this.ctx.connectedState;
  }
 }
複製代碼

上面提到了可以作到精確分發是由於concent將實例的ctx引用作了精心保管,何以體現呢?由於concent爲這些引用作了兩層映射關係,並將其存儲在全局上下文裏,以便高效快速的索引到相關實例引用作渲染更新。

  • 按照各自所屬的不一樣模塊名作第一層歸類映射。

模塊下存儲的是一個全部指向該模塊的ccClassKey類名列表, 當某個實例提交新的狀態時,經過它攜帶者的所屬模塊,直接一步定位到這個模塊下有哪些類存在。

  • 再按照其各自的ccClassKey類名作第二層歸類映射。

ccClassKey下存儲的就是這個cc類對應的上下文對象ccClassContext,它包含不少關鍵字段,如refs是已近實例好的組件對應的ctx引用索引數組,watchedKeys是這個cc類觀察key範圍。

上面提到的ccClassContext是配合concent完成狀態分發的最重要的元數據描述對象,整個過程只需以下2個步驟:

  • 1 實例提交新狀態時第一步定位到所屬模塊下的全部ccClassKey列表,
  • 2 遍歷列表讀取並分析ccClassContext對象,結合其watchedKeys條件約束,嘗試將提交的sharedState經過watchedKeys進一步提取出符合當前類實例更新條件的狀態extractedState,若是提取出爲空,就不更新,反之則將其refs列表下的實例ctx引用遍歷,將extractedState發送給對應的reactSetState入口,觸發它們的視圖渲染更新。

工做原理

解耦ui和業務

有如開篇的咱們爲何須要狀態管理裏提到的,狀態的變動邏輯背後其實就是咱們的業務邏輯,將其抽離出來可以完全解耦ui和業務,有利於邏輯複用,以及持續的維護和迭代。

因此咱們漫天使用setState懟業務邏輯,業務代碼和渲染代碼交織在一塊兒必然形成咱們的組件愈來愈臃腫,且不利於邏輯複用,可是不少時候功能邊界的劃分和模塊的數據模型創建並非一開始可以定義的清清楚楚明明白白的,是在不停的迭代過程當中反覆抽象逐漸沉澱下來的

因此concent容許這樣多種開發模式存在,能夠自上而下的一開始按模塊按功能規劃好store的reducer,而後逐步編碼實現相關組件,也能夠自下而上的開發和迭代,在需求或者功能不明確時,就先不抽象reducer,只是把業務寫在組件裏,而後逐抽離他們,也不用強求中心化的配置模塊store,而是能夠自由的去中心化配置模塊store,再根據後續迭代計劃輕鬆的調整store的配置。

新增reducer定義

import { run } from 'concent';
run({
  counter: {//定義counter模塊
    state: { count: 1 },//state定義,必需
    reducer: {//reducer函數定義,可選
      inc(payload, moduleState) {
        return { count: moduleState.count + 1 }
      },
      dec(payload, moduleState) {
        return { count: moduleState.count - 1 }
      }
    },
  },
})
複製代碼

經過dispatch修改狀態

import { register } from 'concent';
//註冊成爲Concent Class組件,指定其屬於counter模塊
@register('counter')
class CounterComp extends Component {
  render() {
    //ctx是concent爲全部組件注入的上下文對象,攜帶爲react組件提供的各類新特性api
    return (
      <div> count: {this.state.count} <button onClick={() => this.ctx.dispatch('inc')}>inc</button> <button onClick={() => this.ctx.dispatch('dec')}>dec</button> </div>
    );
  }
}
複製代碼

由於concent的模塊除了state、reducer,還有watch、computed和init 這些可選項,支持你按需定義。

cc-modulepng

因此無論是全局消費的business model、仍是組件或者頁面本身維護的component modelpage model,都推薦進一步將model寫爲文件夾,在內部定義state、reducer、computed、watch、init,再導出合成在一塊兒組成一個完整的model定義。

src
├─ ...
└─ page
│  ├─ login
│  │  ├─ model //寫爲文件夾
│  │  │  ├─ state.js
│  │  │  ├─ reducer.js
│  │  │  ├─ computed.js
│  │  │  ├─ watch.js
│  │  │  ├─ init.js
│  │  │  └─ index.js
│  │  └─ Login.js
│  └─ product ...
│  
└─ component
   └─ ConfirmDialog
      ├─ model
      └─ index.js
複製代碼

這樣不只顯得各自的職責分明,防止代碼膨脹變成一個巨大的model對象,同時reducer獨立定義後,內部函數相互dispatch調用時能夠直接基於引用而非字符串了。

// code in models/foo/reducer.js
export function changeName(name) {
  return { name };
}

export async function changeNameAsync(name) {
  await api.track(name);
  return { name };
}

export async function changeNameCompose(name, moduleState, actionCtx) {
  await actionCtx.setState({ loading: true });
  await actionCtx.dispatch(changeNameAsync, name);//基於函數引用調用
  return { loading: false };
}
複製代碼

高性能

現有的狀態管理方案,你們在性能的提升方向上,都是基於縮小渲染範圍來處理,作到只渲染該渲染的區域,對react應用性能的提高就能產生很多幫助,同時也避免了人爲的去寫shouldComponentUpdate函數。

那麼對比redux,由於支持key級別的消費粒度控制,從狀態提交那一刻起就知道更新哪些實例,因此性能上可以給你足夠的保證的,特別是對於組件巨多,數據模型複雜的場景,cocent必定能給你足夠的信心去從容應對,咱們來看看對比mboxconcent作了哪些更多場景的探索。

renderKey,更精確的渲染範圍控制

每個組件的實例上下文ctx都有一個惟一索引與之對應,稱之爲ccUniqueKey,每個組件在其實例化的時候若是不顯示的傳入renderKey來重寫的話,其renderKey默認值就是ccUniqueKey,當咱們遇到模塊的某個stateKey是一個列表或者map時,遍歷它生產的視圖裏各個子項調用了一樣的reducer,經過id來達到只修改本身數據的目的,可是他們共享的是一個stateKey,因此必然觀察這個stateKey的其餘子項也會被觸發冗餘渲染,而咱們指望的結果是:誰修改了本身的數據,就只觸發渲染誰。

如store的list是一個長列表,每個item都會渲染成一個ItemView,每個ItemView都走同一個reducer函數修改本身的數據,可是咱們指望修改完後只能渲染本身,從而作到更精確的渲染範圍控制

render-key.png

基於renderKey機制,concent能夠輕鬆辦到這一點,當你在狀態派發入口處標記了renderKey時,concent會直接命中此renderKey對應的實例去觸發渲染更新。

不管是setStatedispatch,仍是invoke,都支持傳入renderKey

render-key

react組件自帶的key用於diff v-dom-tree 之用,concent的renderKey用於控制實例定位範圍,二者有本質上的區別,如下是示例代碼,在線示例代碼點我查看

// store的一個子模塊描述
{
  book: {
    state: {
      list: [
        { name: 'xx', age: 19 },
        { name: 'xx', age: 19 }
      ],
      bookId_book_: { ... },//map from bookId to book
    },
    reducer: {
      changeName(payload, moduleState) {
        const { id, name } = payload;
        const bookId_book_ = moduleState.bookId_book_;
        const book = bookId_book_[id];
        book.name = name;//change name

        //只是修改了一本書的數據
        return { bookId_book_ };
      }
    }
  }
}

@register('book')
class ItemView extends Component {
  changeName = (e)=>{
    this.props.dispatch('changeName', e.currentTarget.value);
  }
  changeNameFast = (e)=>{
    // 每個cc實例擁有一個ccUniqueKey 
    const ccUniqueKey = this.ctx.ccUniqueKey;
    // 當我修更名稱時,真的只須要刷新我本身
    this.props.dispatch('changeName', e.currentTarget.value, ccUniqueKey);
  }
  render() {
    const book = this.state.bookId_book_[this.props.id];
    //儘管我消費是subModuleFoo的bookId_book_數據,但是經過id來讓我只消費的是list下的一個子項

    //替換changeName 爲 changeNameFast達到咱們的目的
    return <input value={ book.name } onChange = { changeName } />
  }
}

@register('book')
class BookItemContainer extends Component {
  render() {
    const books = this.state.list;
    return (
      <div>
        {/** 遍歷生成ItemView */}
        {books.map((v, idx) => <ItemView key={v.id} id={v.id} />)}
      </div >
    )
  }
}
複製代碼

因concent對class組件的hoc默認採用反向繼承策略作包裹,因此除了渲染範圍下降和渲染時間減小,還將擁有更少的dom層級。

lazyDispatch,更細粒度的渲染次數控制

concent裏,reducer函數和setState同樣,提倡改變了什麼就返回什麼,且書寫格式是多樣的。

  • 能夠是普通的純函數
  • 能夠是generator生成器函數
  • 能夠是async & await函數 能夠返回一個部分狀態,能夠調用其餘reducer函數後再返回一個部分狀態,也能夠啥都不返回,只是組合其餘reducer函數來調用。對比redux或者redux家族的方案,老是合成一個新的狀態是否是要省事不少,且純函數和反作用函數再也不區別對待的定義在不一樣的地方,僅僅是函數聲明上作文章就能夠了,你想要純函數,就聲明爲普通函數,你想要反作用函數,就聲明爲異步函數,簡單明瞭,符合閱讀思惟。

基於此機制,咱們的reducer函數粒度拆得很細很原子,每個都負責獨立更新某一個和某幾個key的值,以便更靈活的組合它們來完成高度複用的目的,讓代碼結構上變優雅,讓每個reducer函數的職責更得更小。

//reducer fns
export async function updateAge(id){
  // ....
  return {age: 100};
}

export async function trackUpdate(id){
  // ....
  return {trackResult: {}};
}

export async function fetchStatData(id){
  // ....
  return {statData: {}};
}

// compose other reducer fns
export async function complexUpdate(id, moduleState, actionCtx) {
  await actionCtx.dispatch(updateAge, id);
  await actionCtx.dispatch(trackUpdate, id);
  await actionCtx.dispatch(fetchStatData, id);
}
複製代碼

雖然代碼結構上變優雅了,每個reducer函數的職責更小了,可是其實每個reducer函數其實都會觸發一次更新。

reducer函數的源頭觸發是從實例上下文ctx.dispatch或者全局上下文cc.dispatch(or cc.reducer)開始的,呼叫某個模塊的某個reducer函數,而後在其reducer函數內部再觸發的其餘reducer函數的話,其實已經造成了一個調用鏈,鏈路上的每個返回了狀態值的reducer函數都會觸發一次渲染更新,若是鏈式上有不少reducer函數,會照常不少次對同一個視圖的冗餘更新。

觸發reducer的源頭代碼

// in your view
<button onClick={()=> ctx.dispatch('complexUpdate', 2)}>複雜的更新</button>
複製代碼

更新流程以下所示

dispatch.png

針對這種調用鏈提供lazy特性,以便讓用戶既能滿意的把reducer函數更新狀態的粒度拆分得很細,又保證渲染次數縮小到最低。

看到此特性,mbox使用者是否是想到了transaction的概念,是的你的理解沒錯,某種程度上它們所到到的目的是同樣的,可是在concent裏使用起來更加簡單和優雅。

如今你只須要將觸發源頭作小小的修改,用lazyDispatch替換掉dispatch就能夠了,reducer裏的代碼不用作任何調整,concent將延遲reducer函數調用鏈上全部reducer函數觸發ui更新的時機,僅將他們返回的新部分狀態按模塊分類合併後暫存起來,最後的源頭函數調用結束時才一次性的提交到store並觸發相關實例渲染。

// in your view
<button onClick={()=> ctx.lazyDispatch('complexUpdate', 2)}>複雜的更新</button>
複製代碼

lazy-dispatch

查看在線示例代碼

如今新的更新流程以下圖

image.png

固然lazyScope也是能夠自定義的,不必定非要在源頭函數上就開始啓用lazy特性。

// in your view
const a=  <button onClick={()=> ctx.dispatch('complexUpdateWithLoading', 2)}>複雜的更新</button>

// in your reducer
export async function complexUpdateWithLoading(id, moduleState, actionCtx) {
  //這裏會實時的觸發更新
  await actionCtx.setState({ loading: true });

  //從這裏開始啓用lazy特性,complexUpdate函數結束前,其內部的調用鏈都不會觸發更新
  await actionCtx.lazyDispatch(complexUpdate, id);

  //這裏返回了一個新的部分狀態,也會實時的觸發更新
  return { loading: false };
}
複製代碼

delayBroadcast,更主動的下降渲染次數頻率

針對一些共享狀態,當某個實例高頻率的改變它的時候,使用delayBroadcast主動的控制此狀態延遲的分發到其它實例上,從而實現更主動的下降渲染次數頻率

delay

function ImputComp() {
  const ctx = useConcent('foo');
  const { name } = ctx.state;
  const changeName = e=> ctx.setState({name: e.currentTarget.value});
  //setState第四位參數是延遲分發時間
  const changeNameDelay = e=> ctx.setState({name: e.currentTarget.value}, null, null, 1000);
  return (
    <div>
      <input  value={name} onChange={changeName} />
      <input  value={name} onChange={changeName} />
    </div>
  );
}

function App(){
  return (
    <>
      <ImputComp />
      <ImputComp />
      <ImputComp />
    </>
  );
}
複製代碼

查看在線示例代碼

加強react

前面咱們提到的ctx對象,是加強react的「功臣」,由於每一個實例上都有一個concent爲之構造的ctx對象,在它之下新增不少新功能、新特性就很方便了。

新特性加入

如上面關於模塊提到了computedwatch等關鍵詞,讀到它們的讀者,必定留了一些疑問吧,其實它們出現的動機和使用體驗是和vue的同樣的。

  • computed定義各個stateKey的值發生變化時,要觸發的計算函數,並將其結果緩存起來,僅當stateKey的值再次變化時,纔會觸發計。瞭解更多關於computed
  • watch定義各個stateKey的值發生變化時,要觸發的回調函數,僅當stateKey的值再次變化時,纔會觸發,一般用於一些異步的任務處理。瞭解更多關於watch。 我若是從setState的本質來解釋,你就可以明白這些功能其實天然而然的就提供給用戶使用了。

setState傳入的參數是partialState,因此concent一開始就知道是哪些stateKey發生了變化,天然而然咱們只須要暴露一個配置computedwatch的地方,那麼當實例提交新的部分狀態時,加強後setState就天然可以去觸發相關回調了。

enhance set state.png

setup賦予組件更多能力

上面提到的computedwatch值針對模塊的,咱們須要針對實例單獨定製computedwatch的話該怎麼處理呢?

setup是針對組件實例提供的一個很是重要的特性,在類組件和函數組件裏都可以被使用,它會在組件首次渲染以前會被觸發執行一次,其返回結果收集在ctx.settings裏,以後便不會再被執行,因此能夠在其中定義實例computed、實例watch、實例effect等鉤子函數,同時也能夠自定義其餘的業務邏輯函數並返回,方便組件使用。

基於setup執行時機的特色,至關於給了組件一個額外的空間,一次性的爲組件定義好相關的個性化配置,賦予組件更多的能力,特別是對於函數組件,提供useConcent來複制了register接口的全部能力,其返回結果收集在ctx.settings裏的特色讓函數組件可以將全部方法一次性的定義在setup裏,從而避免了在函數組件重複渲染期間反覆生成臨時閉包函數的弱點,減小gc的壓力。

使用useConcent只是爲了讓你仍是用經典的dispatch&&reducer模式來書寫核心業務邏輯,並不排斥和其餘工具鉤子函數(如useWindowSize等)一塊兒混合使用。

讓咱們setup吧!!!看看setup帶來的魔力,其中effect鉤子函數完美替代了useEffect瞭解更多關於setup

const setup = ctx => {
  //count變化時的反作用函數,第二位參數能夠傳遞多個值,表示任意一個發生變化都將觸發此反作用
  ctx.effect(() => {
    console.log('count changed');
  }, ['count']);
  //每一輪渲染都會執行
  ctx.effect(() => {
    console.log('trigger every render');
  });
  //僅首次渲染執行的反作用函數
  ctx.effect(() => {
    console.log('trigger only first render');
  }, []);

  //定義實例computed,因每一個實例均可能會觸發,優先考慮模塊computed
  ctx.computed('count', (newVal, oldVal, fnCtx)=>{
    return newVal*2;
  });

 //定義實例watch,區別於effect,執行時機是在組件渲染以前
 //因每一個實例均可能會觸發,優先考慮模塊watch
  ctx.watch('count', (newVal, oldVal, fnCtx)=>{
    //發射事件
    ctx.emit('countChanged', newVal);
    api.track(`count changed to ${newVal}`);
  });

  //定義事件監聽,concent會在實例銷燬後自動將其off掉
  ctx.on('changeCount', count=>{
    ctx.setState({count});
  });

  return {
    inc: () => setCount({ count: ctx.state.count + 1 }),
    dec: () => setCount({ count: ctx.state.count - 1 }),
  };
}
複製代碼

得益於setup特性和全部concent實例都持有上線文對象ctx,類組件和函數組件將實現100%的api調用能力統一,這就意味着二者編碼風格高度一致,相互轉換代價爲0。

接入setup的函數組件

import { useConcent } from 'concent';

function HooklFnComp() {
  //setup只會在初次渲染前調用一次
  const ctx = useConcent({ setup, module:'foo' });
  const { state , settings: { inc, dec }  } = ctx;

  return (
    <div> count: {state.count} <button onClick={inc}>+</button> <button onClick={dec}>-</button> </div>
  );
}
複製代碼

接入setup的類組件

@register('foo')
class ClassComp extends React.Component() {
  $$setup(ctx){
    //複用剛纔的setup定義函數, 這裏記得將結果返回
    return setup(ctx);
  }

  render(){
    const ctx = this.ctx;
    //ctx.state 等同於 this.state
    const { state , settings: { inc, dec }  } = ctx;

    return (
      <div> count: {state.count} <button onClick={inc}>+</button> <button onClick={dec}>-</button> </div>
    );
  }

}
複製代碼

查看在線示例代碼

能力獲得加強後,能夠自由的按場景挑選合適的方式更新狀態

@register("foo")
class HocClassComp extends Component {
  render() {
    const { greeting } = this.state; // or this.ctx.state
    const {invoke, sync, set, dispatch} = this.ctx;

    // dispatch will find reducer method to change state
    const changeByDispatch = e => dispatch("changeGreeting", evValue(e));
    // invoke cutomized method to change state
    const changeByInvoke = e => invoke(changeGreeting, evValue(e));
    // classical way to change state, this.setState equals this.ctx.setState
    const changeBySetState = e => this.setState({ greeting: evValue(e) });
    // make a method to extract event value automatically
    const changeBySync = sync('greeting');
    // similar to setState by give path and value
    const changeBySet = e=> set('greeting', evValue(e));

    return (
      <>
        <h1>{greeting}</h1>
        <input value={greeting} onChange={changeByDispatch} /><br />
        <input value={greeting} onChange={changeByInvoke} /><br />     
        <input value={greeting} onChange={changeBySetState} /><br />
        <input value={greeting} onChange={changeBySync} /><br />
        <input value={greeting} onChange={changeBySet} />
      </>
    );
  }
}
複製代碼

查看在線示例代碼

下圖是一個完整的concent組件生命週期示意圖:

ins.png

支持中間件與插件

一個好的框架應該是須要提供一些可插拔其餘庫的機制來彈性的擴展額外能力的,這樣有利於用戶額外的定製一些個性化需求,從而促進框架周邊的生態發展,因此一開始設計concent,就保留了中間件與插件機制,容許定義中間件攔截全部的數據變動提交記錄作額外處理,也支持自定義插件接收運行時的各類信號,加強concent能力。

image.png

定義中間件並使用

一箇中間就是一個普通函數

import { run } from 'concent';
const myMiddleware = (stateInfo, next)=>{
  console.log(stateInfo);
  next();//next必定不能忘記
}

run(
  {...}, //store config
  {
    middlewares: [ myMiddleware ] 
  }
);
複製代碼

定義插件並使用

一個插件就是一個必需包含install方法的普通對象

import { cst, run } from 'concent';

const myPlugin = {
  install: ( on )=>{
    //監聽來自concent運行時的各類信號,並作個性化處理
    on(cst.SIG_FN_START, (data)=>{
      const { payload, sig } = data;
      //code here
    })
  }

  return { name: 'myPlugin' }//必需返回插件名
}
複製代碼

現基於插件機制已提供以下插件

image.png

擁抱現有的react生態

固然concent不會去造無心義的輪子,依然堅持擁抱現有的react生態的各類優秀資源,如提供的react-router-concent,橋接了react-router將其適配到concent應用裏。

全局暴露history對象,享受編程式的導航跳轉。

import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import { ConnectRouter, history, Link } from 'react-router-concent';
import { run, register } from 'concent';

run();

class Layout extends Component {
  render() {
    console.log('Layout Layout');
    return (
      <div>
        <div onClick={() => history.push('/user')}>go to user page</div>
        <div onClick={() => history.push('/user/55')}>go to userDetail page</div>
        {/** 能夠基於history主動push,也可使用Link */}
        <Link to="/user" onClick={to => alert(to)}>to user</Link>
        <div onClick={() => history.push('/wow')}>fragment</div>
        <Route path="/user" component={User_} />
        <Route path="/user/:id" component={UserDetail_} />
        <Route path="/wow" component={F} />
      </div>
    )
  }
}

const App = () => (
  <BrowserRouter>
    <div id="app-root-node">
      <ConnectRouter />
      <Route path="/" component={Layout} />
    </div>
  </BrowserRouter>
)
ReactDOM.render(<App />, document.getElementById('root'));
複製代碼

點我查看在線示例

結語&思考

concent的工做機制核心是依賴標記引用收集狀態分發,經過構建全局上下文和實例上下文,並讓二者之間產生互動來實現狀態管理的訴求,並進一步的實現組件能力加強。

理論上基於此原理,能夠爲其餘一樣基於pull based更新機制的ui框架實現狀態管理,並讓他們保持一致的api調用能力和代碼書寫風格,如小程序this.setDataomithis.update

同時由於concent提供了實例上下文對象ctx來升級組件能力,因此若是咱們提出一個目標:可讓響應式不可變共存,看起來是可行的,只須要再附加一個和state對等的可觀察對象在ctx上,假設this.ctx.data就是咱們構建的可觀察對象,而後所提到的響應式須要作到針對不一樣平臺按不一樣策略處理,就能達到共存的目的了。

  • 針對自己就是響應式的框架如angualrvue,提供this.ctx.data去直接修改狀態至關於橋接原有的更新機制,而reducer返回的狀態最終仍是落到this.ctx.data去修改來驅動視圖渲染。
  • 針對pull based的框架如react,提供this.ctx.data只是一種僞的響應式,在this.ctx.data收集到的變動最終仍是落到this.setState去驅動視圖更新,可是的確讓用戶使用起來以爲是直接操做了數據就驅動了視圖的錯覺。 因此若是實現了這一層的統一,是否是concent能夠用一樣的編碼方式去書寫全部ui框架了呢?

固然,大一統的願望是美好的,但是真的須要將其實現嗎?各框架裏的狀態管理方案都已經很成熟,我的有限的精力去作實現這份願景必然又是選擇了一條最最艱辛的路,因此這裏只是寫出一份我的對讓響應式不可變共存的的思考整理,給各位讀者提供一些參考意見去思考狀態管理和ui框架之間的發展走向。

若是用一句詩形容狀態管理與ui框架,我的以爲是

金風玉露一相逢,便勝卻人間無數。

二者相互成就對方,相互扶持與發展,見證了這些年各類狀態庫的更替。

目前concent暫時只考慮與react作整合,致力於提升它們之間的默契度,指望逐步的在大哥redux而二哥mobx的地盤下,佔領一小塊根據地生存下來,若是讀者你喜歡此文,對concent有意,歡迎來star,相信革命的火種必定可以延續下去,concent的理念必定能走得更遠。

相關文章
相關標籤/搜索