一晃就到2020年了,时间过得真的是飞快,伴随着q群一些热心小伙伴的反馈和我我的实际的业务落地场景,Concent
已进入一个很是稳定的运行阶段了,在此开年之际,新开一个杂谈系列,会不按期更新,用于作一些总结或者回顾,内容比较随心,想到哪里写到哪里,不会抬拘于风格和形式,重在探讨和温故知新,并激发灵感,本期杂谈的主题是精确更新,文章将综合对比现有业界的各类方案,来看看Concent
如何另辟蹊径,给React
加上精确更新这门不可或缺的重型武器吧。javascript
本文主题是精确更新,为什么这里要提变化检测呢,由于归根到底,3个框架Angular
、Vue
和React
可以实现数据驱动视图,本质就是须要首先创建起一套完善的机制来感知到数据发生变化且是哪些数据发生变化了,从而进一步去作数据对应的视图更新工做。html
那么差别化的部分就是各家对如何感知到数据发生变化了这个细节的具体实现了,下面咱们浅显的总结一下它们的变化检测套路。vue
这里主要说的是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
号称响应式的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
methods: {
changeName: function () {
this.somObj.name = 'newName';// bad
Vue.set(this.somObj, 'name', 'newName');// good
this.somObj = Object.assign({}, this.somObj, {name: 'newName'});// good
}
}
复制代码
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
和vue
的异同时,提到了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
,提供一个全局的单一数据源,让不一样的视图监听数据源里不一样的数据,从而当用户修改数据时,遍历全部监听去执行对应回调。
固然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})
复制代码
某种程度来讲,mobx
结合了react
后有种vue
的味道了,mobx
也有一个本身的store
,可是数据都是observalbe
的,因此同样的主动检测到数据变化。
当时代码组织方式更oop
而非函数式。
Concent
本质上也没有扩展额外的检测策略,和react
保持100%一致,setState
就是更新入口,react
的setState
行为和Concent
的setState
行为彻底同样,惟一的区别就是Concent
为了用户的书写体验新增了其余更新入口函数,以及扩展了函数的参数(非必需填入)。
咱们先建立store的一个子模块foo
来演示下3种主要入口
import { run } from 'concent';
run({
foo: {//声明一个模块foo
state: { list: [], name:'' }
}
});
复制代码
setState
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>
)
}
复制代码
dispatch
先在模块定义里添加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 });
/**略*/
}
复制代码
invoke
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
块看起来有点雏形了,可是dva
、rematch
等基于redux
底层封装出模块概念更切合咱们的编程思路,将模块的状态和修改方法都内聚到一个model
下,而不是分散的写在各个文件里,让咱们更友好的按功能来切分各个模块和组织代码。
在模块多且组件多以后,可能会产生了一些错综复杂的关系,不一样组件会链接不一样的多个模块,消费着模块里的不一样部分数据,当这些模块里的数据发生变动时,只应该通知对应的关心者触发渲染,而不是暴力的所有都渲染,因此咱们须要一些额外的机制来保证渲染区域的精确度,即最大限度的缩小渲染范围,已得到更高的运行时性能。
如下咱们提出的案例场景,以及精确更新比较,主要是针对react内部的3个框架
react-redux
、react-mobx
、concent
三个库作比较,再也不说起vue
和angular
这种场景很是常见,多个组件消费同一个模块的数据,可是消费的粒度不同,假设咱们有以下一个模块的状态
bookState = {
name:'',
age:'',
list: [],
}
复制代码
组件A链接book模块,消费name
与age
,组件B链接book模块消费list
,组件C链接book模块全部数据
@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{}
复制代码
@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
}
}
复制代码
@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
效率来讲,mbox
和concent
都是在作精准通知,由于mbox
经过getter
收集到数据变动关联的视图依赖,而concent
经过依赖标记和引用收集完成了数据变动关联的视图依赖,当数据变动时都是直接通知相对应的视图直接更新,而redux
须要遍历全部的listeners,触发全部实例的订阅回调函数,又回调函数计算出当前订阅组件实例需不须要更新。
Concent本身维护着一个全局上下文,用于分类和索引全部的组件实例,任何一个Concent组件实例修改状态的行为都会携带有模块信息,当状态改变的那一刻,Concent已知道该怎么分发状态到其余实例!
索引模块与类的关系
索引类和类实例的关系
锁定相关实例触发更新
因此效率上来讲结果是
(mobx
,concent
)>redux
由于其不一样的场景有不一样的测试准则mobx
和concent
还暂时作不出比较。
这个场景很常见,例如遍历某个list下的全部元素,为每个元素渲染一个组件,这个组件可以走统一的方法修改本身在store里的数据,可是由于修改的本身的数据,理论上来讲只应该触发本身渲染,而不是触发整个list渲染.
如下代码暂时没法实现此场景,由于基于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]};
}
}
复制代码
@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…
redux
的更新机制在典型的list或者map场合已不能知足需求,mobx
和concent
都能知足,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代码修改哦(如点击图片无效可点击文字连接)
若是有关于concent的疑问,能够扫码加群咨询,我会尽力答疑解惑,帮助你了解更多。