应战Vue3 setup,Concent携手React出招了!

11,957 阅读14分钟

❤ star me if you like concent ^_^

导读

上期写完文章concent 骚操作之组件创建&状态更新后,末尾留下了下面两期的文章预告,按照原来的预告内容,这一次文章题目应该是【探究setup带来的变革】了,但是因为本文会实打实的将vue3里的setup特性提出来和Concent做对比,所以临时改了题目为【应战Vue3 setup,Concent携手React出招了!】,以便体现出有了setup特性的加持,你的react应用将变得犀利无比,代码组织方式将具有更大的想象空间,当然这里要承认一点,大概是在6月份左右在某乎看到了Vue Function-based API RFC这篇文章,给了我极大的灵感,在这之前我一直有一个想法,想统一函数组件和类组件的装配工作,需要定义一个入口api,但是命名似乎一直感觉定不下来,直到此文中提及setup后,我如醍醐灌顶,它所做的工作和我想要达到的效果本质上是一模一样的呀!于是乎Concent里的setup特性就这样诞生了。

正文开始之前,先预览一个生产环境的setup 示例,以示这是一个生产环境可用的标准特性。 进入在线IDE体验

Vue3 setup 设计动机

在Function-based API文章里说得很清楚了,setup API 受 React Hooks 的启发,提供了一个全新的逻辑复用方案,能够更好的组织逻辑,更好的在多个组件之间抽取和复用逻辑, 且将不存在以下问题。

  • 模版中的数据来源不清晰。举例来说,当一个组件中使用了多个 mixin 的时候,光看模版会很难分清一个属性到底是来自哪一个 mixin。HOC 也有类似的问题。
  • 命名空间冲突。由不同开发者开发的 mixin 无法保证不会正好用到一样的属性或是方法名。HOC 在注入的 props 中也存在类似问题。
  • 性能。HOC 和 Renderless Components 都需要额外的组件实例嵌套来封装逻辑,导致无谓的性能开销。

使用基于函数的 API,我们可以将相关联的代码抽取到一个 "composition function"(组合函数)中 —— 该函数封装了相关联的逻辑,并将需要暴露给组件的状态以响应式的数据源的方式返回出来

import { reactive, computed, watch, onMounted } from 'vue'

const App = {
  template: `
    <div>
      <span>count is {{ count }}</span>
      <span>plusOne is {{ plusOne }}</span>
      <button @click="increment">count++</button>
    </div>
  `,
  setup() {
    // reactive state
    const count = reactive(0)
    // computed state
    const plusOne = computed(() => count.value + 1)
    // method
    const increment = () => { count.value++ }
    // watch
    watch(() => count.value * 2, val => {
      console.log(`count * 2 is ${val}`)
    })
    // lifecycle
    onMounted(() => {
      console.log(`mounted`)
    })
    // expose bindings on render context
    return {
      count,
      plusOne,
      increment
    }
  }
}

Concent setup 设计动机

提及Concentsetup的设计动机之前,我们再来复盘下官方给出的hook设计动机

  • 在组件之间复用状态逻辑很难
  • 复杂组件变得难以理解
  • 难以理解的 class

这里面提到的复用状态逻辑很难,是两大框架都达成了一致的共识点,社区也一致在通过各种尝试解决此问题,到了最后,大家发现一个有趣的现象,我们写UI的时候,基本上用不到继承,而且官方也是极力推荐组合大于继承的思想,试想一下,谁会写个BasicModal,然后漫天的各种***Modal继承自BasicModal来写业务实现呢?基本上基础组件设计者都是BasicModal留几个接口和插槽,然后你引入BasicModal自己再封装一个***Modal就完事了对吧?

所以在react基于Fiber的链表式树结构可以模拟出函数调用栈后,hook的诞生就相当于是顺势而为了,但是hook只是给函数组件撕开了一个放置传送门的口子,这个传送门非常神奇,可以定义状态,可以定义生命周期函数等,但是原始的hook和业务开发友好体验度上还是有些间隙,所以大家开始在传送门上开始大做文章,有勤勤恳恳的专注于让你更轻松的使用hook的全家桶react-use,也有专注于某个方向的hook如最近开始大红大紫的专注于fetch data体验的useSWR,当然也有不少开发开始慢慢沉淀自己的业务hook 包。

但是基于hook组织业务逻辑有如下局限性

  • 每次渲染都需要重复定义临时闭包函数

特别注意的陷阱是,闭包函数内部千万不要引入外部的变量,而是要放在依赖列表里

  • hook的复用不是异步的,不适合组织复杂的业务逻辑
function MyProjects () {
  const { data: user } = useSWR('/api/user')
  const { data: projects } = useSWR(() => '/api/projects?uid=' + user.id)
  // When passing a function, SWR will use the
  // return value as `key`. If the function throws,
  // SWR will know that some dependencies are not
  // ready. In this case it is `user`.
  
  if (!projects) return 'loading...'
  return 'You have ' + projects.length + ' projects'
}

以上面useSWR的官方示例代码为例,看起来第二个useSWR是一定会报错的,但是它内部会try catch住undefined错误,推导user还未准备好,从而巧妙的躲过渲染报错,但是本质上hook不是异步的,我们的实际业务逻辑复杂的时候,请求多且相互依赖多的时候,它内部的处理会有更多的额外消耗。

  • hook和class的开发流程是不一样的,两者之间互相共用逻辑已经不可能

基于这些问题的存在,Concentsetup诞生了,巧妙的利用hook这个传送门,让组件初次渲染时执行setup,从而开辟了另一个空间,斡旋在function组件class组件之间,让两者的业务逻辑可以互相共享,从而达成了function组件class组件完美的和谐共存局面,实现了Concent的核心目标,无论是function组件class组件,它们都只是ui的载体,真正的业务逻辑处于model里。

初探useConcent

本文要说的主角是setup,为什么这里要提useConcent呢?因为setup需要传送门呀,在ConcentuseConcent就扮演着这个重要的传送门角色,我们接下来通过代码一步一步的分析,最后引入setup来做出对比。

了解更多可以查看往期文章
聊一聊状态管理&Concent设计理念
进入在线IDE体验(如点击图片无效可点击左侧文字链接)

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

定义model

按照约定,使用任何Concent接口前一定要先配置模型定义

/**  ------ code in runConcent.js ------ */
import { run } from 'concent';
import { foo, bar, baz } from 'models';

run({foo, bar, baz});

/**  ------ code in models/foo/state.js ------ */
export default {
    loading: false,
    name: '',
    age: 12,
}

/**  ------ code in models/foo/reducer.js ------ */
export async function updateAge(payload, moduleState, actionCtx){
    const { data } = await api.serverCall();
    // 各种复杂业务逻辑略
    return {age: payload};
}

export async function updateName(payload, moduleState, actionCtx){
    const { data } = await api.serverCall();
    // 各种复杂业务逻辑略
    return {name: payload};
}

export async function updateAgeAndName({name, age}, moduleState, actionCtx){
    // actionCtx.setState({loading:true});

    // 任意组合调用其他reducer
    await actionCtx.dispatch(updateAge, age);
    await actionCtx.dispatch(updateName, name);
    // return {loading: false}; // 当前这个reducer本身也可以选择返回新的状态
}

注意model并非一定要在run里集中配置,也可以跟着组件就近配置,一个标准的代码组织结构示意如下图

利用configure就近配置page model

定义Concent函数组件

下面我们通过useConcent定义一个Concent函数组件

function Foo(){
    useConcent();
    return (
        <div>hello</div>
    )
}

这就是一个Concent函数组件,当然这样定义是无意义的,因为什么都没有干,所以我们为此函数组件加个私有状态吧先

function Foo(){
    // ctx是Concent为组件注的实例上下文对象
    const ctx = useConcent({state:{tip:'I am private', src:'D'}});
    const { state } = ctx;
    // ...
}

尽管Concent会保证此状态只会在组件初次渲染时在赋值给ctx.state作为初始值,但是每次组件重渲染这里都会临时创建一次state对象,所以更优的写法是我们将其提到函数外面

const iState = {tip:'I am private', src:'D'}; //initialState

function Foo(){
    const ctx = useConcent({state:iState});
    const { state } = ctx;
    // ...
}

如果此组件会同时创建多个,建议将iState写为函数,以保证状态隔离

const iState = ()=> {tip:'I am private'}; //initialState

状态修改

定义完组件,可以读取状态了,下一步我们当然是要修改状态了,同时我们也定义一些生命周期函数吧

function Foo(){
    const ctx = useConcent({state:iState});
    const { state, setState } = ctx;
    
    cosnt changeTip = (e)=> setState({tip:e.currentTarget.value});
    cosnt changeSrc = (e)=> setState({src:e.currentTarget.value});
    
    React.useEffect(()=>{
        console.log('首次渲染完毕触发');
        return ()=> console.log('组件卸载时触发');
    },[]);
    // ...
}

这里看起来是不是有点奇怪,只是将React.setState句柄调用替换成了useConcent返回的ctx提供的setState句柄,但是如果我想定义当tip发生变化时就触发副作用函数,那么React.useEffect里第二为参数列表该怎么写呢,看起来直接传入state.tip就可以了,但是我们提供更优的写法。

接入setup

是时候接入setup了,setup的精髓就是只会在组件初次渲染前执行一次,利用setup开辟的新空间完成组件的功能装配工作吧!

我们定义当tip或者src发生改变时执行的副作用函数吧

// Concent会将实例ctx透传给setup函数
const setup = ctx=>{
    ctx.effect(()=>{
        console.log('tip发生改变时执行');
        return ()=> console.log('组件卸载时触发');
    }, ['tip']);
    
    ctx.effect(()=>{
        console.log('tip和src任意一个发生改变时执行');
        return ()=> console.log('组件卸载时触发');
    }, ['tip', 'src'])
}

function Foo(){
    // useConcent里传入setup
    const ctx = useConcent({state:iState, setup});
    const { state, setState } = ctx;
    // ...
}

注意到没有!ctx.effectReact.useEffect使用方式一模一样,除了第二为参数依赖列表的写法,React.useEffect需要传入具体的值,而ctx.effect之需要传入stateKey名称,因为Concent总是会记录组件最新状态的前一个旧状态,通过两者对比就知道需不需要触发副作用函数了!

因为ctx.effect已经存在于另一个空间内,不受hook语法规则限制了,所以如果你想,你甚至可以这样写(当然了,实际业务在不了解规则的情况下不推荐这样写)

const setup = ctx=>{
    ctx.watch('tip', (tipVal)=>{// 观察到tip值变化时,触发的回调
        if(tipVal === 'xxx' ){//当tip的值为'xxx'时,就定义一个新的副作用函数
            ctx.effect(()=>{
                return ()=> console.log('tip改变');
            }, ['tip']);
        }
    });
}

我们通过上面的示例,完成了状态的定义,和副作用函数的迁移,但是状态的修改还是处于函数组件内部,现在我们将它们挪到setup空间内,利用setup返回的对象可以在ctx.settings里取到这一特点,将这写方法提升为静态的api定义,而不是每次组件重复渲染期间都需要临时再定义了。

const setup = ctx=>{
    ctx.effect(()=>{ /** code */ }, ['tip']);
    
    cosnt changeTip = (e)=> setState({tip:e.currentTarget.value});
    cosnt changeSrc = (e)=> setState({src:e.currentTarget.value});
    return {changeTip, changeSrc};
}

function Foo(){
    const ctx = useConcent({state:iState, setup});
    const { state, setState, settings } = ctx;
    // 现在可以绑定settings.changeTip , settings.changeSrc 到具体的ui上了
}

连接model

上面示例里组件始终操作的是自己的状态,如果需要读取model的数据和操作model的方法怎么办呢?你仅需要标注连接的模块名称就好了,注意的是此时state是私有状态和模块状态合成而来,如果你的私有状态里有key和模块状态同名了,那么它其实就自动的被模块状态的值覆盖了。

function Foo(){
    // 连接到foo模块
    const ctx = useConcent({module:'foo', state:iState, setup});
    const { state, setState, settings } = ctx;
    // 此时state是私有状态和模块状态合成而来
    // {tip:'', src:'', loading:false, name:'', age:12}
}

如果你讨厌state被合成出来,污染了你的ctx.state,你也可以使用connect参数来连接模块,同时connect还允许你连接多个模块

function Foo(){
    // 通过connect连接到foo, bar, baz模块
    const ctx = useConcent({connect:['foo', 'bar', 'baz'], state:iState, setup});
    const { state, setState, settings, connectedState } = ctx;
    const { foo, bar, baz} = connectedState;
    // 通过ctx.connectedState读取到各个模块的状态
}

复用模块的业务逻辑

还记得我们上面定义的foo模块的reducer函数吗?现在我们可以通过dispatch直接调用reducer函数,所以我们可以在setup里完成这些桥接函数的装配工作。

const setup = ctx=>{
    cosnt updateAgeAndName = e=> ctx.dispatch('updateAgeAndName', e.currentTarget.value);
    cosnt updateAge = e=> ctx.dispatch('updateAge', e.currentTarget.value);
    cosnt updateName = e=> ctx.dispatch('updateName', e.currentTarget.value);
    
    return {updateAgeAndName, updateAge, updateName};
}

当然,上面的写法是在注册Concent组件时指定了明确的module值,如果是使用connect参数连接的模块,则需要加明确的模块前缀

const setup = ctx=>{
    // 调用的是foo模块updateAge方法
    cosnt updateAge = e=> ctx.dispatch('foo/updateAge', e.currentTarget.value);
}

等等!你说讨厌字符串调用的形式,因为你已经在上面foo模块的reducer文件里看到函数之间可以直接基于函数引用来组合逻辑了,这里还要写名字很不爽,Concent满足你直接基于函数应用调用的需求

import * as fooReducer from 'models/foo/reducer';
const setup = ctx=>{
    // dispatch fooReducer函数
    cosnt updateAge = e=> ctx.dispatch(fooReducer.updateAge, e.currentTarget.value);
}

嗯?什么,这样写也觉得不舒服,想直接调用,当然可以!

const setup = ctx=>{
    // 直接调用fooReducer
    cosnt updateAge = e=> ctx.reducer.foo.updateAge(e.currentTarget.value);
}

和class共享业务逻辑

因为class组件也支持setup,也拥有实例上下文对象,那么和function组件间共享业务逻辑自然是水到渠成的事情了

import { register } from 'concent';

register('foo')
class FooClazzComp extends React.Component{
    ?setup(ctx){
        // 模拟componentDidMount
        ctx.effect(()=>{
            /** code */
            return ()=>{console.log('模拟componentWillUnmount');}
        }, []);
        ctx.effect(()=>{
            console.log('模拟componentDidUpdate');
        }, null, false);
        // 第二位参数depKeys写null表示每一轮都执行
        // 第三位参数immediate写false,表示首次渲染不执行
        // 两者一结合,即模拟出了componentDidUpdate
        
        cosnt updateAge = e=> ctx.dispatch('updateAge', e.currentTarget.value);
        return { updateAge }
    }
    
    render(){
        const { state, setState, settings } = this.ctx;
        // 这里其实this.state 和 this.ctx.state 指向的是同一个对象
    }
}

强大的实例上下文

上文里,其实读者有注意的话,我们一直提到了一个关键词实例上下文,它是Concent管控所有组件和增强组件能力的重要入口。

例如setup在ctx上提供给用户的effect接口,底层会自动去适配函数组件的useEffect和类组件的componentDidMountcomponentDidUpdatecomponentWillUnmount,从而抹平了函数组件和类组件之间的生命周期函数的差异。

例如ctx上提供的emit&on接口,让组件之间除了数据驱动ui的模式,还是更松耦合的通过事件来驱动目标组件完成一些其他动作。

下图完整了的解释了整个Concent组件在创建期、存在期和销毁期各个阶段的工作细节。

对比Vue3 setup

最后的最后,我们使用Concent提供的registerHookComp接口来写一个组件和Vue3 setup做个对比,期望这次出招能够打动作为react开发者的你的心,相信基于不可变原则也能写出优雅的组合api型函数组件。

registerHookComp本质上是基于useConcent浅封装的,自动将返回的函数组件包裹了一层React.memo ^_^

import { registerHookComp } from "concent";

const state = {
  visible: false,
  activeKeys: [],
  name: '',
};

const setup = ctx => {
  ctx.on("openMenu", (eventParam) => { /** code here */ });
  ctx.computed("visible", (newState, oldState) => { /** code here */ });
  ctx.watch("visible", (newState, oldState) => { /** code here */ });
  ctx.effect( () => { /** code here */ }, []);
  
  const doFoo = param =>  ctx.dispatch('doFoo', param);
  const doBar = param =>  ctx.dispatch('doBar', param);
  const syncName = ctx.sync('name');
  
  return { doFoo, doBar, syncName };
};

const render = ctx => {
  const {state, settings} = ctx;

  return (
    <div className="ccMenu">
      <input value={state.name} onChange={settings.syncName} />
      <button onClick={settings.doFoo}>doFoo</button>
      <button onClick={settings.doBar}>doBar</button>
    </div>
  );
};

export default registerHookComp({
  state, 
  setup,  
  module:'foo',
  render
});

结语

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

下期预告【concent love typescript】,因为Concent整套api都是面向函数式的,和ts结合是天生一对的好基友,所以基于ts书写concent将是非常的简答和舒服😀,各位敬请期待。

强烈建议有兴趣的你进入在线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的疑问,可以扫码加群咨询,我会尽力答疑解惑,帮助你了解更多。