聊聊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上的方法,这类访问全局对象的行为,我也视之为“越界”行为。毕竟已经跨出了组件之外了。
一旦一个组件有操作全局对象的行为,那它就可以被认为有潜在威胁。所以通常应该注意以下方面:
-
用this.$el.querySelector代替document.querySelector,不要去查询组件外的DOM
-
用到的BOM接口,统一封装成模块,在组件中引入使用
-
本地存储也进行一次包装,例如,把localStorage相关操作统一封到一个storage.js模块中
-
子组件尽量避免监听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>
什么?别骗我!你只是改了命名而已。
我没骗你,“命名即思维“,这是我一贯坚持的准则,胡乱给变量命名的人必然有一颗乱成麻团的脑袋。等你明白了舍生取义的道理,自然会回来和我一起念:「命名即思维」。
把页面上的所有功能都完整的抽象成状态,那就是状态驱动了,而这状态,不是样式的状态。那么,如何拥有正确的状态驱动思维呢?答案就是:面向对象。
面向对象的思维
不看表象,看抽象。前端所要有的面向对象思维差不多就是这样。
表象是啥呢?是输入框,是弹出层,是列表,是表格,是花里胡哨的各种颜色。
抽象是啥呢?是用户名,是密码,是登陆状态,是各种业务数据。我们把页面的内容抽象成对象的属性,把交互抽象成对象的方法。
还是举个例子吧,看下面这个丑陋的原型图:
那我们抽象出来的对象应该大致这样:
{ 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作者也推崇这样来处理弹窗,节点始终挂载在页面,需要弹的时候给显示即可。
本篇结束,以上是笔者在实际开发者总结出的最佳实践,当然这只是一个开发模式,并无对错。大家可以参考,或引发其他思考。