41

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

 4 years ago
source link: https://juejin.im/post/5dd123ec5188253dbe5eeebd
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
2019年11月18日 阅读 9316

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

❤ 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体验

16e7c9273b00ef87?imageslim

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里集中配置,也可以跟着组件就近配置,一个标准的代码组织结构示意如下图

1

利用configure就近配置page model

1

定义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组件在创建期、存在期和销毁期各个阶段的工作细节。

1

对比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的疑问,可以扫码加群咨询,我会尽力答疑解惑,帮助你了解更多。

1

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK