【Concent雜談】精確更新策略

一晃就到2020年了,時間過得真的是飛快,伴隨着q羣一些熱心小夥伴的反饋和我我的實際的業務落地場景,Concent已進入一個很是穩定的運行階段了,在此開年之際,新開一個雜談系列,會不按期更新,用於作一些總結或者回顧,內容比較隨心,想到哪裏寫到哪裏,不會擡拘於風格和形式,重在探討和溫故知新,並激發靈感,本期雜談的主題是精確更新,文章將綜合對比現有業界的各類方案,來看看Concent如何另闢蹊徑,給React加上精確更新這門不可或缺的重型武器吧。javascript

變化檢測,套路多多

本文主題是精確更新,爲什麼這裏要提變化檢測呢,由於歸根到底,3個框架AngularVueReact可以實現數據驅動視圖,本質就是須要首先創建起一套完善的機制來感知到數據發生變化且是哪些數據發生變化了,從而進一步去作數據對應的視圖更新工做。html

那麼差別化的部分就是各家對如何感知到數據發生變化了這個細節的具體實現了,下面咱們淺顯的總結一下它們的變化檢測套路。vue

ng之髒檢查&zone

這裏主要說的是ng2以後改進髒檢查機制,在咱們寫下下面一段代碼聲明瞭這樣一個組件後,在每個組件實例化的過程當中ng都會配套維護着一個變化檢測器,因此視圖渲染完畢生成dom樹後,其實ng也同時擁有了一個變化檢測樹,angular利用zone優化了整個變化檢測週期的觸發時機,每一輪變化檢測週期內經過淺比較收集到發生改變的屬性來進一步以爲該更新哪些dom片斷了,同時也配套提供ChangeDetectorRef來讓用戶重寫變化檢測規則,人工干預某個組件的變化檢測關閉和激活時機,來進一步提高性能。java

一個簡單的angular組件以下react

@Component({
  template: ` <h1>{{firstName}} {{lastName}}</h1> <button (click)="changeName()">change name</button> `
})
class MyApp {
  firstName:string = 'Jim';
  lastName:string = 'Green';

  changeName() {
    this.firstName = 'JimNew';
    this.lastName = 'GreenNew';
  }
}
複製代碼

注意上文裏提到了在變化檢測週期內經過淺比較收集變化屬性,這也是爲何當成員變量是對象時,咱們須要重賦值對象引用,而不是改原有引用的值,以免檢測失效。git

@Component(/**略*/)
class MyApp {
  list: string[] = [];

  changeList() {
    const list = this.list;
    list.push('new item');
    this.list = list;// bad
    this.list = list.slice();// good
  }
}
複製代碼

Vue之數據劫持&發佈訂閱

Vue號稱響應式的mvvm,核心原理就是在你實例化你的vue組件時,框架劫持了你的組件數據源,轉變爲一個個Observable可觀察對象,因此模板裏的各類取值表達式在模板編譯爲函數期間或者再次渲染期間都隱式的觸發了可觀察對象的getter,這樣vue就順利的收集到了不一樣視圖對不一樣數據的依賴,這些依賴Dep則添加相關的訂閱者Watcher實例(即組件實例,每一個組件實例都對應一個 watcher 實例),當若是用戶修改了數據則隱式的觸發了setter,框架感知到了數據變動就會發布通知,讓全部訂閱者更新內容,改變視圖(即調用了相關組件實例的update方法)github

一個簡單的vue組件以下(採用單文件寫法):編程

<template>
  <h1>{{firstName}} {{lastName}}</h1>
  <button @click="changeName">change name</button>
</template>

<script> export default { data() { return { firstName: "Jim", lastName: "Green", } }, methods: { changeName: function () { this.firstName = 'JimNew'; this.lastname = 'GreenNew'; } } } </script>
複製代碼

固然了,可觀察對象的轉換也並非如咱們想象的那樣所有轉換掉,vue爲了性能考慮會折中考慮只監聽一層,若是對象層級過深時,watch表達式裏須要用戶手寫深度監聽函數,對象賦值處須要調用工具函數來處理redux

  • 舉例1
methods: {
      changeName: function () {
        this.somObj.name = 'newName';// bad
        Vue.set(this.somObj, 'name', 'newName');// good
        this.somObj = Object.assign({}, this.somObj, {name: 'newName'});// good
      }
    }
複製代碼
  • 舉例2
methods: {
      replaceListItem: function () {
        this.somList[2] = 'newName';// bad
        Vue.set(this.somList, 2, 'newName');// good
      }
    }
複製代碼

固然若是你不想使用工具函數的話,使用$forUpdate也能達到刷新視圖的目的api

methods: {
      replaceListItem: function () {
        // not good, but it works
        this.somList[2] = 'newName';
        this.$forceUpdate();
      }
    }
複製代碼

注,vue2 與 vue3轉變可觀察對象的方式已經不同了,2採用defineProperty,3採用proxy,因此在vue3在對象動態添加屬性這種場景下也能主動感知到數據變化了。

React之調度更新

記得很早以前,尤雨溪的一篇訪談裏談論reactvue的異同時,提到了react是一個pull based的框架而vue是一個push based的框架,兩種設計理念沒有孰好孰壞之分,只有不一樣場景下看誰更適合而已,push based可讓框架主動分析出數據的更新粒度和拆分出渲染區域不一樣依賴,因此對於初學者來講不用關注細節就能更容易寫出一些性能較好的代碼。

react感知到數據變化的入口是setState,用戶主動觸發這個接口,框架拉取到最新的數據從而進行視圖更新,可是其實從react角度來看沒有感知到數據變化一說,由於你只要顯示的調用了setState就表示要驅動進行新一輪的渲染了。

以下面例子所示,上一刻的obj和新的obj是同一個引用,點擊了按鈕照樣會觸發視圖渲染。

class Foo extends React.Component{
    state = { obj:{} };
    handleClick = ()=> this.setState({obj:this.state.obj});
    render(){
        return <button onCLick={this.handleClick}>click me</button>
    }
}
複製代碼

因此很顯然react把變化檢測這個這一步交給了用戶,若是obj沒有變化,你爲何要調用setState呢,若是你調用了就是告訴react須要更新視圖了,哪怕上一刻和下一刻數據源如出一轍也同樣會更新視圖。

更重要的是,默認狀況下react組件是至上而下所有渲染的,因此react配套出了shouldComponentUpdate接口,React.memo接口和PureComponent組件等來幫助react識別出不須要更新的視圖區域,來阻礙這種株連式的更新策略,從而致使了有些人議論react學習曲線較大,給人更多的心智負擔。

固然了,react16以後穩定了的Context api也算是變化檢測的手段之一,經過Context.Provider來從某個組件根節點注入關心變化的對象,在根節點裏各個子孫節點須要消費的具體數據處包裹Context.Comsumer來達到目的。

React&Redux之發佈訂閱

上面咱們提到裸寫的react是沒有變化檢測的,可是提供了配套的函數來輔助其完成檢測,社區裏固然也有很多優秀的方案,如redux,提供一個全局的單一數據源,讓不一樣的視圖監聽數據源裏不一樣的數據,從而當用戶修改數據時,遍歷全部監聽去執行對應回調。

固然redux自己與框架無關只是一個庫,具體的變化檢測須要框架相關的對應的去實現,這裏咱們要提到的實現就是react-redux了,提供了connect裝飾器來幫助組件完成檢測過程,以便決定組件是否須要被更新。

咱們來看一個典型的使用了redux的組件

const mapStateToProps = state => {
  return { loginName: state.login.name, product: state.product };
}

@connect(mapStateToProps)
class Foo extends React.Component {
  render() {
    const { loginName, product } = this.props;
    // 渲染邏輯略
  }
}
複製代碼

mapStateToProps實際上是一個狀態選擇操做,挑出想要的狀態映射到實例的props上,變化檢測發生哪一步呢?經過源碼咱們會知道connect經過高階組件,在包裹層完成了訂閱操做以便監聽store數據變化,訂閱的回調函數計算出當前組件該不應渲染,咱們實例化的組件時實際上是包裹後的組件,該組件實現了shouldComponentUpdate行爲,在它重渲染期間會按照react的生命週期流程調用到shouldComponentUpdate以決定當前組件實例是否須要更新。

注意咱們提到了一個訂閱機制,由於redux自身的實現原理,當單一狀態樹上任何一個數據節點發生改變時,其實因此高階組件的訂閱回調都會被執行,具體組件該不應更新,回調函數裏會淺比較前一刻的狀態和後一刻狀態來決定當前實例需不要更新,因此這也是爲何redux強調若是狀態改變了,必定老是要返回新的狀態,以便輔助淺比較可以正常工做,固然順帶實現了時間回溯功能,可是大多數時候咱們的應用自己是不須要此功能的,而redux-dev-tool卻是很是依賴單一狀態在不一樣時間的快照來實現重放功能。

因此從使用者角度來講,不須要顯示去關心shouldComponentUpdate也可以寫出性能更好的應用了。

下面示例演示了state發生了改變,必需老是返回最新的

const initState = { list: [] };
export const oneReudcer = (state = initState, action) => {
  const { type, payload } = action;
  switch (type) {
    case 'ADD':
      const list = state.list;
      list.push(payload);
      
      return { list: [...list] };// right
      return { list] };// wrong !!!
    default:
      return state;
  }
}
複製代碼

由於list提高到了store,因此咱們在react組件某個方法裏若是寫爲下面格式是起效的,可是放redux裏,就必須嚴格按照它的運行規則來。

const list = this.state.list;
    list.push(payload);
    this.setState({list})
複製代碼

React&Mobx之可觀察對象

某種程度來講,mobx結合了react後有種vue的味道了,mobx也有一個本身的store,可是數據都是observalbe的,因此同樣的主動檢測到數據變化。

當時代碼組織方式更oop而非函數式。

React&Concent之調度更新

Concent本質上也沒有擴展額外的檢測策略,和react保持100%一致,setState就是更新入口,reactsetState行爲和ConcentsetState行爲徹底同樣,惟一的區別就是Concent爲了用戶的書寫體驗新增了其餘更新入口函數,以及擴展了函數的參數(非必需填入)。

咱們先建立store的一個子模塊foo來演示下3種主要入口

import { run } from 'concent';
run({
  foo: {//聲明一個模塊foo
    state: { list: [], name:'' }
  }
});
複製代碼
  • 入口1setState
import { register, useConcent } from 'concent';

//類寫法
@register('foo')
class CompClazz extends React.Component {
  addItem = () => {
    const list = this.state.list;
    list.push(Math.random());
    this.setState({ list });// trigger render
  }
  render() {
    return (
      <div> {this.state.list.length} <button onCLick={this.addItem}>add item</button> </div>

    )
  }
}

//函數寫法
function CompFn() {
  const ctx = useConcent('foo');
  addItem = () => {
    const list = ctx.state.list;
    list.push(Math.random());
    ctx.setState({ list });// trigger render
  };

  return (
    <div> {ctx.state.list.length} <button onCLick={ctx.addItem}>add item</button> </div>
  )
}

複製代碼

固然了上面寫法裏咱們能夠進一步優化下,抽出setup,避免了函數組件裏重複建立新的函數,同時能夠和類一塊兒複用

const setup = (ctx) => {
  return {
    addItem = () => {
      const list = ctx.state.list;
      list.push(Math.random());
      ctx.setState({ list });// trigger render
    }
  }
}

@register({ module: 'foo', setup })
class CompClazz extends React.Component {
  render() {
    return (
      <div> {this.state.list.length} <button onCLick={this.ctx.settings.addItem}>add item</button> </div>
    )
  }
}
//函數寫法
function CompFn() {
  const ctx = useConcent({ module: 'foo', setup });
  return (
    <div> {ctx.state.list.length} <button onCLick={ctx.settings.addItem}>add item</button> </div>
  )
}
複製代碼
  • 入口2dispatch

先在模塊定義裏添加reducer函數

run({
  foo: {//聲明一個模塊foo
    state: { list: [], name: '' },
    reducer: {
      addItem(payload, moduleState) {// 定義reducer函數
        const list = moduleState.list;
        list.push(Math.random());
        return { list };// trigger render
      },
      async addItemAsync(){/** 一樣也支持async函數 */}
    }
  }
});
複製代碼

改寫下setup

const setup = (ctx) => {
  return {
    addItem = () => ctx.dispatch('addItem'),
    // 固然了這裏這直接支持調用reducer函數
    addItem = () => ctx.moduleReducer.addItem(),
  }
}

@register({ module: 'foo', setup })
class CompClazz extends React.Component {/**略*/}

function CompFn() {
  const ctx = useConcent({ module: 'foo', setup });
  /**略*/
}
複製代碼
  • 入口3invoke

invoke直接繞過reducer函數定義,調用用戶的自定義函數改寫狀態,咱們先定義一個addItem函數,它和reducer裏的函數並沒有寫法區別,只是放置的位置不一樣而已,逃離了reducer這個區域,直接和setup放在一塊兒。

function addItem(payload, moduleState) {
  const list = moduleState.list;
  list.push(Math.random());
  return { list };// trigger render
}

const setup = (ctx) => {
  return {
    addItem = () => ctx.invoke(addItem)
  }
}

@register({ module: 'foo', setup })
class CompClazz extends React.Component {/**略*/}

function CompFn() {
  const ctx = useConcent({ module: 'foo', setup });
  /**略*/
}
複製代碼

總之無論形式怎麼變,本質仍是和react數據驅動的核心保持一致,即經過入口輸入一個新的片斷狀態,觸發視圖渲染。

精確更新,誰更勝一籌

這裏談到了精確更新,咱們先明確爲什麼須要精確更新,當咱們的數據提高到store後,有多個組件消費着store不一樣模塊的不一樣部分數據,注意這裏提到的模塊,redux裏自己是沒有模塊的概念的,儘管子reducer塊看起來有點雛形了,可是dvarematch等基於redux底層封裝出模塊概念更切合咱們的編程思路,將模塊的狀態和修改方法都內聚到一個model下,而不是分散的寫在各個文件裏,讓咱們更友好的按功能來切分各個模塊和組織代碼。

在模塊多且組件多以後,可能會產生了一些錯綜複雜的關係,不一樣組件會鏈接不一樣的多個模塊,消費着模塊裏的不一樣部分數據,當這些模塊裏的數據發生變動時,只應該通知對應的關心者觸發渲染,而不是暴力的所有都渲染,因此咱們須要一些額外的機制來保證渲染區域的精確度,即最大限度的縮小渲染範圍,已得到更高的運行時性能。

如下咱們提出的案例場景,以及精確更新比較,主要是針對react內部的3個框架react-reduxreact-mobxconcent三個庫作比較,再也不說起vueangular

單個模塊,消費不一樣的key

這種場景很是常見,多個組件消費同一個模塊的數據,可是消費的粒度不同,假設咱們有以下一個模塊的狀態

bookState = {
    name:'',
    age:'',
    list: [],
}
複製代碼

組件A鏈接book模塊,消費nameage,組件B鏈接book模塊消費list,組件C鏈接book模塊全部數據

  • redux 案例僞代碼
@connect(state=> ({name: state.book.name, age: state.book.age }))
class A extends React.Component{}

@connect(state=> ({list: state.book.list }))
class B extends React.Component{}

@connect(state=> state.book)
class C extends React.Component{}
複製代碼
  • mobx 案例僞代碼
@inject('book')
@observer
class A extends React.Component{
  render(){
    const { name, age } = this.props.book;
    //使用name,age
  }
}

@inject('book')
@observer
class B extends React.Component{
  render(){
    const { list } = this.props.book;
    //使用list
  }
}

@inject('book')
@observer
class C extends React.Component{
  render(){
    const { name, age, list } = this.props.book;
    //使用name age list
  }
}
複製代碼
  • concent 案例僞代碼
@register({ module:'book', watchedKeys:['name', 'age']})
class A extends React.Component{
  render(){
    const { name, age } = this.state;
    //使用name,age
  }
}

@register({ module:'book', watchedKeys:['list']})
class B extends React.Component{
  render(){
    const { list } = this.state;
    //使用list
  }
}

@register('book')// 感知book模塊的所有key變化,就不須要在顯式的指定watchedKeys範圍了
class C extends React.Component{
  render(){
    const { name, age, list } = this.state;
    //使用name age list
  }
}
複製代碼

以上代碼都在約束react的渲染範圍,從寫法來看,mbox自動的完成了依賴收集,concent因其依賴標記原理須要顯示的讓用戶標定須要感知變化的key,因此會多一些筆墨,redux這須要connnect經過函數完成狀態的挑選,會有更多的代碼產生,因此代碼輕量程度來講結果是

mobx>concent>redux

效率來講,mboxconcent都是在作精準通知,由於mbox經過getter收集到數據變動關聯的視圖依賴,而concent經過依賴標記和引用收集完成了數據變動關聯的視圖依賴,當數據變動時都是直接通知相對應的視圖直接更新,而redux須要遍歷全部的listeners,觸發全部實例的訂閱回調函數,又回調函數計算出當前訂閱組件實例需不須要更新。

Concent本身維護着一個全局上下文,用於分類和索引全部的組件實例,任何一個Concent組件實例修改狀態的行爲都會攜帶有模塊信息,當狀態改變的那一刻,Concent已知道該怎麼分發狀態到其餘實例!

索引模塊與類的關係

索引類和類實例的關係

鎖定相關實例觸發更新

因此效率上來講結果是

(mobxconcent)>redux

由於其不一樣的場景有不一樣的測試準則mobxconcent還暫時作不出比較。

單個模塊,消費目標是key類型爲list或者map結構下的某個元素

這個場景很常見,例如遍歷某個list下的全部元素,爲每個元素渲染一個組件,這個組件可以走統一的方法修改本身在store裏的數據,可是由於修改的本身的數據,理論上來講只應該觸發本身渲染,而不是觸發整個list渲染.

  • redux僞代碼

如下代碼暫時沒法實現此場景,由於基於redux的設計目前還辦不到這一點,對於經過store的list遍歷出來的視圖,沒法經過參數來標記當前組件消費消費的是某一個下標的元素,同時又修改了它處於list裏某個位置的元素數據後,只渲染這個元素對應的視圖。

// BookItem聲明
@conect(state => {
  return { list: state.book.list },
}, dispatch=>{
  return {modBookName: (idx)=> dispatch('modBookName', idx)}    
})
class BookItem extends React.Component(){
  render(){
    const { idx, list } = this.props;
    const bookData = list[idx];
    const modBookName = ()=> this.props.modBookName(idx);
    // ui 略
  }
}

// BookItemContainer聲明
@conect(state => {
  return { list: state.book.list }
})
class BookItemContainer extends React.Component(){
  render(){
    const { list } = this.props;
    return (
      <div> {list.map((v, idx) => <BookItem key={idx} idx={idx} />)} </div> ) } } 複製代碼

reducer裏

export const book = (state, action)=>{
    switch(action.type){
        case 'modBookName':
        const list = state.list;
        const idx = action.payload;
        const bookItem = list[idx];
        bookItem.name = Math.random();
        
        // 此處一定會引發整個BookItemContainer以及包含的全部BookItem重渲染
        return {list:[...list]};
    }
}
複製代碼
  • concent僞代碼
@register({module:'book', watchedKeys:['list']})
class BookItem extends React.Component(){
  render(){
    const { list } = this.state;
    const bookData = list[this.props.idx];
    const renderKey = this.ctx.ccUniqueKey;
    
    //dispatch(type:string, payload?:any, renderKey?:string)
    const modBookName = ()=> this.ctx.dispatch('modBookName', idx, renderKey;
    
    //也能夠寫爲
    const modBookName = ()=> this.ctx.moduleReducer.modBookName(idx, renderKey);
  }
}

// BookItemContainer聲明
@register({module:'book', watchedKeys:['list']})
class BookItemContainer extends React.Component(){
  render(){
    const { list } = this.state;
    return (
      <div> {list.map((v, idx) => <BookItem key={idx} idx={idx} />)} </div> ) } } 複製代碼

當實例攜帶renderKey調用時,concent會去尋找和傳遞的renderKey值同樣的實例觸發渲染,而每個cc實例,若是沒有人工設置renderKey的話,默認的renderKey值就是ccUniqueKey(即每個cc實例的惟一索引),因此當咱們擁有大量的消費了store某個模塊下同一個key如sourceList(一般是map和list)下的不一樣數據的組件時,若是調用方傳遞的renderKey就是本身的ccUniqueKey, 那麼renderKey機制將容許組件修改了sourceList下本身的數據同時也只觸發本身渲染,而不觸發其餘實例的渲染,這樣大大提升這種list場景的渲染性能。

此示例完整代碼在線示例見此處 stackblitz.com/edit/concen…

  • mbox 僞代碼 mobx也能作到上述場景描述問題,可是mobx自己轉換數組爲observable就是一筆不可忽視的開銷,特別是數組很大很長時,此處暫時再也不列出僞代碼。

總結

redux的更新機制在典型的list或者map場合已不能知足需求,mobxconcent都能知足,mobx偏向於oop的方式組織代碼,concent完全的面向函數式且因其setState就與store打通了的能力,能與react天生無縫結合,能夠若入侵的直接接入,且其精確更新能力依然保持非凡實力。

另外concent獨立代碼組織方式,讓你少寫大量中間代碼且架構更爲優雅,以下兩個計算器示例。

實例1基於hook,來自於一個印度同志。 點我查看實例1

實例2基於concent,上圖中箭頭處都將抽象爲model的不一樣部分。點我查看實例1

最後的視圖渲染則經過useConcent輕鬆和模塊打通。

結語

❤ star me if you like concent ^_^,Concent的發展離不開你們的精神鼓勵與支持,也期待你們瞭解更多和提供相關反饋,讓咱們一塊兒構建更有樂趣,更加健壯和更高性能的react應用吧。

強烈建議有興趣的你進入在線IDE fork代碼修改哦(如點擊圖片無效可點擊文字連接)

Edit on CodeSandbox

https://codesandbox.io/s/concent-guide-xvcej

Edit on StackBlitz

https://stackblitz.com/edit/cc-multi-ways-to-wirte-code

若是有關於concent的疑問,能夠掃碼加羣諮詢,我會盡力答疑解惑,幫助你瞭解更多。

相關文章
相關標籤/搜索