【Concent杂谈】精确更新策略

3,149 阅读20分钟

一晃就到2020年了,时间过得真的是飞快,伴随着q群一些热心小伙伴的反馈和我个人实际的业务落地场景,Concent已进入一个非常稳定的运行阶段了,在此开年之际,新开一个杂谈系列,会不定期更新,用于做一些总结或者回顾,内容比较随心,想到哪里写到哪里,不会抬拘于风格和形式,重在探讨和温故知新,并激发灵感,本期杂谈的主题是精确更新,文章将综合对比现有业界的各种方案,来看看Concent如何另辟蹊径,给React加上精确更新这门不可或缺的重型武器吧。

变化检测,套路多多

本文主题是精确更新,为何这里要提变化检测呢,因为归根到底,3个框架AngularVueReact能够实现数据驱动视图,本质就是需要首先建立起一套完善的机制来感知到数据发生变化且是哪些数据发生变化了,从而进一步去做数据对应的视图更新工作。

那么差异化的部分就是各家对如何感知到数据发生变化了这个细节的具体实现了,下面我们浅显的总结一下它们的变化检测套路。

ng之脏检查&zone

我们知道angular团队从ng1升级到ng2进行了底层的重写,所以产生了很多破坏性的变更,ng1称为AngularJs,ng2及其之后的版本都统称为Angular,而这里主要说的是ng2之后(包含ng2)改进的脏检查机制。

在我们写下下面一段代码声明了这样一个组件后,在每一个组件实例化的过程中ng都会配套维护着一个变化检测器,所以视图渲染完毕生成dom树后,其实ng也同时拥有了一个变化检测树,angular利用zone优化了整个变化检测周期的触发时机,每一轮变化检测周期内通过浅比较收集到发生改变的属性来进一步觉得该更新哪些dom片段了,同时也配套提供ChangeDetectorRef来让用户重写变化检测规则,人工干预某个组件的变化检测关闭和激活时机,来进一步提升性能。

一个简单的angular组件如下

@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';
  }
}

注意上文里提到了在变化检测周期内通过浅比较收集变化属性,这也是为什么当成员变量是对象时,我们需要重赋值对象引用,而不是改原有引用的值,以避免检测失效。

@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实例即组件实例,每个组件实例都对应一个 Watcher实例),当如果用户修改了数据则隐式的触发了setter,框架感知到了数据变更,Dep会发布通知,让相对应的订阅者触发再次渲染,从而改变视图(即调用了相关组件实例的update方法)

一个简单的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表达式里需要用户手写深度监听函数,对象赋值处需要调用工具函数来处理

  • 举例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也能达到刷新视图的目的

    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数据驱动的核心保持一致,即通过入口输入一个新的片段状态,触发视图渲染,但是相比react,悄悄的多添加了一层元数据管理,让组件实例化那一刻就与模块产生了联系,所以才能以此作为铺垫引出concent的精确更新策略。

注意所谓的元数据,就是上面的代码里register调用传入的那些参数,当组件实例化后这些参数就带入到了实例上的ctx属性上,此处让我们可以观察一个打印在控制台的concent组件实例

我们可以看到react通过给组件实例附加_reactInternalFiber属性实现Fiber特性,基于Fiber的链表式树结构可以模拟出函数调用栈,进一步实现了hooksuspense等特性,concent采取同样的类似思路,给所有需要实现状态管理的组件实例附加ctx属性,在这上面记录了组件定义阶段标识的模块和观察依赖等元数据信息,从而在逻辑层面建立起一套更优的更新调度机制,同时也不破坏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还暂时做不出比较。

查看watchedKeys在线示例

单个模块,消费目标是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就是一笔不可忽视的开销,特别是数组很大很长时,此处暂时不再列出伪代码。

多模块消费数据

多模块消费数据和单模块没有区别,这里不再详细赘述,只是小提一下,Concent里组件和模块存在两种关系,一种是属于,一种是连接,属于关系下组件只能属于一个模块,连接关系下组件可以连接多个模块,当组件属于一个模块的,所以模块的数据能够直接注入到state里,如果存在消费多个模块的数据,则写法上有些区别,

  • 属于foo模块
// 注册组件
@register('foo')
// or
@register({module:'foo'})

//获取数据
render(){
  const {f1, f2} = this.state;
}
  • 连接到foo模块
@register({connect:['foo']})

//获取数据
render(){
  const {f1, f2} = this.ctx.connectedState.foo;
}
  • 连接到foo、 bar模块
@register({connect:['foo', 'bar']})

//获取foo, bar模块数据
render(){
  const {foo, bar} = this.ctx.connectedState;
}
  • 连接到foo、 bar模块,关心foo所有key变化,关心bar模块部分key变化
@register({connect:{foo:'*',bar:['key1','key2']}})

虽然依赖标记和获取数据写法有差异,但是renderKey是一样的运作机制,所以效率上不会因为是多模块而降低。

总结

redux的更新机制在典型的list或者map场合已不能满足需求,mobxconcent都能满足,mobx偏向于oop的方式组织代码,concent彻底的面向函数式且因其setState就与store打通了的能力,能与react天生无缝结合,可以若入侵的直接接入,且其精确更新能力依然保持非凡实力。

另外concent弹性灵活的api,让你更容易搭建出关注点分离、职责清晰、架构稳健的代码组织方式,如下两个计算器示例。

实例1基于hook,来自于一个印度同志。 点我查看示例1

实例2基于concent,上图中箭头处都将抽象为model的不同部分。点我查看示例2

最后的视图渲染则通过useConcent轻松和模块打通。

结语

❤ star me if you like concent ^_^,Concent的发展离不开大家的精神鼓励与支持,也期待大家了解更多和提供相关反馈,让我们一起构建更有乐趣,更加健壮和更高性能的react应用吧。

强烈建议有兴趣的你进入在线IDE fork代码修改哦(如点击图片无效可点击文字链接)

Edit on CodeSandbox(js)

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

Edit on CodeSandbox(ts),git仓库地址点我 ts-git-repo

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

Edit on StackBlitz

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

如果有关于concent的疑问,可以扫码加群咨询,我会尽力答疑解惑,帮助你了解更多。