V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
fantasticsoul
V2EX  ›  React

使用 concent,感受一次渐进式创建 react 应用的魅力吧

  •  
  •   fantasticsoul · 2019-09-06 14:14:11 +08:00 · 2794 次点击
    这是一个创建于 1939 天前的主题,其中的信息可能已经有所发展或是发生改变。

    传统的 redux 项目里,我们写在 reducer 里的状态一定是要打通到 store 的,我们一开始就要规划好 state、reducer 等定义,有没有什么方法,既能够快速享受 ui 与逻辑分离的福利,又不需要照本宣科的从条条框框开始呢?本文从普通的 react 写法开始,当你一个收到一个需求后,脑海里有了组件大致的接口定义,然后丝滑般的接入到 concent 世界里,感受渐进式的快感以及全新 api 的独有魅力吧!

    需求来了

    上周天气其实不是很好,记得下了好几场雨,不过北京总部大厦的隔音太好了,以致于都没有感受到外面的风雨飘摇,在工位上正在思索着整理下现有代码时,接到一个普通的需求,大致是要实现一个弹窗。

    • 左侧有一个可选字段列表,点击任意一个字段,就会进入右侧。
    • 右侧有一个已选字段列表,该列表可以上下拖拽决定字段顺序决定表格里的列字段显示顺序,同时也可以删除,将其恢复到可选择列表。
    • 点击保存,将用户的字段配置存储到后端,用户下次再次使用查看该表格时,使用已配置的显示字段来展示。

    这是一个非常普通的需求,我相信不少码神看完后,脑海里已经把代码雏形大致写完了吧,嘿嘿,但是还请耐性看完本篇文章,来看看在concent的加持下,你的react应用将如何变得更加灵活与美妙,正如我们的 slogan:

    concent, power your react

    准备工作

    产品同学期望快速见到一般效果原型,而我希望原型是可以持续重构和迭代的基础代码,当然要认真对待了,不能为了交差而乱写一版,所以要快速整理需求并开始准备工作了。

    因为项目大量基于antd来书写 UI,听完需求后,脑海里冒出了一个穿梭框模样的组件,但因为右侧是一个可拖拽列表,查阅了下没有类似的组件,那就自己实现一个吧,初步整理下,大概列出了以下思路。

    • 组件命名为ColumnConfModal,基于antdModal, Card实现布局,antdList来实现左侧的选择列表,基于react-beautiful-dnd的可拖拽 api 来实现右侧的拖拽列表。

    ui 布局

    • 因为这个弹窗组件在不同页面被不同的 table 使用,传入的列定义数据是不一样的,所以我们使用事件的方式,来触发打开弹窗并传递表格 id,打开弹窗后获取该表格的所有字段定义,以及用户针对表哥的已选择字段数据,这样把表格元数据的初始化工作收敛在ColumnConfModal内部。
    • 基于表格左右两侧的交互,大致定义一下内部接口 1 moveToSelectedList (移入到已选择列表 ) 2 moveToSelectableList (移入到可选择列表) 3 saveSelectedList (保存用户的已选择列表) 4 handleDragEnd (处理已选择列表顺序调整完成时) 5 其他略.....

    UI 实现

    因为注册为concent组件后天生拥有了emit&on的能力,而且不需要手动offconcent在实例销毁前自动就帮你解除其事件监听,所以我们可以注册完成后,很方便的监听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来暴露concentreact带来的新特性 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里的effectapi 语法是不是和useEffect有点像?

    effectuseEffect的执行时机是一样的,即每次组件渲染完毕之后,但是effect只需要在setup调用一次,相当于是静态的,更具有性能提升空间,假设我们加一个需求,每次vibible变为 false 时,上报后端一个操作日志,就可以写为

        //依赖列表填入 key 的名称,表示当这个 key 的值发生变化时,触发副作用
        ctx.effect( ctx=>{
          if(!ctx.state.visible){
            //当前最新的 visible 已是 false,上报
          }
        }, ['visible']);
    

    关于effect就点到为止,说得太多扯不完了,我们继续回到本文的组件上。

    提升状态到 store

    我们希望组件的状态变更可以被记录下来,方便观察数据变化,so,我们先定义一个 store 的子模块,名为ColumnConf

    定义其 sate 为

    // code in ColumnConfModal/model/state.js
    export function getInitialState() {
      return {
        selectedColumnKeys: [],
        selectableColumnKeys: [],
    	visible: false,
      };
    }
    
    export default getInitialState();
    

    然后利用concentconfigure接口载入此配置

    // 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各个模块当前的最新数据。

    window.sss

    然后我们把 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插件,来辅助做状态变更记录的,小伙伴们千万不要误会,没有reduxconcent一样能够正常运作,但是由于concent提供完善的插件机制,为啥不利用社区现有的优秀资源呢,重复造无意义的轮子很辛苦滴(⊙﹏⊙)b......

    现在让我们打开chrome的 redux 插件看看效果吧。

    state tree

    上图里是含有大量的 ccApi/setState,是因为还有不少逻辑没有抽离到reducerdispatch/***模样的 type 就是dispatch调用了,后面我们会提到。

    这样看状态变迁是不是要比window.sss好多了,因为sss只能看当前最新的状态。

    这里既然提到了redux-dev-tool,我们就顺道简单了解下,concent 提交的数据长什么样子吧

    action

    上图里可以看到 5 个字段,renderKey是用于提高性能用的,可以先不作了解,这里我们就说说其他四个,module表示修改的数据所属的模块名,committedState表示提交的状态,sharedState表示共享到store的状态,ccUniqueKey表示触发数据修改的实例 id。

    为什么要区分committedStatesharedState呢?因为setState调用时允许提交自己的私有 key 的(即没有在模块里声明的 key ),所以committedState是整个状态都要再次派发给调用者,而sharedState是同步到store后,派发给同属于module值的其他 cc 组件实例的。

    这里就借用官网一张图示意下:

    cc-core

    所以我们可以在组件里声明其他非模块的 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;
      }
    }
    

    解耦业务逻辑与 UI

    虽然代码能够正常工作,状态也接入了 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() {
    }
    

    利用concentconfigure接口把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;
      }
    }
    

    爱 class,爱 hook,让两者和谐共处

    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 减小的压力是显而易见的。

    由于两者的写法高度一致,从classHook是不是非常的自然呢?我们其实不需要争论该用谁更好了,按照你的个人喜好就可以,就算某天你看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 可以点这里,更多在线示例列表点这里

    由于本篇主题主要是介绍渐进式重构组件,所以其他特性诸如synccomputed$watch、高性能杀手锏renderKey等等内容就不在这里展开讲解了,留到下一篇文章,敬请期待。

    如果看官觉得喜欢,就来点颗星星呗,concent致力于为react带来全新的编码体验和功能强化,敬请期待更多的特性和生态周边。

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2785 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 13:28 · PVG 21:28 · LAX 05:28 · JFK 08:28
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.