concent 骚操作之组件创建&状态更新

8,157 阅读12分钟

❤ 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: [],
    }
}

export default getInitialState();

使用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去操作了。

现在让我们通过gif图演示看看实际效果吧

因为这些实例都是属于demo模块的组件,所以无论我修改任何一处,其他地方视图都会同步被更新,是不是将特别方便呢?

使用sync更新

当然如果对于这种单个key的更新,我们也可以不用写setState,而是直接使用concent提供的工具函数sync来完成值的提取与更新

// 改写HookComp使用sync来更新,其他组件写法都一样,class组件通过this.ctx.sync来更新
function HookComp(){
  const ctx = useConcent('demo', 'HookComp');
  const {state: { name, age, visible, infos }, sync } = 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=>{
  //这里其实还可以搞更多的事儿,诸如ctx.computed, ctx.watch, ctx.effect 等,下期再聊✧(≖ ◡ ≖✿)
  return {
    updateNameComplex: (e)=>ctx.dispatch('updateNameComplex',e.currentTarget.value),
  }
}
function HookComp(){
  // setup只会在组件初次渲染之前触发一次!
  const ctx = useConcent({module:'demo', setup}, '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({module:'demo', setup}, '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 ^_^