聊聊vue组件开发的“边界把握”和“状态驱动”


vue有着完整的组件化开发机制,但是官网只给了开发的方式,对于开发规范以及组件化开发的最佳实践,还需要我们来摸索。本文就平时开发中的经验来谈谈“把握边界”和“状态驱动”这两个话题。

边界把握

边界把握其实很好理解。在模块化编程中,我们通常要定义好一个模块的功能边界,做什么,不做什么,从外部接收什么,向外部提供什么。在vue的组件化系统之下,这些问题又更具体一些,需要我们细细把握。

划分业务逻辑

这个原则适用于任何模块化开发,一个组件要负责哪些业务,在开始写之初就应该非常明确,否则边界就容易模糊了。举个例子,页面上有个弹出层,里面会显示用户名。那么在弹出层组件中,需要有username这样一个数据吗?

很显然是不需要的。弹出层的任务就是:弹出、关闭、显示内容。至于是什么内容,组件并不需要关心。所以我们顶多会定义一个通用的content字段,或者干脆用slot。

组件简单了尚且容易把握,当业务较复杂的时候就需要好好斟酌了,这是个基本思维。

父子通信的注意点

这个话题想必大家不陌生,你甚至可以朗朗上口的背出来:父通过props传递数据给子,子通过emit发送消息给父。这有什么好说的呢?

props容易忽略的问题在于,当父组件传递一个对象给子组件时,这个传递就不再是“单向”的。因为子组件拿到的是一个引用,当子组件修改了该对象上的属性值,父组件的数据也会相应变化。数据流就变成了双向的,子组件是不应该直接修改父组件的数据的。所以我们要在props中只传递简单值。对象、数组这样的引用类型要避免传递。

为了保证props传递的数据类型,推荐在定义props的时候写明类型和默认值:

props: {
    name: {
        type: string,
        default: ''
    }
}

关于子组件emit消息,我之前也谈到过一个原则,子组件需要对外通知的是“我发生了什么”,而不是“你去干什么”。这只是语义上的一个差别,往小里说只是一个命名的事。但从逻辑上来讲,缺是一个边界把握不清楚的行为。

这也是很容易想通的,如果让子组件决定父组件的行为,那么他们在逻辑上便耦合了。举个例子:点击弹出层上的确定按钮,父组件去请求商品列表。那么子组件发出的消息应该叫”confirm”或”ok”,而不是叫”request-product”。

避免全局操作

我们在平时的编程中,通常会用一些BOM的方法如history,或者是使用document上的方法,这类访问全局对象的行为,我也视之为“越界”行为。毕竟已经跨出了组件之外了。

一旦一个组件有操作全局对象的行为,那它就可以被认为有潜在威胁。所以通常应该注意以下方面:

  1. 用this.$el.querySelector代替document.querySelector,不要去查询组件外的DOM

  2. 用到的BOM接口,统一封装成模块,在组件中引入使用

  3. 本地存储也进行一次包装,例如,把localStorage相关操作统一封到一个storage.js模块中

  4. 子组件尽量避免监听window的事件,可让最外层组件监听,然后传递数据

vuex的状态管理

如果你使用了vuex,那么store中的数据管理也是需要留意的。vue完美集成了vuex这样一个全局状态管理工具,可以在任何组件中通过this.store访问/提交状态。

既然是全局状态,我们担心的又来了,组件内操作全局的东西,岂不是一次越界行为?而且各种commit散落在各个组件中,将来找起来岂不是很麻烦?

我的做法是这样的,单独定义一个模块,姑且叫做storeMonitor吧,所有修改全局状态的方法全部定义在这里面,组件借助这个storeMonitor去修改store中的数据,相当于是一个门面模式。这样的好处是,组件间接地去修改全局状态,相当于建立了一个隔离层。另一方面,所有的commit操作都集中在这个文件中,一目了然。

 

状态驱动

何为状态驱动

状态驱动也可以说是数据驱动,只不过数据是具体存在的(比如一个js对象),“状态”是抽象出来的一种描述。状态驱动就是指代码逻辑集中在数据操作, 而不是DOM操作以及样式操作。

举个例子,一个表单提交按钮,不可点击的时候要灰色背景,可点击的时候要蓝色背景。那么我们通过一个js变量disabled来控制,大致代码如下:

<button :class="disabled ? 'bg-gray' : 'bg-blue'">提交</button>

这不就是mvvm双向绑定的终极奥义嘛,说了半天废话。

其实上面的代码是有问题的。如果你隐隐觉得bg-gray、bg-blue这俩名字有点别扭,甚至那个disabled也看着不顺眼,那么你有可能要理解我想说什么了。

问题在哪里呢?想想这段代码表达了什么语义。“按钮不可用的时候给灰色背景,可用的时候给蓝色背景”,这,明明还是DOM世界的说法嘛。只是包上了双向绑定的皮而已,根本不是状态驱动。

而状态驱动的精髓,是要保留业务逻辑,消灭和DOM、样式有关的一切思维。而我们真正的业务逻辑可能是什么呢?“校验通过的时候让按钮可用,不通过的时候失效”。所以,正确的代码应该这么写:

<button :class="validate ? 'enable' : 'disabled'">提交</button>

什么?别骗我!你只是改了命名而已。

我没骗你,“命名即思维“,这是我一贯坚持的准则,胡乱给变量命名的人必然有一颗乱成麻团的脑袋。等你明白了舍生取义的道理,自然会回来和我一起念:「命名即思维」。

把页面上的所有功能都完整的抽象成状态,那就是状态驱动了,而这状态,不是样式的状态。那么,如何拥有正确的状态驱动思维呢?答案就是:面向对象。

面向对象的思维

不看表象,看抽象。前端所要有的面向对象思维差不多就是这样。

表象是啥呢?是输入框,是弹出层,是列表,是表格,是花里胡哨的各种颜色。

抽象是啥呢?是用户名,是密码,是登陆状态,是各种业务数据。我们把页面的内容抽象成对象的属性,把交互抽象成对象的方法。

还是举个例子吧,看下面这个丑陋的原型图:

聊聊vue组件开发的“边界把握”和“状态驱动”

那我们抽象出来的对象应该大致这样:

{
    businessOptions: [],
    currentIndex: 0,
    selectedList: [],
    select: function(index){ //选中操作  }
    remove: function(index){ //删除操作   }
}

我们的代码逻辑应该是切换currentIndex,以及调用select方法来添加选项到selectedList数组。如果你想用active来表示当前激活的tab,或者是用left/right表示左边/右边两栏,那就大大的犯了表象主义错误。

在写小游戏的时候可能用到的面向对象思维较多,组件化开发中,也应当用这个思维去做整体设计。一个组件就是很具象的实体,所以要将之“物件化”。

css也要“状态”

css作为样式的描述语言,其命名方式以及组织方式有多种规则。在状态驱动的开发思维下,我倾向让css也具有“描述状态”的能力。看下面的一段sass代码:

.sidebar{
    position: absolute;
    bottom: 0;
    width: 80%;
    &.show{
        display: block;
    }
    &.hidden{
        display: none;
    }
    .btn{
        display: inline-block;
        width: 200px;
        height: 20px;
    }
    &.open{
        left: 0;
        .btn{
            background-image: url(left.png);
        }
    }
    &.close{
        left: -80%;
        .btn{
            background-image: url(right.png);
        }
    }
}

光看css,不看js代码的情况下,我们已经可以得知界面的展示逻辑了:有一个名为sidebar的侧边栏,它有四种状态,分别是:show、hidden、open、close。sidebar下有一个按钮btn,它在sidebar打开的时候是向左的背景图,在sidebar关闭的时候是向右的背景图。

这样一套结构清晰,语义明确的css规则,能够帮助我们很快理清页面逻辑,别人在看你的代码的时候一目了然。上面只是一个简单的例子,实践的时候会有复杂的场景,可根据具体功能划分出各自的作用域(嵌套语法),稍微需要花时间去设计,换来的是清晰的代码。

不需要动态创建组件

用mvvm框架去写弹框组件的时候,往往会有这样一个困惑:在jquery时代,我们通过 $.msg('内容')这样的方式调用弹框,此时在页面上动态创建一个节点,关闭弹框的时候再把节点移除。习惯于此,我们很希望能用同样的方式来处理弹框。

当然这在vue中也是可以做到的,方式就是动态创建标签,并且动态new一个组件实例去渲染它,在监听到close消息时,把这个节点手动删掉。大体代码如下:

const MessageConstructor = Vue.extend(alert);
 
const Message = (config) => {
   instance = new MessageConstructor({
       el: document.createElement('div')
   });
   document.body.appendChild(instance.$el);
 
   Vue.nextTick(()=>{
       instance.show = true;
       instance.content = config.content || '';
       instance.type = config.type || 'danger';
       instance.$on('close', function(){
           this.show = false;
           document.body.removeChild(this.$el);
       });
       instance.$on('confirm', config.onConfirm)
   });
}

export default Message;

这样的方式确实可以实现,但是其思想却是和状态驱动违背的,某个应用在某时某刻弹窗,这可以理解为这个应用的状态,我们只需用一个变量来标记该状态即可,犯不着手动创建节点、删除节点这么大动干戈。事实上vue作者也推崇这样来处理弹窗,节点始终挂载在页面,需要弹的时候给显示即可。

本篇结束,以上是笔者在实际开发者总结出的最佳实践,当然这只是一个开发模式,并无对错。大家可以参考,或引发其他思考。


发表评论

电子邮件地址不会被公开。 必填项已用*标注

您可以使用这些HTML标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>