❤ 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 设计动机
提及Concent
的setup
的设计动机之前,我们再来复盘下官方给出的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的开发流程是不一样的,两者之间互相共用逻辑已经不可能
基于这些问题的存在,Concent
的setup
诞生了,巧妙的利用hook这个传送门,让组件初次渲染时执行setup,从而开辟了另一个空间,斡旋在function组件
和class组件
之间,让两者的业务逻辑可以互相共享,从而达成了function组件
和class组件
完美的和谐共存局面,实现了Concent
的核心目标,无论是function组件
和class组件
,它们都只是ui的载体,真正的业务逻辑处于model
里。
初探useConcent
本文要说的主角是setup
,为什么这里要提useConcent
呢?因为setup
需要传送门呀,在Concent
里useConcent
就扮演着这个重要的传送门角色,我们接下来通过代码一步一步的分析,最后引入setup
来做出对比。
了解更多可以查看往期文章
聊一聊状态管理&Concent设计理念
或进入在线IDE体验(如点击图片无效可点击左侧文字链接)
定义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.effect
和React.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){
ctx.effect(()=>{/** code */}, []);
cosnt updateAge = e=> ctx.dispatch(fooReducer.updateAge, e.currentTarget.value);
return { updateAge }
}
render(){
const { state, setState, settings } = this.ctx;
// 这里其实this.state 和 this.ctx.state 指向的是同一个对象
}
}
强大的实例上下文
上文里,其实读者有注意的话,我们一直提到了一个关键词实例上下文,它是Concent管控所有组件和增强组件能力的重要入口。
例如setup在ctx上提供给用户的effect接口,底层会自动去适配函数组件的useEffect
和类组件的componentDidMount
、componentDidUpdate
、componentWillUnmount
,从而抹平了函数组件和类组件之间的生命周期函数的差异。
例如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", (newVal, oldVal) => { /** code here */ });
ctx.watch("visible", (newVal, oldVal) => { /** 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代码修改哦(如点击图片无效可点击文字链接)
如果有关于concent的疑问,可以扫码加群咨询,我会尽力答疑解惑,帮助你了解更多。