前端 - Vue组件与脚手架


Vue文档

目录
  • 组件化编程
    • 非单文件组件
      • 组件名
      • 组件标签
      • 简写
      • 组件的嵌套
      • VueComponent构造函数
      • Vue实例与组件实例
    • 单文件组件
      • 暴露的三种方法
    • 单文件组件举例
  • Vue脚手架
    • 脚手架分析
    • render()
    • ref属性
    • props配置
      • 通过数组简单接收
      • 通过对象指定数据
      • 修改props
    • mixin混入
      • 局部混入
      • 全局混入
    • 插件
    • scoped样式
    • TodoList样例
      • 流程
      • 源码
      • 总结
      • 本地存储
    • 组件自定义事件
      • 触发自定义事件
      • 绑定自定义事件
      • 自定义事件解绑
      • this指向
      • 原生事件
    • 全局事件总线
      • 通过VueComponent对象
      • 通过Vue对象
      • TodoList改进
      • nextTick
    • 消息的订阅与发布
    • 动画与过渡
      • 动画效果
      • 过渡效果
      • 第三方动画

组件化编程

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

组件分为:

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

非单文件组件

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

学校名称:{{schoolName}}

地址:{{address}}


学生姓名:{{stuName}}

年龄:{{age}}

通过非单文件组件:


/* 第一步:定义school组件和student组件 */
// 定义school组件
const s = Vue.extend({
    // 不能写el, 因为所有的组件都要被一个vm管理, 由vm决定组件给哪个容器服务
    // 使用template必须只有一个根元素, 即所有元素最终都要被一个根元素包裹
    template: `
        

学校名称:{{schoolName}}

地址:{{address}}

`, // data必须写成函数, 这样每个实例可以维护一份被返回对象的独立拷贝 data() { return { schoolName: 'My school', address: 'Hangzhou', } }, }) // 定义student组件 const student = Vue.extend({ template: `

学生姓名:{{stuName}}

年龄:{{age}}

`, 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开发者工具中显示的名字,实际上使用时还是需要注册时的名字

组件标签

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

简写

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

const s = {
    template: `
        

学校名称:{{schoolName}}

地址:{{address}}

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

组件的嵌套

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

// 首先定义 子组件student
const student = {
    template: `
        

学生姓名:{{stuName}}

年龄:{{age}}

`, data() { return { stuName: 'Lee', age: 30 } }, }; // 然后定义 父组件school const school = { // 父组件template可以调用子组件 template: `

学校名称:{{schoolName}}

地址:{{address}}

`, data() { return { schoolName: 'My school', address: 'Hangzhou', } }, // 注册组件, 当前组件管理student组件 components: { student } }; // 定义app组件, 管理其他组件 const app = { template: `
`, // 注册组件, 当前组件管理school组件 components: { school } } // 创建vm const vm = new Vue({ el: '#root', template: ``, // 只需要注册app组件 components: { app } });

VueComponent构造函数

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

// 定义组件school
const school = Vue.extend({
    template: `
        

学校名称:{{schoolName}}

`, data() { return { schoolName: 'My school' } } }); // 创建vm const vm = new Vue({ el: '#root', template: ``, components: { school } });

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

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

总结

  1. school组件本质上是一个名为VueComponent的构造函数,该函数由Vue.extend()生成
  2. 每当有,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__为传入的对象。

单文件组件

暴露的三种方法




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


单文件组件举例


    
    
/* main.js Vue实例 */

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











单文件组件需要在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: '',
  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 元素;如果用在子组件上,引用就指向组件实例

Test

export default {
  name: 'App',
  components: {
    Student
  },
  methods: {
    check() {
      console.log(this.$refs);  // {stu: VueComponent, h3test: h3}
    }
  }
}

props配置

通过数组简单接收

{{hello}}

姓名:{{name}}

年龄:{{age + 1}}

export default {
    name : "Student", 
    data() {
        return {
            hello: 'Hello!!!'
        }
    },
    props: ['name', 'age']  // 数组接受
}

Student的父组件App中:

注意

  • 使用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

{{hello}}

姓名:{{name}}

年龄:{{age + 1}}

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:

{{hello}}

姓名:{{myName}}

年龄:{{myAge + 1}}

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样式:






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






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




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


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


TodoList样例

流程

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

源码

安装nanoid用于生成唯一id:

npm i nanoid

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






MyHeader.vue负责添加新事项。






MyList.vue负责展示事项。






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






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






总结

  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等修饰符通用


  • 方式二:通过ref,拿到组件实例对象vc之后,再绑定

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(否则认为是自定义事件),默认绑定给组件最外面的标签。


全局事件总线

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

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

通过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展示和隐藏:


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:进入的终点

动画效果

一个简单的动画:

你好!

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


    
    

你好!

你好!

第三方动画

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

npm install animate.css

在JS引入:


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


    

你好111!

你好222!