传统的 redux 项目里,我们写在 reducer 里的状态一定是要打通到 store 的,我们一开始就要规划好 state、reducer 等定义,有没有什么方法,既能够快速享受 ui 与逻辑分离的福利,又不需要照本宣科的从条条框框开始呢?本文从普通的 react 写法开始,当你一个收到一个需求后,脑海里有了组件大致的接口定义,然后丝滑般的接入到 concent 世界里,感受渐进式的快感以及全新 api 的独有魅力吧!
上周天气其实不是很好,记得下了好几场雨,不过北京总部大厦的隔音太好了,以致于都没有感受到外面的风雨飘摇,在工位上正在思索着整理下现有代码时,接到一个普通的需求,大致是要实现一个弹窗。
这是一个非常普通的需求,我相信不少码神看完后,脑海里已经把代码雏形大致写完了吧,嘿嘿,但是还请耐性看完本篇文章,来看看在concent的加持下,你的react
应用将如何变得更加灵活与美妙,正如我们的 slogan:
concent, power your react
产品同学期望快速见到一般效果原型,而我希望原型是可以持续重构和迭代的基础代码,当然要认真对待了,不能为了交差而乱写一版,所以要快速整理需求并开始准备工作了。
因为项目大量基于antd
来书写 UI,听完需求后,脑海里冒出了一个穿梭框模样的组件,但因为右侧是一个可拖拽列表,查阅了下没有类似的组件,那就自己实现一个吧,初步整理下,大概列出了以下思路。
ColumnConfModal
,基于antd
的Modal
, Card
实现布局,antd
的List
来实现左侧的选择列表,基于react-beautiful-dnd
的可拖拽 api 来实现右侧的拖拽列表。ColumnConfModal
内部。因为注册为concent
组件后天生拥有了emit&on
的能力,而且不需要手动off
,concent
在实例销毁前自动就帮你解除其事件监听,所以我们可以注册完成后,很方便的监听openColumnConf
事件了。
我们先抛弃各种 store 和 reducer 定义,快速的基于class
撸出一个原型,利用register
接口将普通组件注册为concent
组件,伪代码如下
import { register } from 'concent';
class ColumnConfModal extends React.Component {
state = {
selectedColumnKeys: [],
selectableColumnKeys: [],
visible: false,
};
componentDidMount(){
this.ctx.on('openColumnConf', ()=>{
this.setState({visible:true});
});
}
moveToSelectedList = ()=>{
//code here
}
moveToSelectableList = ()=>{
//code here
}
saveSelectedList = ()=>{
//code here
}
handleDragEnd = ()=>{
//code here
}
render(){
const {selectedColumnKeys, selectableColumnKeys, visible} = this.state;
return (
<Modal title="设置显示字段" visible={state._visible} onCancel={settings.closeModal}>
<Head />
<Card title="可选字段">
<List dataSource={selectableColumnKeys} render={item=>{
//...code here
}}/>
</Card>
<Card title="已选字段">
<DraggableList dataSource={selectedColumnKeys} onDragEnd={this.handleDragEnd}/>
</Card>
</Modal>
);
}
}
// es6 装饰器还处于实验阶段,这里就直接包裹类了
// 等同于在 class 上 @register( )来装饰类
export default register( )(ColumnConfModal)
可以发现,这个类的内部和传统的react
类写法并无区别,唯一的区别是concent
会为每一个实例注入一个上下文对象ctx
来暴露concent
为react
带来的新特性 api。
因为事件的监听只需要执行一次,所以例子中我们在componentDidMount
里完成了事件openColumnConf
的监听注册。
根据需求,显然的我们还要在这里书写获取表格列定义元数据和获取用户的个性化列定义数据的业务逻辑
componentDidMount() {
this.ctx.on('openColumnConf', () => {
this.setState({ visible: true });
});
const tableId = this.props.tid;
tableService.getColumnMeta(`/getMeta/${tableId}`, (columns) => {
userService.getUserColumns(`/getUserColumns/${tableId}`, (userColumns) => {
//根据 columns userColumns 计算 selectedList selectableList
});
});
}
所有的concent
实例可以定义setup
钩子函数,该函数只会在初次渲染前调用一次。
现在让我们来用setup
代替掉此生命周期
//class 里定义的 setup 加$$前缀
$$setup(ctx){
//这里定义 on 监听,在组件挂载完毕后开始真正监听 on 事件
ctx.on('openColumnConf', () => {
this.setState({ visible: true });
});
//标记依赖列表为空数组,在组件初次渲染只执行一次
//模拟 componentDidMount
ctx.effect(()=>{
//service call balabala.....
}, []);
}
如果已熟悉hook
的同学,看到setup
里的effect
api 语法是不是和useEffect
有点像?
effect
和useEffect
的执行时机是一样的,即每次组件渲染完毕之后,但是effect
只需要在setup
调用一次,相当于是静态的,更具有性能提升空间,假设我们加一个需求,每次vibible
变为 false 时,上报后端一个操作日志,就可以写为
//依赖列表填入 key 的名称,表示当这个 key 的值发生变化时,触发副作用
ctx.effect( ctx=>{
if(!ctx.state.visible){
//当前最新的 visible 已是 false,上报
}
}, ['visible']);
关于effect
就点到为止,说得太多扯不完了,我们继续回到本文的组件上。
我们希望组件的状态变更可以被记录下来,方便观察数据变化,so,我们先定义一个 store 的子模块,名为ColumnConf
,
定义其 sate 为
// code in ColumnConfModal/model/state.js
export function getInitialState() {
return {
selectedColumnKeys: [],
selectableColumnKeys: [],
visible: false,
};
}
export default getInitialState();
然后利用concent
的configure
接口载入此配置
// code in ColumnConfModal/model/index.js
import { configure } from 'concent';
import state from './state';
// 配置模块 ColumnConf
configure('ColumnConf', {
state,
});
注意这里,让
model
跟着组件定义走,方便我们维护model
里的业务逻辑。
整个store
已经被concent
挂载到了window.sss
下,为了方便查看 store,当当当当,你可以打开console,直接查看store
各个模块当前的最新数据。
然后我们把 class 注册为'配置模ColumnConf
的组件,现在class
里的 state 声明可以直接被我们干掉了。
import './model';//引用一下 model 文件,触发 model 配置到 concent
@register('ColumnConf')
class ColumnConfModal extends React.Component {
// state = {
// selectedColumnKeys: [],
// selectableColumnKeys: [],
// visible: false,
// };
render(){
const {selectedColumnKeys, selectableColumnKeys, visible} = this.state;
}
}
大家可能注意到了,这样暴力的注释掉,render
里的代码会不会出问题?放心吧,不会的,concent 组件的 state 和store
是天生打通的,同样的setState
也是和store
打通的,我们先来安装一个插件concent-plugin-redux-devtool
。
import ReduxDevToolPlugin from 'concent-plugin-redux-devtool';
import { run } from 'concent';
// storeConfig 配置略,详情可参考 concent 官网
run(storeConfig, {
plugins: [ ReduxDevToolPlugin ]
});
注意哦,concent
驱动 ui 渲染的原理和redux
完全不一样的,核心逻辑部分也不是在redux
之上做包装,和redux
一点关系都没有的^_^,这里只是桥接了redux-dev-tool
插件,来辅助做状态变更记录的,小伙伴们千万不要误会,没有redux
,concent
一样能够正常运作,但是由于concent
提供完善的插件机制,为啥不利用社区现有的优秀资源呢,重复造无意义的轮子很辛苦滴(⊙﹏⊙)b......
现在让我们打开chrome
的 redux 插件看看效果吧。
上图里是含有大量的 ccApi/setState,是因为还有不少逻辑没有抽离到reducer
,dispatch/***
模样的 type 就是dispatch
调用了,后面我们会提到。
这样看状态变迁是不是要比window.sss
好多了,因为sss
只能看当前最新的状态。
这里既然提到了redux-dev-tool
,我们就顺道简单了解下,concent 提交的数据长什么样子吧
上图里可以看到 5 个字段,renderKey
是用于提高性能用的,可以先不作了解,这里我们就说说其他四个,module
表示修改的数据所属的模块名,committedState
表示提交的状态,sharedState
表示共享到store
的状态,ccUniqueKey
表示触发数据修改的实例 id。
为什么要区分committedState
和sharedState
呢?因为setState
调用时允许提交自己的私有 key 的(即没有在模块里声明的 key ),所以committedState
是整个状态都要再次派发给调用者,而sharedState
是同步到store
后,派发给同属于module
值的其他 cc 组件实例的。
这里就借用官网一张图示意下:
所以我们可以在组件里声明其他非模块的 key,然后在this.state
里获取到了
@register('ColumnConf')
class ColumnConfModal extends React.Component {
state = {
_myPrivKey:'i am a private field value, not for store',
};
render(){
//这里同时取到了模块的数据和私有的数据
const {selectedColumnKeys, selectableColumnKeys, visible, _myPrivKey} = this.state;
}
}
虽然代码能够正常工作,状态也接入了 store,但是我们发现 class 已经变得臃肿不堪了,利用setState
怼固然快和方便,但是后期维护和迭代的代价就会慢慢越来越大,让我们把业务抽到reduder
吧
export function setLoading(loading) {
return { loading };
};
/** 移入到已选择列表 */
export function moveToSelectedList() {
}
/** 移入到可选择列表 */
export function moveToSelectableList() {
}
/** 初始化列表 */
export async function initSelectedList(tableId, moduleState, ctx) {
//这里可以不用基于字符串 ctx.dispatch('setLoading', true) 去调用了,虽然这样写也是有效的
await ctx.dispatch(setLoading, true);
const columnMeta = await tableService..getColumnMeta(`/getMeta/${tableId}`);
const userColumsn = await userService.getUserColumns(`/getUserColumns/${tableId}`);
//计算 selectedColumnKeys selectableColumnKeys 略
//仅返回需要设置到模块的片断 state 就可以了
return { loading: false, selectedColumnKeys, selectableColumnKeys };
}
/** 保存已选择列表 */
export async function saveSelectedList(tableId, moduleState, ctx) {
}
export function handleDragEnd() {
}
利用concent
的configure
接口把reducer
也配置进去
// code in ColumnConfModal/model/index.js
import { configure } from 'concent';
import * as reducer from 'reducer';
import state from './state';
// 配置模块 ColumnConf
configure('ColumnConf', {
state,
reducer,
});
还记得上面的setup
吗,setup
可以返回一个对象,返回结果将收集在settiings
里,现在我们稍作修改,然后来看看 class 吧,世界是不是清静多了呢?
import { register } from 'concent';
class ColumnConfModal extends React.Component {
$$setup(ctx) {
//这里定义 on 监听,在组件挂载完毕后开始真正监听 on 事件
ctx.on('openColumnConf', () => {
this.setState({ visible: true });
});
//标记依赖列表为空数组,在组件初次渲染只执行一次
//模拟 componentDidMount
ctx.effect(() => {
ctx.dispatch('initSelectedList', this.props.tid);
}, []);
return {
moveToSelectedList: (payload) => {
ctx.dispatch('moveToSelectedList', payload);
},
moveToSelectableList: (payload) => {
ctx.dispatch('moveToSelectableList', payload);
},
saveSelectedList: (payload) => {
ctx.dispatch('saveSelectedList', payload);
},
handleDragEnd: (payload) => {
ctx.dispatch('handleDragEnd', payload);
}
}
}
render() {
//从 settings 里取出这些方法
const { moveToSelectedList, moveToSelectableList, saveSelectedList, handleDragEnd } = this.ctx.settings;
}
}
react
社区轰轰烈烈推动了Hook
革命,让大家逐步用Hook
组件代替class
组件,但是本质上Hook
逃离了this
,精简了dom
渲染层级,但是也带来了组件存在期间大量的临时匿名闭包重复创建。
来看看concent
怎么解决这个问题的吧,上面已提到setup
支持返回结果,将被收集在settiings
里,现在让稍微的调整下代码,将class
组件吧变身为Hook
组件吧。
import { useConcent } from 'concent';
const setup = (ctx) => {
//这里定义 on 监听,在组件挂载完毕后开始真正监听 on 事件
ctx.on('openColumnConf', (tid) => {
ctx.setState({ visible: true, tid });
});
//标记依赖列表为空数组,在组件初次渲染只执行一次
//模拟 componentDidMount
ctx.effect(() => {
ctx.dispatch('initSelectedList', ctx.state.tid);
}, []);
return {
moveToSelectedList: (payload) => {
ctx.dispatch('moveToSelectedList', payload);
},
moveToSelectableList: (payload) => {
ctx.dispatch('moveToSelectableList', payload);
},
saveSelectedList: (payload) => {
ctx.dispatch('saveSelectedList', payload);
},
handleDragEnd: (payload) => {
ctx.dispatch('handleDragEnd', payload);
}
}
}
const iState = { _myPrivKey: 'myPrivate state', tid:null };
export function ColumnConfModal() {
const ctx = useConcent({ module: 'ColumnConf', setup, state: iState });
const { moveToSelectedList, moveToSelectableList, saveSelectedList, handleDragEnd } = ctx.settings;
const { selectedColumnKeys, selectableColumnKeys, visible, _myPrivKey } = ctx.state;
// return your ui
}
在这里要感谢尤雨溪老师的这篇Vue Function-based API RFC,给了我很大的灵感,现在你可以看到所以的方法的都在setup
里定义完成,当你的组件很多的时候,给 gc 减小的压力是显而易见的。
由于两者的写法高度一致,从class
到Hook
是不是非常的自然呢?我们其实不需要争论该用谁更好了,按照你的个人喜好就可以,就算某天你看class
不顺眼了,在concent
的代码风格下,重构的代价几乎为 0。
上面我们定义了一个 on 事件openColumnConf
,那么我们在其他页面里引用组件ColumnConfModal
时,当然需要触发这个事件打开其弹窗了。
import { emit } from 'concent';
class Foo extends React.Component {
openColumnConfModal = () => {
//如果这个类是一个 concent 组件
this.ctx.emit('openColumnConfModal', 3);
//如果不是则可以调用顶层 api emit
emit('openColumnConfModal', 3);
}
render() {
return (
<div>
<button onClick={this.openColumnConfModal}>配置可见字段</button>
<Table />
<ColumnConfModal />
</div>
);
}
}
上述写法里,如果有其他很多页面都需要引入ColumnConfModal
,都需要写一个openColumnConfModal
,我们可以把这个打开逻辑抽象到modalService
里,专门用来打开各种弹窗,而避免在业务见到openColumnConfModal
这个常量字符串
//code in service/modal.js
import { emit } from 'concent';
export function openColumnConfModal(tid) {
emit('openColumnConfModal', tid);
}
现在可以这样使用组件来触发事件调用了
import * as modalService from 'service/modal';
class Foo extends React.Component {
openColumnConfModal = () => {
modalService.openColumnConfModal(6);
}
render() {
return (
<div>
<button onClick={this.openColumnConfModal}>配置可见字段</button>
<Table />
<ColumnConfModal />
</div>
);
}
}
以上代码在任何一个阶段都是有效的,想要了解渐进式重构的在线 demo 可以点这里,更多在线示例列表点这里
由于本篇主题主要是介绍渐进式
重构组件,所以其他特性诸如sync
、computed$watch
、高性能杀手锏renderKey
等等内容就不在这里展开讲解了,留到下一篇文章,敬请期待。
如果看官觉得喜欢,就来点颗星星呗,concent
致力于为react
带来全新的编码体验和功能强化,敬请期待更多的特性和生态周边。