16

上节说到组件https://segmentfault.com/a/1190000009236700,这一节继续来学习组件:

原文博客地址,欢迎学习交流:点击预览
从github上获取本文代码:示例代码

封装的组件要具备复用性和通用性。

先来说复用,复用主要是复用 HTML 结构,外加这块结构中的交互 js,和针对这一块设置的 css。 这三者是构成一个组件最基本的要素,这三者相互隔离有相互作用,将三者聚合起来,在需要使用的地方,类似一个变量(标签对)一样,会引用这一块的所有功能,可以多次使用。

在 vue 中提供了单文件组件,一个文件就是一个组件,这样把组件模块化的方式,让开发者更方便的利用组件堆积页面。将三者聚合在一个文件中,孤立的存在,减少了改动组件而影响外界的风险,极大的提高了代码可维护性。

再说通用性,在讨论通用性这点上,要向两个方面思考:

  1. 外界使用组件,对组件所需要的数据进行定制,由外界传递进来(内部可以设置默认值)
  2. 组件内部的交互要通知给外界,并在外界的控制下产生影响,做不同事情。

组件达到复用后,可以在多个地方使用,而使用的位置不同,需要展示的数据也不同,此时封装的组件要具有通用性,组件内部则由外界使用组件时来决定将要显示的数据,需要将数据传递给组件。

组件的内部除了需要数据外,不可避免的还有交互,当完成一个交互后,需要对外界产生影响,这不能在组件内部做具体的事情,因为使用的位置不同,所产生的效果也不一样,而完成这一系列事情则交给外界来决定,需要组件内部通信给外界,告诉外界,内部完成了一次交互。

注册使用组件

从封装一个自定义的下拉框 custom-select 组件开始。

要达到封装性好,并且可以写多种功能的代码块,那么组件本身就是一个函数或者类,需要使用 Vue.extend( options ) 来创建构造器,这个构造器可以由开发者自己手动初始化挂载,也可以注册成组件在其他组件的模板中使用。

在 body 中放置挂载点:

<div id="app"></div>

定义组件的构造器,并手动初始化,手动挂载:

let customeSelect = Vue.extend({
    template: `
        <div class="select">
            <h2>这是一个定义的下拉框</h2>
            <p>请选择:北京</p>
            <ul>
                <li>北京</li>
                <li>上海</li>
                <li>杭州</li>
            </ul>
        </div>
    `
})
// 手动初始化,挂载到页面的挂载点上
new customeSelect().$mount('#app');

选择手动初始化的方式,调用内置方法 $mount 方法进行挂载,随后组件的模板进行编译,替换掉挂载点,渲染在页面中。

往往定义组件的构造器后,不需要手动的进行初始化,而是在其他组件的模板中当成标签来使用,这时候需要调用 Vue.component( id, [definition] ) 注册成组件。

// 注册组件,传入一个扩展过的构造器
Vue.component('my-component', Vue.extend({ /* ... */ }))

// 注册组件,传入一个选项对象 (自动调用 Vue.extend)
Vue.component('my-component', { /* ... */ })

// 获取注册的组件 (始终返回构造器)
var MyComponent = Vue.component('my-component')

根据注册组件的语法,其实是可以省略调用 Vue.extend 这一步,只需要传入 选项对象即可,内部会自定调用 Vue.extend ,所以定义组件变成了这样的简写方式:

Vue.component('custome-select',{
    template: `
        <div class="select">
            <h2>这是一个定义的下拉框</h2>
            <p>请选择:北京</p>
            <ul>
                <li>北京</li>
                <li>上海</li>
                <li>杭州</li>
            </ul>
        </div>
    `
})

将来 custome-select 就当成了标签使用在其他组件的模板中 < custome-select>< /custome-select>,Vue在编译模板时,就回去找这种自定义标签是否是一个组件,如果已经注册的话,就会把注册的构造器进行初始化,编译组件模板,最终将编译后的模板替换掉自定义标签的位置。如果没有注册直接使用,则会抛出错误。

关于组件名称的命名:

  • 采用烤串(kebab-case)命名,custome-select
  • 采用驼峰命名( PascalCase), customeSelect
  • 名称不能是HTML规定的标签名,比如div、span、header、footer等等。。。
注意:注册时随便使用两种命名方式的任何一种,在模板中一律采用烤串命名才有效。

定义挂载点,并使用组件:

<div id="app">
    <custome-select></custome-select>
</div>

启动应用:

new Vue({
    el: '#app'
})

最终渲染后的结构为:

<div id="app">
    <!--以下替换了原来 <custome-select></custome-select> 标签位置-->
    <div class="select">
        <h2>这是一个定义的下拉框</h2>
        <p>请选择:北京</p>
        <ul>
            <li>北京</li>
            <li>上海</li>
            <li>杭州</li>
        </ul>
    </div>
</div>

给组件定制数据传递props

目前 HTML 达到了复用的目的,但使用多次依然显示的是写死的数据。作为显示数据的 HTML 结构,在不同地方使用,所要展示的数据由外界来决定,这就需要给组件传递数据。

<div id="app">
    <!-- 城市的下拉 -->
    <custome-select></custome-select>
    <!-- 用户的下拉 -->
    <custome-select></custome-select>
    <!-- 日期的下拉 -->
    <custome-select></custome-select>
</div>

而传递参数实际上就是给组件的构造器传递参数,本质上就是给函数传参。函数的参数分为实参和形参两个部分:

  • 实参是实际传递给函数的参数
  • 形参是用来接收数据所声明的变量

现在组件写在模板中以标签对的形式呈现,需要传递实际的参数,唯一的地方就是写在行间作为自定义属性,而传递的参数会有很多个,最好表明具体的含义,需要和组件约定好属性名,传递参数:

<div id="app">
    <!-- 城市的下拉 -->
    <custome-select title="请选择城市" :list="['北京','上海','杭州']"></custome-select>
    <!-- 用户的下拉 -->
    <custome-select title="请选择用户" :list="['张三','李四','王五']"></custome-select>
</div>

在组件中需要显示的用 props 接收传递的数据,这样的好处就是一旦看到组件,就会很清晰快速的了解到组件所需要的数据。

注意: 在行间写上自定义属性,要解析为数组,在属性名前加上 v-bind,解析为 javascript 表达式,否则只能当成是字符串。

具体如下:

Vue.component('custome-select',{
    // 关于props具体参考:
    // https://cn.vuejs.org/v2/guide/components-props.html#Prop-%E7%B1%BB%E5%9E%8B
    // https://cn.vuejs.org/v2/guide/components-props.html#Prop-%E9%AA%8C%E8%AF%81
    props:{
        title: {
            type: String,
            default: '这是一个定义的下拉框'
        },
        list:{
            type: Array,
            default(){return []}
        },
        selectIndex:{
            type: Number,
            default:0
        }
    },
    template: `
        <div class="select">
            <h2>{{title}}</h2>
            <p>请选择:{{list[selectIndex]}}</p>
            <ul>
                <li 
                    v-for="item,index in list"
                    :key="index"
                >
                    {{item}}
                </li>
            </ul>
        </div>
    `
})

在组件中约定了三个需要接收的参数,分别写出了接受的类型和默认值,props参数文档如下:

属性 说明 类型 默认值
title 定制组件的标题 String '这是一个定义的下拉框''
list 定制组件的下拉列表 Array []
selectIndex 选择要展示的一项 String 0

有了文档,很清晰的知道每一个属性代表的意思,传入响应的参数后,就会达到预期的效果。

组件自身状态data

以上渲染后直接把下拉框显示了出来,下拉框应该是在点击 p 标签时候才能显示,再次点击就隐藏掉,要实现这样的一个显示隐藏切换功能。

Vue 中不提倡直接操作 DOM,需要设置一个状态来确定 DOM 的状态,当需要改变 DOM 时,只需要改变设置好的状态即可,把我们的关注点放在状态的维护上,而无需手动操作 DOM 改变。

这个状态不受外界的影响,属于是组件自身的状态变化,定义在组件内部,并且改变时只能由组件自身更改。

具体如下:

Vue.component('custome-select',{
    ... 省略了props设置
    data(){
        return {
            show: false  // 一开始状态为false,也就是不显示下拉列表
        }
    },
    template: `
        <div class="select">
            <h2>{{title}}</h2>
            <p @click="toggleShow">请选择:{{list[selectIndex]}}</p>
            <ul v-show="show">
                <li 
                    v-for="item,index in list"
                    :key="index"
                >
                    {{item}}
                </li>
            </ul>
        </div>
    `,
    methods:{
        toggleShow(){
            this.show = !this.show;
        }
    }
})

以上做了三件事情:

  1. data 中设置一个状态为 show,初始值为 false,来表示下拉列表为隐藏状态
  2. 在模板上使用指令 v-show="show" 控制 DOM 的显示隐藏
  3. p 绑定事件,切换 show 的值,一旦改变,自动更新 DOM 到对应状态上,也就是 true 显示,false 隐藏

Vuejs 这个框架要做的就是状态和UI保持同步。

单向数据流

单向数据流顾名思义就是单方向的数据流向,也就是数据只能从一边流向另一边,反过来则不行,如黄河之水从天上来,却不能再流回到天上去。具体到组件中,就是:父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,子组件改变不能改变父组件。这样设计的目的是防止从子组件意外改变父级组件的状态,从而导致应用的数据流向难以理解。

与之对应的就是双向数据流,父组件子组件都可以任意修改,互相产生影响,这样的话使用这套数据的其他组件也会跟着变化,变得非常的诡异。

在复杂的应用中,控制数据有规则的改变和传递非常重要,如果不是单向数据流的限制,任何组件都能修改数据,就跟定义全局的数据在任何程序都能修改一样,最终经过多个函数的调用修改后,出现了问题,不能准确的定位到具体的函数中,排查问题会变的非常的困难。

每次父级组件发生更新时,子组件中所有的 prop 都将会刷新为最新的值。由父组件传递给子组件的数据,子组件内部不能改变 prop。如果你这样做了,Vue 会在浏览器的控制台中发出警告。

来个例子说明一下。上面的例子中,需要在下拉框中选择具体的的一项,显示在 p标签中,要显示的数据是通过外界传递的 selectIndex 来决定从 list 中选取哪一项。那我们可以这样来做,在点击下拉框的某一项时,改变 selectIndex 为点击的一项的下标即可,具体如下:

HTML 代码:

<div id="app">
    <!-- 城市的下拉 -->
    <custome-select 
        title="请选择城市" 
        :list="['北京','上海','杭州']" 
        :select-index="0"
    ></custome-select>
</div>

JavaScript代码:

Vue.component('custome-select',{
    props:{
        // 省略了title和list....
        selectIndex:{
            type: Number,
            default:0
        }
    },
    data(){
        return {
            show: false  // 一开始状态为false,也就是不显示下拉列表
        }
    },
    template: `
        <div class="select">
            <h2>{{title}}</h2>
            <p @click="toggleShow">请选择:{{list[selectIndex]}}</p>
            <ul v-show="show">
                <li 
                    v-for="item,index in list"
                    :key="index"
                    @click="changeIndex(index)"
                >
                    {{item}}
                </li>
            </ul>
        </div>
    `,
    methods:{
        toggleShow(){
            this.show = !this.show;
        },
        changeIndex(index){
            // 改变为选中的下标,此时会报错
            this.selectIndex = index;
        }
    }
})

new Vue({
    el: '#app'
})

以上代码做的事情:

  1. 接收外界传入的 selecteIndex,在模板中选择对应的值{{list[selectIndex]}}
  2. 给下拉框的每一项绑定事件,并传递各自的下标
  3. 传递下标给到 changeIndex 函数,改变selectIndex的值
  4. 控制台报错:[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "selectIndex"大致的意思是:不能直接修改组件的props值,当父组件重新渲染时候会重写这个。在组件中使用data或者computed属性来代替修改prop的值。

以上的报错已经警告了,不能直接修改props的值,但是组件内部是可以修改组件内部数据 data ,所以修改如下:

// 其他代码省略
Vue.component('custome-select',{
    data(){
        return {
            currentIndex: this.selectIndex // 把selectIndex作为currentIndex的初始值
        }
    },
    template: `
        <div class="select">
            <h2>{{title}}</h2>
            <p @click="toggleShow">请选择:{{list[currentIndex]}}</p>
            <ul v-show="show">
                <li 
                    v-for="item,index in list"
                    :key="index"
                    @click="changeIndex(index)"
                >
                    {{item}}
                </li>
            </ul>
        </div>
    `,
    methods:{
        changeIndex(index){
            // 改变自己内部状态currentIndex
            this.currentIndex = index;
        }
    }
})

以上代码分析:

  1. 在 data 中定义组件内部状态currentIndex,将props中的selectIndex的值,作为currentIndex的初始值
  2. 修改模板中取值的selectIndex为currentIndex
  3. 点击改变currentIndex,此时修改的是组件内部状态,不是props的值,修改成功

从以上的例子中可以看出来,data 中定义的就是组件内部状态,只在组件内部更改,而传递的 props 不能在组件内修改,可以通过赋值给data,修改data的值来更新组件自身的状态。

父组件监听,子组件发布

上面说的是父组件向子组件定制数据传递 props,在子组件内部会产生一些交互。

子组件内部交互一旦发生后,父组件是需要根据子组件的交互会产生一些影响,比如改变颜色,显示文字等。父组件这些变换又不能写在子组件的交互中,因为子组件是通用的组件。一旦写了某个父组件的业务代码,只能和这个父组件绑定在一起,不能使用在别的地方了了,此时组件不能达到通用的目的。

举个例子:
以下使用了两次 custome-select 组件,当点击第一个组件的下拉框某一项时候,就需要改变 class 为 test1 的div 样式为 red 色。当点击第二个组件的下拉框某一项时候,就需要改变 class 为 test2 的div 样式为 blue 色。

<div id="app">
    <!-- 城市的下拉 -->
    <div class="test1" :style="{color: color1}">第一个需求</div>
    <custome-select 
        title="请选择城市" 
        :list="['北京','上海','杭州']" 
        :select-index="0">
    </custome-select>
    <!-- 用户的下拉 -->
    <div class="test2" :style="{color: color2}">第二个需求</div>
    <custome-select 
        title="请选择用户" 
        :list="['张三','李四','王五']" 
        :select-index="1">
    </custome-select>
</div>

使用了两次组件,组件内部点击下拉框时不能写具体的处理第一个需求还是第二个需求。而是交到外部的父组件来决定,这时候父组件就需要知道子组件内部是否点击了下拉框。而点击下拉框这个动作是由用户触发的,不知何时会触发一次,那怎么办呢?
跟原生的元素处理思路一样,假定以后用户点击了这个元素后,需要改变页面中样式,那么就需要监控这个元素的点击事件,只要用户点击了,触发事件处理函数,在函数中写具体改变样式这个动作。
HTML代码:

<button onclick="handle()">按钮</button>

JavaScript代码:

<script>
    // 全局设置函数
    function handle(){
        document.body.style.background = 'red'    
    }
</script>

以上代码是 DOM0 级时代的写法,直接在行间写监听事件,这样写更直观。目的就是当有用户点击了按钮一下,浏览器内部就会发布一个 click 事件,而正好我们在元素上监听了 click 事件,就会把对应的事件处理函数触发,从而达到开发者的目的,对页面做出一些变化。

组件标签使用在模板中,此时外界需要知道组件内部发生了的交互,那么思路一致,也需要在行间监听事件,不过此事件名字不限于是 w3c 规定的事件名,可以自定义事件名,结合 Vue 中绑定事件的方式,代码如下:

<div id="app">
    <!-- 城市的下拉 -->
    <div class="test1" :style="{color: color1}">第一个需求</div>
    <custome-select 
        title="请选择城市" 
        :list="['北京','上海','杭州']" 
        :select-index="0"
        @click-option="changeTest1Handle"
    >
    </custome-select>
    <!-- 用户的下拉 -->
    <div class="test2" :style="{color: color2}">第二个需求</div>
    <custome-select 
        title="请选择用户" 
        :list="['张三','李四','王五']" 
        :select-index="1"
        @click-option="changeTest2Handle"
    >
    </custome-select>
</div>

把事件处理函数写在选项对象中:

new Vue({
    el: '#app',
    data: {
        color1: '',
        color2: ''
    },
    methods: {
        // 第一个需求
        changeTest1Handle(){
            this.color1 = 'red';
        },
        // 第二个需求
        changeTest2Handle(){
            this.color2 = 'blue';
        }
    }
})

以上代码准备完毕,去点击下拉选项,并没有触发父组件的函数,并没有完成需求,为什么呢?
在原生元素上在行间监控事件,用户点击元素后,浏览器会发布 click 事件。而现在换做是使用自定义事件来监控子组件内部产生的交互,这就需要在子组件内部自己发布这个自定义的事件,否则监控的自定义事件是无效的。

那什么时候发布事件呢?就是在用户点击了下拉框的选项时候发布这个自定义事件即可。

你可以这样来理解,监听原生事件 click ,只需要监听,开发者无需手动的在浏览器内部写发布事件,click 事件名是浏览器给开发者约定的名字。而现在我们需要自己设计子组件发布事件,父组件监听这样的机制。所以需要开发者自己约定事件的名字和手动的在组件中发布事件。在 Vue 中这样的订阅/发布模式已经写好,开发者只需要调用即可。

在子组件中发布事件:

// 其他代码省略
methods:{
    changeIndex(index){
        this.currentIndex = index;
        // 在点击选项时候产生交互,手动发布事件,通知父组件
        this.$emit('click-option');
    }
}

当点击选项时候,父组件中会完成不同的需求,改变不同元素的颜色。

以上代码父子组件之间完全的解耦,父组件中不使用这个组件,依然可以工作,子组件不使用在这个组件中,可以使用在任意其他的组件中。如果父组件关系子组件内部选中下拉框一项这个交互,只需要监听 click-option这个自定义事件,不关心则不监听。

总结

以上可以看出一个组件数据的来源有两个:

  • 组件自身的数据,写在 data 中
  • 父组件传递的数据,写在props中

父子组件之间通信:

  • 父 ---> 子,使用 props
  • 子 ---> 父,订阅发布模式

以上属于个人理解,如有偏差欢迎指正学习,谢谢。


戎马
2.4k 声望346 粉丝

前端码农一枚,上班一族,爱文学一本。ส็็็็็็็็็็็็็็ ส้้้้้้้้้้้้้้้้้้้。