前端 - Vue组件与脚手架

Vue文档

组件化编程

组件的定义:实现应用中局部功能的代码(HTML, CSS, JS等)和资源(images等)的整合。

组件分为:

  • 非单文件组件:一个文件中包含若干个组件
  • 单文件组件:一个文件中只有一个组件,即我们看到的.vue文件

非单文件组件

实现对下面HTML页面的组件化编程:

<div id="root">
    <h1>学校名称:{{schoolName}}</h1>
    <h1>地址:{{address}}</h1>
    <hr>
    <h1>学生姓名:{{stuName}}</h1>
    <h1>年龄:{{age}}</h1>
</div>

通过非单文件组件:

<div id="root">
    <!-- 第三步:编写组件标签 -->
    <School></School>
    <hr>
    <student></student>
    <!-- 组件复用, 且数据互相独立 -->
    <student></student>
</div>
/* 第一步:定义school组件和student组件 */
// 定义school组件
const s = Vue.extend({
    // 不能写el, 因为所有的组件都要被一个vm管理, 由vm决定组件给哪个容器服务
    // 使用template必须只有一个根元素, 即所有元素最终都要被一个根元素包裹
    template: `
        <div>
            <h1>学校名称:{{schoolName}}</h1>
            <h1>地址:{{address}}</h1>
        </div>
    `,
    // data必须写成函数, 这样每个实例可以维护一份被返回对象的独立拷贝
    data() {
        return {
            schoolName: 'My school',
            address: 'Hangzhou',
        }
    },
})
// 定义student组件
const student = Vue.extend({
    template: `
        <div>
            <h1>学生姓名:{{stuName}}</h1>
            <h1>年龄:{{age}}</h1>
        </div>
    `,
    data() {
        return {
            stuName: 'Lee',
            age: 30
        }
    },
})

// 创建vm
const vm = new Vue({
    el: '#root',
    /* 第二步:注册组件(局部注册) */
    components: {
        School: school,
        student  // 同名, 可以简写
    }
});

上面使用了局部注册,使用全局注册的方法:

/* 第二步:注册组件(全局注册, 所有Vue实例都能用) */
Vue.component('School', s);
Vue.component('student', student);

// 创建vm
const vm = new Vue({
    el: '#root'
});

组件名

  1. 一个单词:school或者School
  2. 多个单词:my-school或者MySchool(该方式需要Vue脚手架支持)

注意:

  • 组件名尽可能回避HTML标签名
  • 创建组件时,配置name属性,可以指定组件在Vue开发者工具中显示的名字,实际上使用时还是需要注册时的名字

组件标签

可以写为<school></school>,也可以<school/>(该方式需要Vue脚手架支持,否则后续组件无法渲染)

简写

对上面的school简写,可以直接写成对象,不写Vue.extend()方法:

const s = {
    template: `
        <div>
            <h1>学校名称:{{schoolName}}</h1>
            <h1>地址:{{address}}</h1>
        </div>
    `,
    data() {
        return {
            schoolName: 'My school',
            address: 'Hangzhou',
        }
    },
};

const vm = new Vue({
    el: '#root',
    components: {
        school: s,  // 注册时如果发现是对象, 自动调用Vue.extend()方法
        student
    }
});

组件的嵌套

一般来说,vm只管理一个app组件,而app负责管理剩下的组件。

<div id="root"></div>
// 首先定义 子组件student
const student = {
    template: `
        <div>
            <h1>学生姓名:{{stuName}}</h1>
            <h1>年龄:{{age}}</h1>
        </div>
    `,
    data() {
        return {
            stuName: 'Lee',
            age: 30
        }
    },
};

// 然后定义 父组件school
const school = {
    // 父组件template可以调用子组件
    template: `
        <div>
            <h1>学校名称:{{schoolName}}</h1>
            <h1>地址:{{address}}</h1>
            <student></student>
        </div>
    `,
    data() {
        return {
            schoolName: 'My school',
            address: 'Hangzhou',
        }
    },
    // 注册组件, 当前组件管理student组件
    components: {
        student
    }
};

// 定义app组件, 管理其他组件
const app = {
    template: `
        <div>
            <school></school>
        </div>
    `,
    // 注册组件, 当前组件管理school组件
    components: {
        school
    }
}

// 创建vm
const vm = new Vue({
    el: '#root',
    template: `<app></app>`,
    // 只需要注册app组件
    components: {
        app
    }
});

VueComponent构造函数

首先看一下一个简单的例子:

// 定义组件school
const school = Vue.extend({
    template: `
        <div>
            <h1>学校名称:{{schoolName}}</h1>
        </div>
    `,
    data() {
        return {
            schoolName: 'My school'
        }
    }
});

// 创建vm
const vm = new Vue({
    el: '#root',
    template: `<school></school>`,
    components: {
        school
    }
});

输出school,可以发现school组件就是一个VueComponent构造函数:

console.log(school);  // ƒ VueComponent (options) { this._init(options); }

总结

  1. school组件本质上是一个名为VueComponent的构造函数,该函数由Vue.extend()生成
  2. 每当有<school></school>,Vue解析时会执行new VueComponent(options),创建school组件的实例对象
  3. 注意:每次调用Vue.extend(),返回的都是一个全新VueComponent构造函数
  4. 在组件配置(data, watch, computed等的函数)中,this指向的是VueComponent实例对象

Vue实例与组件实例

一个重要的关系:

// VueComponent.prototype.__proto__ === Vue.prototype
console.log(school.prototype.__proto__ === Vue.prototype);  // true

这样的话,组件实例就能够访问到Vue原型上的属性、方法。

$delete: ƒ del(target, key)
$destroy: ƒ ()
$emit: ƒ (...args)
$forceUpdate: ƒ ()
$inspect: ƒ ()
$mount: ƒ ( el, hydrating )
$nextTick: ƒ (fn)
$off: ƒ (event, fn)
$on: ƒ (event, fn)
$once: ƒ (event, fn)
$set: ƒ (target, key, val)
$watch: ƒ ( expOrFn, cb, options )
...

正常来说,Vue的实例对象满足vm.__proto__ === Vue.prototype,而Vue.prototype是一个Object实例对象,所以Vue.prototype.__proto__ === Object.prototype,即vm.__proto__.__proto__ === Object.prototype。因此,vm既能使用Vue的原型的属性和方法,也能使用Object的原型的属性和方法。

所以,由于school是一个VueComponent构造函数,按理来说,school的实例化对象应该也满足类似关系。但是在Vue中,手动地将VueComponent.prototype.__proto__赋值为Vue.prototype。建立了这样一条原型链,VueComponent的实例对象就能够同时使用VueComponent的原型、Vue的原型、Object的原型的属性和方法。

在Vue源码中:

var Sub = function VueComponent (options) {
    this._init(options);
};
// 创建一个新对象, 它的__proto__为Super.prototype, 然后将Sub.prototype设置为这个新创建的对象
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;  // 修正constructor

Object.create()方法创建一个新对象,新创建的对象的__proto__为传入的对象。

单文件组件

暴露的三种方法

<script>
    /* 方式一 */
    export const school = Vue.extend({
        template: `
            <div>
                <h1>学校名称:{{schoolName}}</h1>
            </div>
        `,
        data() {
            return {
                schoolName: 'My school'
            }
        }
    });
</script>
<script>
    /* 方式二 */
    const school = Vue.extend({...});
    export {school};
</script>
<script>
    /* 方式三 */
    const school = Vue.extend({...});
    export default school;
</script>

一般来说采取方式三,默认暴露的方式书写。由于只需要暴露一个变量,所以不需要中转变量school;并且Vue.extend()可以简写,所以一般写成:

<script>
    export default {...};
</script>

单文件组件举例

<body>
    <!-- index.html -->
    <div id="root"></div>

    <script type="text/javascript" src="../js/vue.js"></script>
    <script type="text/javascript" src="./main.js"></script>
</body>
/* main.js Vue实例 */

import App from './App.vue'
new Vue({
    el: '#root',
    template: '<App></App>',
    components: { App }
});
<template>
  <div>
      <School></School>
      <School></School>
  </div>
</template>

<script>
import School from "./School.vue";

export default {
  name: "App",
  components: {
    School,
  },
};
</script>
<template>
  <!-- 组件结构 -->
  <div>
    <h1>学校名称:{{ name }}</h1>
    <h1>地址:{{ address }}</h1>
    <Student></Student>
    <Student></Student>
  </div>
</template>

<script>
import Student from "./Student.vue";

export default {
  name: "School",
  data() {
    return {
      name: "My school",
      address: "Hangzhou",
    };
  },
  components: {
    Student,
  },
};
</script>

<style>
/* 组件的样式 */
</style>
<template>
  <div>
    <h2>学生姓名:{{ name }}</h2>
    <h2>性别:{{ sex }}</h2>
  </div>
</template>

<script>
export default {
  name: "Student",
  data() {
    return {
      name: "Lee",
      sex: "female",
    };
  },
};
</script>

单文件组件需要在Vue脚手架环境下运行。

Vue脚手架

脚手架分析

在执行下面命令后,自动生成脚手架:

vue create hello_cli

main.js是整个项目的入口文件:

import Vue from 'vue'  // 引入Vue
import App from './App.vue'  // 引入App组件, 它是所有组件的父组件

Vue.config.productionTip = false  // 关闭vue生产提示

// 创建Vue实例对象, 即vm
new Vue({
  render: h => h(App),  // 将App组件放入容器中
}).$mount('#app')

完成对应的.vue文件编写,即能成功运行。

render()

如果将上面的main.js稍作修改

new Vue({
  el: '#app',
  template: '<App></App>',
  components: { App }
});

会出现错误:

[Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.

这是因为通过下面这条语句引入的Vue是不完整的,无法解析实例化Vue时的template

import Vue from 'vue'

官方文档

字符串模板的代替方案,允许你发挥 JavaScript 最大的编程能力。该渲染函数接收一个 createElement 方法作为第一个参数用来创建 VNode

render()函数会接受一个 createElement 方法,所以相当于调用了createElement(App)

ref属性

ref 被用来给元素或子组件注册引用信息。引用信息将会注册在父组件的 $refs 对象上。如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例

<div id="app">
  <Student ref="stu"/>
  <button @click="check">Click</button>
  <h3 ref="h3test">Test</h3>
</div>
export default {
  name: 'App',
  components: {
    Student
  },
  methods: {
    check() {
      console.log(this.$refs);  // {stu: VueComponent, h3test: h3}
    }
  }
}

props配置

通过数组简单接收

<div>
  <h1>{{hello}}</h1>
  <h2>姓名:{{name}}</h2>
  <h2>年龄:{{age + 1}}</h2>
</div>
export default {
    name : "Student", 
    data() {
        return {
            hello: 'Hello!!!'
        }
    },
    props: ['name', 'age']  // 数组接受
}

Student的父组件App中:

<div id="app">
  <Student name="zhangsan" :age="20"/>
  <Student name="lisi" :age="30"/>
  <Student name="wangwu" :age="25"/>
</div>

注意

  • 使用v-bind绑定age属性,这样属性值不再是字符串,而是作为JS表达式运行
  • props不能使用keyref等特殊属性

通过对象指定数据

仅对数据类型指定:

export default {
    // ...
    props: {
        name: String,
        age: Number
    }
}

指定数据是否是必须的以及默认值:

export default {
    // ...
    props: {
        name: {
            type: String,
            require: true  // 属性必须
        },
        age: {
            type: Number,
            default: 100  // 属性默认值是100
        }
    }
}

修改props

尝试修改age

<div>
  <h1>{{hello}}</h1>
  <h2>姓名:{{name}}</h2>
  <h2>年龄:{{age + 1}}</h2>
  <button @click="changeAge">Age++</button>
</div>
export default {
    // ...
    methods: {
        changeAge() {
            this.age++;
        }
    },
}

虽然能成功修改,但是会有如下警告

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders.

备注:props是只读的,如果确实需要修改,需要复制props的内容到data,然后修改。

使用data中的变量接收props:

<div>
  <h1>{{hello}}</h1>
  <h2>姓名:{{myName}}</h2>
  <h2>年龄:{{myAge + 1}}</h2>
  <button @click="changeAge">Age++</button>
</div>
export default {
    name : "Student", 
    data() {
        return {
            hello: 'Hello!!!',
            myName: this.name,  // 接收props
            myAge: this.age
        }
    },
    // props: {...},
    methods: {
        changeAge() {
            this.myAge++;  // 修改data中的变量
        }
    },
}

mixin混入

可以把多个组件公用的配置提取成一个混入对象。

局部混入

/* mixin.js */
export const mixin = {
    data() {
        return {
            name: 'mixin_test',
            hello: 'Hello!!'
        }
    },
    methods: {
        showName() {
            console.log(this.name);
        }
    },
    mounted() {
        console.log('Mounted!!!!');
    },
}
/* Student.vue */
import {mixin} from '../mixin'

export default {
  // name: "Student",
  // data() {...},
  mixins: [mixin]
};
/* School.vue */
import Student from "./Student.vue";
import {mixin} from "../mixin"

export default {
  // name: "School",
  // data() {...},
  // components: {...},
  mixins: [mixin],
  mounted() {
    console.log('Mounted School!');
  },
};

运行后发现:

  1. Student组件和School组件都能使用showName()方法
  2. Student组件和School组件本身在data中定义的name属性值没有被mixin覆盖,而原来没有定义的hello属性出现在data中,值为mixin的值
  3. 生命周期钩子mounted()则是二者都触发,School组件先输出Mounted!!!!再输出Mounted Schoool!

全局混入

import Vue from 'vue'
import App from './App.vue'
import {mixin} from './mixin'

Vue.config.productionTip = false
Vue.mixin(mixin)

new Vue({
  render: h => h(App),
}).$mount('#app')

如果使用全局混入,RootApp也会被混入。

插件

用于增强Vue,实际上是包含install()方法的一个对象,第一个参数是Vue,之后的参数是插件使用者传入的参数。

import Vue from 'vue'
import App from './App.vue'
import plugins from './plugins'  // 引入插件

Vue.config.productionTip = false
Vue.use(plugins, 'aa', 'bb', 'cc');  // 使用插件并传入参数

new Vue({
  render: h => h(App),
}).$mount('#app');

/* plugins.js */
export default {
    install(Vue, a, b, c) {
        console.log(a, b, c);  // aa bb cc
        
        /* 定义全局过滤器 */
        // Vue.filter(...)

        /* 定义全局指令 */
        // Vue.directive(...)

        /* 定义全局混入 */
        // Vue.mixin(...)

        /* 给Vue原型添加方法(vm和vc都能使用) */
        Vue.prototype.testInstall = () => console.log('Test install!');
    }
}

scoped样式

当CSS类名出现冲突时,会根据引用顺序决定最终显示效果:

Student.vue中定义.test样式:

<template>
  <div class="test">
    <h2 @click="showName">学生姓名:{{ name }}</h2>
    <h2 @click="testInstall">性别:{{ sex }}</h2>
  </div>
</template>

<script>
  // ...
</script>

<style>
  .test {
    background-color: skyblue;
  }
</style>

School.vue中也定义.test样式:

<template>
  <div class="test">
    <h1 @click="showName">学校名称:{{ name }}</h1>
    <h1>地址:{{ address }}</h1>
  </div>
</template>

<script>
  // ...
</script>

<style>
  .test {
    background-color: orange;
  }
</style>

那么最终显示效果根据App.vue的引入顺序决定,如果是下面的顺序,则显示为orange;当顺序调换,显示为skyblue

<template>
  <div>
    <School></School>
    <Student></Student>
  </div>
</template>

<script>
import Student from "./components/Student.vue"
import School from "./components/School.vue"

// export default {...};
</script>

使用scoped属性让样式只在局部生效School.vueStudent.vue二者中任意的样式添加scoped属性,即可恢复正常:

<style scoped>
  .test {
    background-color: skyblue;
  }
</style>

此外,使用npm下载less后,还可以使用less:

<style lang="less" scoped>
  .test {
    background-color: skyblue;
  }
</style>

TodoList样例

流程

  1. 实现静态组件
  2. 展示动态数据:数据保存在哪个组件
  3. 交互:绑定事件监听

源码

安装nanoid用于生成唯一id:

npm i nanoid

App.vue存储待办事项,并且提供一些修改待办事项的方法,通过props传输给子组件。

<template>
  <div id="root">
    <div class="todo-container">
      <div class="todo-wrap">
        <!-- 通过props将添加item的方法传给MyHeader -->
        <MyHeader :addTodo="addTodo" />
        <!-- 通过props将itemList等传给MyList -->
        <MyList
          :itemList="itemList"
          :checkTodo="checkTodo"
          :removeTodo="removeTodo"
        />
        <MyFooter
          :itemList="itemList"
          :selectAll="selectAll"
          :removeAllCompleted="removeAllCompleted"
        />
      </div>
    </div>
  </div>
</template>

<script>
import MyHeader from "./components/MyHeader.vue";
import MyList from "./components/MyList.vue";
import MyFooter from "./components/MyFooter.vue";

export default {
  name: "App",
  components: {
    MyHeader,
    MyList,
    MyFooter,
  },
  data() {
    return {
      // 存储事项
      itemList: [
        { id: "001", isDone: true, content: "学习Vue" },
        { id: "002", isDone: false, content: "学习ES6" },
        { id: "003", isDone: false, content: "学习AJAX" },
      ],
    };
  },
  methods: {
    // 添加一个新item
    addTodo(todo) {
      this.itemList.unshift(todo);
    },
    // 将一个item的isDone取反
    checkTodo(itemId) {
      this.itemList.forEach((todo) => {
        if (todo.id === itemId) {
          todo.isDone = !todo.isDone;
        }
      });
    },
    // 删除一个todo
    removeTodo(itemId) {
      this.itemList = this.itemList.filter((todo) => {
        return todo.id !== itemId;
      });
    },
    // 全选/全不选
    selectAll(isSeleted) {
      this.itemList.forEach((todo) => {
        todo.isDone = isSeleted;
      });
    },
    // 删除所有已经完成的todo
    removeAllCompleted() {
      this.itemList = this.itemList.filter((todo) => {
        return !todo.isDone;
      });
    },
  },
};
</script>

<style>
/*base*/
body {
  background: #fff;
}
.btn {
  display: inline-block;
  padding: 4px 12px;
  margin-bottom: 0;
  font-size: 14px;
  line-height: 20px;
  text-align: center;
  vertical-align: middle;
  cursor: pointer;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2),
    0 1px 2px rgba(0, 0, 0, 0.05);
  border-radius: 4px;
}
.btn-danger {
  color: #fff;
  background-color: #da4f49;
  border: 1px solid #bd362f;
}
.btn-danger:hover {
  color: #fff;
  background-color: #bd362f;
}
.btn:focus {
  outline: none;
}
.todo-container {
  width: 600px;
  margin: 0 auto;
}
.todo-container .todo-wrap {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 5px;
}
</style>

MyHeader.vue负责添加新事项。

<template>
  <div class="todo-header">
    <!-- 绑定按下回车键 -->
    <input
      type="text"
      v-model="content"
      placeholder="请输入你的任务名称,按回车键确认"
      @keydown.enter="addItem"
    />
  </div>
</template>

<script>
import { nanoid } from "nanoid"; // 引入nanoid, 生成唯一id

export default {
  name: "MyHeader",
  data() {
    return {
      content: "",
    };
  },
  props: {
    addTodo: Function, // 接收addTodo()方法
  },
  methods: {
    addItem(event) {
      if (!this.content) return; // 判断输入是否为空

      // 获取用户输入, 并且包装成对象传输
      const todo = {
        id: nanoid(),
        content: this.content,
        isDone: false,
      };
      this.addTodo(todo);

      this.content = ""; // 将输入框重新变为空
    },
  },
};
</script>

<style scoped>
/*header*/
.todo-header input {
  width: 560px;
  height: 28px;
  font-size: 14px;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 4px 7px;
}

.todo-header input:focus {
  outline: none;
  border-color: rgba(82, 168, 236, 0.8);
  box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),
    0 0 8px rgba(82, 168, 236, 0.6);
}
</style>

MyList.vue负责展示事项。

<template>
  <ul class="todo-main">
    <!-- 使用props传数据给MyItem -->
    <MyItem
      v-for="todo of itemList"
      :key="todo.id"
      :todo="todo"
      :checkTodo="checkTodo"
      :removeTodo="removeTodo"
    />
  </ul>
</template>

<script>
import MyItem from "./MyItem.vue";

export default {
  name: "MyList",
  components: { MyItem },
  props: {
    itemList: Array,
    checkTodo: Function,
    removeTodo: Function
  },
};
</script>

<style scoped>
/*main*/
.todo-main {
  margin-left: 0px;
  border: 1px solid #ddd;
  border-radius: 2px;
  padding: 0px;
}

.todo-empty {
  height: 40px;
  line-height: 40px;
  border: 1px solid #ddd;
  border-radius: 2px;
  padding-left: 5px;
  margin-top: 10px;
}
</style>

MyItem.vue是一个事项的组件,可以删除该事项。

<template>
  <li>
    <label>
      <!-- checkbox被勾选时触发事件 -->
      <input type="checkbox" :checked="todo.isDone" @change="handleChange">
       <!-- 不建议用v-model绑定todo.isDone, 因为这样相当于修改props -->
      <!-- <input type="checkbox" v-model="todo.isDone"> -->
      <span>{{todo.content}}</span>
    </label>
    <button class="btn btn-danger" @click="handleDelete">删除</button>
  </li>
</template>

<script>
export default {
  name: "MyItem",
  props: {
    todo: Object,
    checkTodo: Function,
    removeTodo: Function
  },
  methods: {
    handleChange() {
      this.checkTodo(this.todo.id);
    },
    handleDelete() {
      if(confirm('是否删除该事项?')) {
        this.removeTodo(this.todo.id);
      }
    }
  },
};
</script>

<style scoped>
/*item*/
li {
  list-style: none;
  height: 36px;
  line-height: 36px;
  padding: 0 5px;
  border-bottom: 1px solid #ddd;
}

li label {
  float: left;
  cursor: pointer;
}

li label li input {
  vertical-align: middle;
  margin-right: 6px;
  position: relative;
  top: -1px;
}

li button {
  float: right;
  display: none;
  margin-top: 3px;
}

li:before {
  content: initial;
}

li:last-child {
  border-bottom: none;
}

li:hover {
  background-color: #ddd;
}

/* 鼠标在li上, 显示button */
li:hover button {
  display: block;
}
</style>

MyFooter.vue展示已经完成的事项和总事项,可以全选和全不选,可以删除所有已完成的事项。

<template>
  <div class="todo-footer" v-show="itemList.length">
    <label>
      <!-- 当todo被全选时打勾 -->
      <input type="checkbox" v-model="isAll"/>
    </label>
    <span> <span>已完成({{completed}})</span> / 全部 ({{itemList.length}})</span>
    <button class="btn btn-danger" @click="handleRemoveCompleted">清除已完成任务</button>
  </div>
</template>

<script>
export default {
  name: "MyFooter",
  props: {
    itemList: Array,
    selectAll: Function,
    removeAllCompleted: Function
  },
  computed: {
    completed() {
      // prev先前的返回值, curr当前对象, 0初始值
      return this.itemList.reduce((prev, curr) => prev + curr.isDone, 0);
    },
    isAll: {
      get() {
        return this.itemList.length !==0 && this.completed === this.itemList.length;
      },
      // 通过setter完成全选
      set(value) {
        this.selectAll(value);
      }
      
    }
  },
  methods: {
    handleRemoveCompleted() {
      if(confirm('是否删除所有已完成事项?')) {
        this.removeAllCompleted();
      }
    }
  },
};
</script>

<style scoped>
/*footer*/
.todo-footer {
  height: 40px;
  line-height: 40px;
  padding-left: 6px;
  margin-top: 5px;
}

.todo-footer label {
  display: inline-block;
  margin-right: 20px;
  cursor: pointer;
}

.todo-footer label input {
  position: relative;
  top: -1px;
  vertical-align: middle;
  margin-right: 5px;
}

.todo-footer button {
  float: right;
  margin-top: 5px;
}
</style>

总结

  1. v-model不能绑定props传来的值,因为props传来的值是只读的
  2. 父组件可以通过props将修改自身数据的方法传给子组件,从而间接实现子组件向父组件的通信

本地存储

itemList存在localStorage,需要在App.vue中添加数据监视:

export default {
  name: "App",
  // ...
  
  data() {
    return {
      // 初始化时从本地存储中读取
      itemList: JSON.parse(localStorage.getItem("itemList")) || [],
    };
  },
  watch: {
    itemList: {
      deep: true, // 深度监视, 因为监视的目标是一个对象
      handler(value) {
        localStorage.setItem("itemList", JSON.stringify(value));
      },
    },
  },
};

组件自定义事件

之前我们通过父组件给子组件传输props实现子给父传输数据,可以通过自定义事件传输。

触发自定义事件

首先需要触发自定义事件,以上面的MyFooter为例,当用户点击全选/全不选时,将布尔值传输给App

export default {
  name: "MyFooter",
  // props: {...},
  computed: {
    // completed() {...},
    isAll: {
      get() {
        return this.itemList.length !==0 && this.completed === this.itemList.length;
      },
      // 通过setter完成全选
      set(value) {
        // this.selectAll(value);  // 原来的处理方法
        // 当isAll被改变时, 触发selectAllChanged事件
        this.$emit('selectAllChanged', value);
      }
    }
  }
};

绑定自定义事件

事件在MyFooter组件触发,于是需要在App组件绑定:

  • 方式一:通过v-on,在组件标签上绑定
    • .once等修饰符通用
<!-- 当MyFooter触发selectAllChanged事件时, selectAll函数处理该事件 -->
<MyFooter
  :itemList="itemList"
  :removeAllCompleted="removeAllCompleted"
  @selectAllChanged="selectAll"
/>
  • 方式二:通过ref,拿到组件实例对象vc之后,再绑定
<MyFooter
  :itemList="itemList"
  :removeAllCompleted="removeAllCompleted"
  ref="footer"
/>
export default {
  name: "App",
  // ...
  mounted() {
    /* 更灵活, 例如可以通过setTimeout延时绑定 */
    this.$refs.footer.$on('selectAllChanged', this.selectAll);  // 通过$on来绑定
    // this.$refs.footer.$once('selectAllChanged', this.selectAll);  // 只触发一次
  },
};

自定义事件解绑

export default {
  name: "MyFooter",
  // ...
  methods: {
    // handleRemoveCompleted() {...},
    unbind() {
      // this.$off('selectAllChanged');  // 解绑单个自定义事件
      // this.$off(['selectAllChanged', 'removeAllCompleted']); // 解绑多个自定义事件
      this.$off();  // 解绑全部自定义事件
    }
  },
};

this指向

如果绑定时这样写回调函数,则this是触发事件的组件,即MyFooter

this.$refs.footer.$on("removeAllCompleted", function() {
    console.log(this);  // VueComponent {…}
});

可以修改为箭头函数,或恢复成之前的写法,配置在methods中。

原生事件

给组件的原生事件绑定回调函数,需要添加.native(否则认为是自定义事件),默认绑定给组件最外面的标签。

<MyFooter :itemList="itemList" @click.native="show"/>

全局事件总线

实现任意组件间通信,原理是设置一个中间对象,需要发数据的组件触发该对象的事件并传输数据,需要收数据的组件对象接收事件并在回调函数中处理发来的数据。

  • 关键:需要写一个所以组件对象都能访问到,且能够触发/绑定事件的对象

通过VueComponent对象

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

const VC = Vue.extend({});  // 创建一个VueComponent对象
Vue.prototype.x = new VC();  // 实例化一个vc对象, 并且添加到Vue的原型对象中

new Vue({
  render: h => h(App),
}).$mount('#app');

可以实现,但是更推荐下面的写法。

通过Vue对象

此时,触发事件的对象实际上就是vm

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
  beforeCreate() {
    Vue.prototype.$bus = this;  // 安装全局事件总线
  }
}).$mount('#app');

TodoList改进

之前的自定义事件无法实现兄弟组件对象、爷孙组件对象的通信,使用事件总线完善TodoList:

/* App.vue */
export default {
  name: "App",
  // ...
  mounted() {
    this.$bus.$on('removeTodo', this.removeTodo);  // 给bus绑定事件(接收数据)
    this.$bus.$on('checkTodo', this.checkTodo);
  },
  beforeDestroy() {
    this.$bus.$off('removeTodo');  // 组件对象销毁时解绑事件, 减轻bus负担
    this.$bus.$off('checkTodo');
  },
/* MyItem.vue */
export default {
  name: "MyItem",
  // ...
  methods: {
    handleChange() {
      // this.$emit('checkTodo', this.todo.id);
      this.$bus.$emit('checkTodo', this.todo.id);  // 触发bus的事件(发送数据)
    },
    handleDelete() {
      if(confirm('是否删除该事项?')) {
        // this.$emit('removeTodo', this.todo.id);
        this.$bus.$emit('removeTodo', this.todo.id);  // 触发bus的事件(发送数据)
      }
    }
  },
};

nextTick

在下一次DOM更新结束后执行回调函数。

  • 当改变数据后,需要基于更新后的DOM进行操作时使用

例如下面的标签,其根据todo.isEdit展示和隐藏:

<input ref="inputEdit" v-show="todo.isEdit" type="text">
handleEdit(event) {
  if(this.todo.hasOwnProperty('isEdit')) {
    this.todo.isEdit = true;
  }
  else {
    this.$set(todo, 'isEdit', true);
  }
  // 当修改了isEdit时, 由于DOM还未更新, 直接调用focus()无法获取焦点
  this.$nextTick(function() {
    this.$refs.inputEdit.focus();
  });
},

消息的订阅与发布

安装pubsub.js:

npm i pubsub-js

还是上面的通信为例子:

/* App.vue */
import pubsub from "pubsub-js"
export default {
  name: "App",
  // ...
  methods: {
    // ...
    // 注意回调函数, 第一个参数是消息名, 第二个才是传来的数据
    removeTodo(msg, itemId) {
      console.log(msg);  // removeTodo, 即消息名
      this.itemList = this.itemList.filter((todo) => {
        return todo.id !== itemId;
      });
    },
  },
  mounted() {
    this.pubId = pubsub.subscribe('removeTodo', this.removeTodo);  // 订阅消息, 返回id
  },
  beforeDestroy() {
    pubsub.unsubscribe(this.pubId);  // 根据id取消订阅
  }
};
/* MyItem.vue */
import pubsub from 'pubsub-js'
export default {
  name: "MyItem",
  // ...
  methods: {
    // handleChange() {...},
    handleDelete() {
      if(confirm('是否删除该事项?')) {
        pubsub.publish('removeTodo', this.todo.id);  // 发布消息
      }
    }
  },
};

由于消息的订阅与发布需要安装第三方库,所以更推荐使用事件总线。

动画与过渡

  1. v-enter:进入的起点
  2. v-enter-active:进入过程中
  3. v-enter-to:进入的终点

动画效果

一个简单的动画:

<div>
    <button @click="isShow = !isShow">显示/隐藏</button>
    <!-- appear表示页面初始化时是否播放动画 -->
    <transition name="hello" appear>
        <h1 v-show="isShow">你好!</h1>
    </transition>
</div>
h1 {
  background-color: orangered;
}
/* 如果没有在上面指定类名, 则默认为v-enter-active */
.hello-enter-active {
    animation: show 1s linear;
}
.hello-leave-active {
    animation: show 1s linear reverse;
}
/* 自定义动画 */
@keyframes show {
    from {
        transform: translateX(-100%);
    }
    to {
        transform: translateX(0);
    }
}

过渡效果

通过过渡实现相同效果:

/* 进入的起点, 离开的终点 */
.hello-enter, .hello-leave-to {
    transform: translateX(-100%);
}
/* 进入的终点, 离开的起点 */
.hello-enter-to, .hello-leave {
    transform: translateX(0);
}
.hello-enter-active, .hello-leave-active {
    transition: 1s linear;
}

当需要给多个元素过渡时,需要使用transition-group,且指定key

<transition-group name="hello" appear>
    <!-- key必需, 实际情况一般为v-for -->
    <h1 v-show="isShow" key="1">你好!</h1>
    <h1 v-show="!isShow" key="2">你好!</h1>
</transition-group>

第三方动画

推荐animate.css,使用npm安装:

npm install animate.css

在JS引入:

<script>
import 'animate.css';
// export default {...};
</script>

修改<transition-group>标签的3个属性,给进入和离开添加动画:

<transition-group
    appear
    name="animate__animated animate__bounce"
    enter-active-class="animate__backInDown"
    leave-active-class="animate__backOutDown"
>
    <h1 v-show="isShow" key="1">你好111!</h1>
    <h1 v-show="!isShow" key="2">你好222!</h1>
</transition-group>
posted @   lv6laserlotus  阅读(75)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示