深度挖掘Concent的effect,全面提升useEffect的开发体验

4,980 阅读14分钟

❤ star me if you like concent ^_^

管理副作用代码

在hook还没有诞生时,我们通常都会在class内置的生命周期函数componentDidMountcomponentDidUpdatecomponentWillUnmount书写副作用逻辑。

这里就不再讨论componentWillUpdatecomponentWillReceiveProps了,因为随着react支持异步渲染后,这些功能已标记为不安全,让我们跟随者历史的大潮流,彻底忘记他们吧😀

我们来举一个最典型的应用场景如下:

class SomePage extends Component{
    state = { products: [] }
    componentDidMount(){
        api.fetchProducts()
        .then(products=>this.setState({products}))
        .catch(err=> alert(err.message));
    }
}

这样的类似代码是你100%一定曾经写过的,表达的含义也很简单,组件初次挂载完毕时,获取一下产品列表数据。

我们的页面通常都会是这样子的,头部是一个条件输入或者选择区域,中央大块区域是一个表格,现在我们对这个页面提一些需求,选择区域里任何值发生改变时,都触发自动查询更新列表,组件销毁时做些其他事情,亲爱的读者一定都写过类似如下代码:

class SomePage extends Component{
    state = { products: [], type:'', sex:'', addr:'', keyword:'' }
    
    componentDidMount(){
        this.fetchProducts();
    }
    
    fetchProducts = ()=>{
        const {type, sex, addr, keyword} = this.state;
        api.fetchProducts({type, sex, addr, keyword})
        .then(products=>this.setState({products}))
        .catch(err=> alert(err.message));
    }
    
    changeType = (e)=> this.setState({type:e.currentTarget.value})
    
    changeSex = (e)=> this.setState({sex:e.currentTarget.value})
    
    changeAddr = (e)=> this.setState({addr:e.currentTarget.value})
    
    changeKeyword = (e)=> this.setState({keyword:e.currentTarget.value})
    
    componentDidUpdate(prevProps, prevState){
        const curState = this.state;
        if(
            curState.type!==prevState.type ||
            curState.sex!==prevState.sex || 
            curState.addr!==prevState.addr || 
            curState.keyword!==prevState.keyword 
        ){
            this.fetchProducts();
        }
    }
    
    componentWillUnmount(){
        // 这里搞清理事情
    }
    
    render(){
        const { type, sex, addr, keyword } = this.state;
        return (
            <div className="conditionArea">
                <select value={type} onChange={this.changeType} >{/**some options here*/}</select>
                <select value={sex} onChange={this.changeSex}>{/**some options here*/}</select>
                <input value={addr} onChange={this.changeAddr} />
                <input value={keyword} onChange={this.changeKeyword} />
            </div>
        );
    }
}

当然一定有骚气蓬勃的少年不想写那么多change***,在渲染节点里标记data-***来减少代码,大概率如下:

class SomePage extends Component{
    changeKey = (e)=> this.setState({[e.currentTarget.dataset.key]:e.currentTarget.value})
    // 其他略...
    render(){
        const { type, sex, addr, keyword } = this.state;
        return (
            <div className="conditionArea">
                <select data-key="type" value={type} onChange={this.changeKey} >
                    {/**some options here*/}
                </select>
                <select data-key="sex" value={sex} onChange={this.changeKey}>
                    {/**some options here*/}
                </select>
                <input data-key="addr" value={addr} onChange={this.changeKey} />
                <input data-key="keyword" value={keyword} onChange={this.changeKey} />
            </div>
        );
    }
}

如果此组件的某个状态还需要接受来自props的值来更新,那么使用class里的新函数getDerivedStateFromProps替代了不推荐的componentWillReceiveProps,代码书写大致如下:

class SomePage extends Component{
    static getDerivedStateFromProps (props, state) {
        if (props.tag !== state.tag) return {tag: props.tag}
        return null
    }
}

到此,我们完成了class组件对副作用代码管理的讨论,接下来我们让hook粉末登场━(`∀´)ノ亻!

hook爸爸教做人

hook诞生之初,都拿上面类似例子来轮,会将上面例子改写为更简单易懂的例子,分分钟教class组件重新做人😀

我们来看一个改写后的代码

const FnPage = React.memo(function({ tag:propTag }) {
  const [products, setProducts] = useState([]);
  const [type, setType] = useState("");
  const [sex, setSex] = useState("");
  const [addr, setAddr] = useState("");
  const [keyword, setKeyword] = useState("");
  const [tag, setTag] = useState(propTag);//使用来自props的tag作为初始化值

  const fetchProducts = (type, sex, addr, keyword) =>
    api
      .fetchProducts({ type, sex, addr, keyword })
      .then(products => setProducts(products))
      .catch(err => alert(err.message));

  const changeType = e => setType(e.currentTarget.value);
  const changeSex = e => setSex(e.currentTarget.value);
  const changeAddr = e => setAddr(e.currentTarget.value);
  const changeKeyword = e => setKeyword(e.currentTarget.value);

  // 等价于上面类组件里componentDidMount和componentDidUpdate里的逻辑
  useEffect(() => {
    fetchProducts(type, sex, addr, keyword);
  }, [type, sex, addr, keyword]);
  // 填充了4个依赖项,初次渲染时触发此副作用
  // 此后组件处于存在期,任何一个改变都会触发此副作用
  
  useEffect(()=>{
      return ()=>{// 返回一个清理函数
          // 等价于componentWillUnmout, 这里搞清理事情
      }
  }, []);//第二位参数传空数组,次副作用只在初次渲染完毕后执行一次1
  
  useEffect(()=>{
     // 首次渲染时,此副作用还是会执行的,在内部巧妙的再比较一次,避免一次多余的ui更新
     // 等价于上面组件类里getDerivedStateFromProps里的逻辑
     if(tag !== propTag)setTag(tag);
  }, [propTag, tag]);

  return (
    <div className="conditionArea">
      <select value={type} onChange={changeType}>
        {/**some options here*/}
      </select>
      <select data-key="sex" value={sex} onChange={changeSex}>
        {/**some options here*/}
      </select>
      <input data-key="addr" value={addr} onChange={changeAddr} />
      <input data-key="tkeywordype" value={keyword} onChange={changeKeyword} />
    </div>
  );
});

看起来好清爽啊有木有,写起来很骚气似不似?巧妙的利用useEffect替换掉了类组件里各个生命周期函数,而且上下文里完全没有了迷惑的this,真面向函数式编程!

更让人喜欢的是,hook是可以自由组合、自由嵌套的,所以你的这个看起看起来很胖的FnPage里的逻辑可以瞬间瘦身为

function useMyLogic(propTag){
    //刚才那一堆逻辑可以完全拷贝到这里,然后把状态和方法返回出去
    return {
      type, sex, addr, keyword, tag,
      changeType,changeSex,changeAddr, changeKeyword,
    };
}

const FnPage = React.memo(function({ tag: propTag }) {
  const {
    type, sex, addr, keyword, tag,
    changeType,changeSex,changeAddr, changeKeyword,
   } = useMyLogic(propTag);
  // return your ui
});

useMyLogic函数可以在其他任意地方被复用!这将是多么的方便,如果状态更新比较复杂,官方还配套有useReducer来将业务逻辑从hook函数里分离出去,如下代码Dan Abramov给的例子:
点击此处查看在线示例

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    </>
  );
}

😀说到此处,是不是感觉对class无爱了呢?但是这样使用hook组织业务代码真的就完美了吗?没有弱点了吗?

使用Concent的effect,升级useEffect使用体验

useMyLogic的确可以到处复用,useReducer的确将状态从hook函数再度解耦和分离出去,但是它们的问题如下:

  • 问题1,本质上来说,hook是鼓励开发者使用闭包的,因为hook组件函数每一帧渲染创建了对应那一刻的scope,在scope内部生成的各种状态或者方法都将只对那一帧有效,但是我们逃不掉的是每一帧渲染都真真实实的创建了大量临时的闭包函数,不短累计的确给js立即回收带来了一些额外的压力,我们能不能避免掉反复创建临时闭包函数这些这个问题呢?答案是当然可以,具体原因参见往期文章setup带来的变革,这里主要主要讨论useEffect和Concent的effect做对比,针对setup就不在次做赘述。
  • 问题2,useReducer只是解决了解耦更新状态逻辑和hook函数的问题,但是它本身只是一个纯函数,异步逻辑是无法写在里面的,你的异步逻辑最终还是落地到自定义hook函数内部,且useReducer只是一个局部的状态管理,我们能不能痛快的实现状态更新可异步,可同步,能自由组合,且可以轻易的提升为全局状态管理目的呢,答案是当然可以,Concent的invoke接口将告诉你最终答案!
  • 问题3,useEffect的确解决了副作用代码管理的诟病,但是我们将类组件换为函数组件时,需要代码调整和逻辑转换,我们能不能统一副作用代码管理方式,且让类组件和函数组件可以0改造共用呢,答案同样是完全可以,基于Concent的effect接口,你可以一行代码不用改而实现统一的副作用管理,这意味着你的组件可以任你在类与函数之间自由切换!

我们总结一下将要解决的3个问题:

  • 1 避免反复创建临时闭包函数。
  • 2 状态更新可异步,可同步,能自由组合,且可以轻易的提升为全局状态管理目的。
  • 3 统一副作用代码管理方式,让类与函数实现0成本的无痛共享。

让我们开始表演吧

改造FnPage函数组件

构造setup函数

const setup = ctx => {
  console.log('setup函数只会在组件初次渲染之前被执行一次');
  const fetchProducts = () => {
    const { type, sex, addr, keyword } = ctx.state;
    api.fetchProducts({ type, sex, addr, keyword })
      .then(products => ctx.setState({ products }))
      .catch(err => alert(err.message));
  };

  ctx.effect(() => {
    fetchProducts();
  }, ["type", "sex", "addr", "keyword"]);//这里只需要传key名称就可以了
  /** 原函数组件内写法:
    useEffect(() => {
      fetchProducts(type, sex, addr, keyword);
    }, [type, sex, addr, keyword]);
  */

  ctx.effect(() => {
    return () => {
      // 返回一个清理函数
      // 等价于componentWillUnmout, 这里搞清理事情
    };
  }, []);
  /** 原函数组件内写法:
    useEffect(()=>{
      return ()=>{// 返回一个清理函数
        // 等价于componentWillUnmout, 这里搞清理事情
      }
    }, []);//第二位参数传空数组,次副作用只在初次渲染完毕后执行一次
  */

  ctx.effectProps(() => {
    // 对props上的变更书写副作用,注意这里不同于ctx.effect,ctx.effect是针对state写副作用
    const curTag = ctx.props.tag;
    if (curTag !== ctx.prevProps.tag) ctx.setState({ tag: curTag });
  }, ["tag"]);//这里只需要传key名称就可以了
  /** 原函数组件内写法:
  useEffect(()=>{
    // 首次渲染时,此副作用还是会执行的,在内部巧妙的再比较一次,避免一次多余的ui更新
    // 等价于上面组件类里getDerivedStateFromProps里的逻辑
    if(tag !== propTag)setTag(tag);
  }, [propTag, tag]);
 */

  return {// 返回结果收集在ctx.settings里
    fetchProducts,
    //推荐使用此方式,把方法定义在settings里,下面示例故意直接使用sync语法糖函数
    changeType: ctx.sync('type'),
  };
};

setup逻辑构造完毕了,我们来看看函数组件是长什么样子滴

import { useConcent } from 'concent';

//定义状态构造函数,传递给useConcent
const iState = () => ({ products:[], type: "", sex: "", addr: "", keyword: "", tag: "" });

const ConcentFnPage = React.memo(function(props) {
  // useConcent返回ctx,这里直接解构ctx,拿想用的对象或方法
  const { state, settings, sync } = useConcent({ setup, state: iState, props });
  const { products, type, sex, addr, keyword, tag } = state;
  const { fetchProducts } = settings;

  // 下面UI中使用sync语法糖函数同步状态,如果为了最求极致的性能
  // 可将它们定义在setup返回结果里,这样不用每次渲染都生成临时的更新函数
  return (
    <div className="conditionArea">
      <h1>concent setup compnent</h1>
      <select value={type} onChange={sync('type')}>
        <option value="1">1</option>
        <option value="2">2</option>
      </select>
      <select data-key="sex" value={sex} onChange={sync('sex')}>
        <option value="1">male</option>
        <option value="0">female</option>
      </select>
      <input data-key="addr" value={addr} onChange={sync('addr')} />
      <input data-key="keyword" value={keyword} onChange={sync('keyword')} />
      <button onClick={fetchProducts}>refresh</button>
      {products.map((v, idx)=><div key={idx}>name:{v.name} author:{v.author}</div>)}
    </div>
  );
});

setup的强大之处在于,它只会在组件首次渲染之前执行一次,返回的结果搜集在settings里,这意味着你的api都是静态声明好的,而不是每次渲染再创建!同时在这个空间内你还可以定义其他的函数,如ctx.on定义事件监听,ctx.computed定义计算函数,ctx.watch定义观察函数等,这里我们重点讲得是ctx.effect,其他的使用方法可以查阅以下例子:
codesandbox.io/s/concent-g…
stackblitz.com/edit/concen…

我们现在看看效果吧

避免反复创建临时闭包函数

到此为止,我们解决了第一个问题即避免反复创建临时闭包函数

那如果我们的状态更新逻辑伴随着很多复杂的操作,避免不了的我们的setup body会越来臃肿,我们当然可以在把这些函数封装一遍抽象出去,最后返回结果然后调用ctx.state去更新,但是concent提供更优雅的接口invoke让你做这个事情,我们将这些逻辑封装成一个个函数放置在一个文件logic.js中,然后返回新的片段状态,使用invoke调用它们

//code in logic.js

export function simpleUpdateType(type, moduleState, actionCtx){
    return { type };
}

在你的setup体内你就可以构造一个将被收集到settings里的属性调用该函数了。

import * as lc from './logic';

const setup = ctx=>{
    //其他略
    return {
        upateType: e=> ctx.invoke(lc.simpleUpdateType, e.currentTarget.value);
    }
}

这也许看起来没什么嘛,不就是一个调用吗,来来,我们换一个异步的写法

//code in logic.js
export async function complexUpdate(type, moduleState, actionCtx){
    await api.updateType(type);
    return { type };
}

// code in setup
import * as lc from './logic';

const setup = ctx=>{
    //其他略
    return {
        upateType: e=> ctx.invoke(lc.complexUpdate, e.currentTarget.value);
    }
}

是不是看起来舒服多了,更棒的是支持我们来书写多个函数然后自由组合,大家或许注意到函数参数列表除了第一位payload,还有第二位moduleState,第三位actionCtx,若调用方不属于任何模块则第二为参数是一个无内容的对象{},何时有值我们后面再做分析,这里我们重点看第三位参数actionCtx,可以用它来串联其他的函数,是不是特别方便呢?

//code in logic.js
export async function complexUpdateType(type, moduleState, actionCtx){
    await api.updateType(type);
    return { type };
}

export async function complexUpdateSex(sex, moduleState, actionCtx){
    await api.updateSex(sex);
    return { sex };
}

export async function updateTypeAndSex({type, sex}, moduleState, actionCtx){
    await actionCtx.invoke(complexUpdateType, type);
    await actionCtx.invoke(complexUpdateSex, sex);
}

// code in setup
import * as lc from './logic';

const setup = ctx=>{
    //其他略
    return {
        upateType: e=> {
            // 为了配合这个演示,我们另开两个key存type,sex^_^
            const {tmpType, tmpSex} = ctx.state;
            ctx.invoke(lc.updateTypeAndSex, {type:tmpType, sex:tmpSex}};
        }
    }
}

那如果这个状态我想其他组件共享改怎么办呢?我们只需要先将状态的配置在run函数里(z注:使用concent是一定要在渲染根组件前先调用run函数的),居然在使用useConcent的时候,标记模块名就ok了

先配置好模块

import { useConcent, run } from "concent";
import * as lc from './logic';

run({
    product:{
        //这里复用刚才的状态生成函数
        state: iState(), 
        // 把刚才的逻辑函数模块当做reducer配置在此处
        // 当然这里可以不配置,不过推荐配上,方便调用处不需要再引入logic.js
        reducer: lc,
    }
});

接下来在组件里加上模块标记吧,和ConcentFnPage对比,仅仅是将state属性改为了module并设定为product

const ConcentFnModulePage = React.memo(function({ tag: propTag }) {
  // useConcent返回ctx,这里直接解构ctx,拿想用的对象或方法
  const { state, settings, sync } = useConcent({ setup, module:'product' });
  const { products, type, sex, addr, keyword, tag } = state;
  const { fetchProducts } = settings;
    
  //此处略,和ConcentFnPage 一毛一样的代码
  );
});

注意哦,原ConcentFnPage依然能正常运行,一行代码也不用改,新的ConcentFnModulePage也只是在使用useConcent时,传入了module值并去掉state,ctx.state将有所属的模块注入,其他的代码包括setup体内也是一行都没有改,但是它们运行起来效果是不一样的,ConcentFnPage是无模块组件,它的实例们状态是各自孤立的,例如实例1改变了状态不会影响实例2,但是ConcentFnModulePage是注册了product模块的组件,这意味着它的任何一个实例修改了状态都会被同步到其他实例,状态提升为共享是如此轻松!仅仅标记了一个模块记号。

来让我们看看效果吧!注意concent shared comp2个实例的状态是同步的。

到此为止,我们解决了第二个问题即状态更新可异步,可同步,能自由组合,且可以轻易的提升为全局状态管理,且提升的过程是如此丝滑与惬意。

统一副作用代码管理方式

那我们还剩最后一个目标:统一副作用代码管理方式,让类与函数实现0成本的无痛共享。

这对于Concent更是轻而易举了,总而言之,concent在setup里提供的effect会自动根据注册的组件类型来做智能适配,对于类组件适配了它的各个生命周期函数即componentDidMountcomponentDidMountcomponentWillUnmount,对于函数组件适配了useEffect,所以切换成本一样的是0代价!

改写后的class组件如下,ctx从this获取,注册的参数交给register接口,注意哦,setup也是直接复用了的。

class ConcentFnModuleClass extends React.Component{
  render(){
    const { state, settings, sync } = this.ctx;
    const { products, type, sex, addr, keyword, tag } = state;
    const { fetchProducts, fetchByInfoke } = settings;
  
    //此处略,一毛一样的代码
  }
}

export default register({ setup, module:'product' })(ConcentFnModuleClass);

来看看效果吧!

shared comp 是函数组件,shared class comp是类组件。

结语

本文到此结束,我知道亲爱的你一定有不少疑惑,或者想亲自试一试,以上代码片段的在线示例在这里,欢迎点击查看,fork,并修改

当然了,还为你准备有一个生产可用的标准代码模板示例
js: codesandbox.io/s/concent-g…
ts: codesandbox.io/s/concent-g…

人到中年,生活不易,秃头几乎无法阻止,码字艰辛,看上的看官就来颗✨星星呗 ❤ star me if you like concent ^_^

我们知道hook的诞生提升了react的开发体验,那么对于Concent来说呢,它做的远比你想的更多,代码的拆分与组合,逻辑的分离与复用,状态的定义与共享,都能给你的开发体验再度幸福提升double or more,因为Concent的slogan是一个可预测、0入侵、渐进式、高性能的增强型状态管理方案

于我而言,华发虽已开始坠落,但若能以一人掉落的代价换来更多开发能够保留住那一头乌黑亮丽的浓密头发,瞬间觉得值了,哈哈😀