前端 - 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'
});
组件名
- 一个单词:
school或者School - 多个单词:
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); }
总结:
school组件本质上是一个名为VueComponent的构造函数,该函数由Vue.extend()生成- 每当有
,Vue解析时会执行new VueComponent(options),创建school组件的实例对象 - 注意:每次调用
Vue.extend(),返回的都是一个全新的VueComponent构造函数 - 在组件配置(
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 }
});
学校名称:{{ name }}
地址:{{ address }}
学生姓名:{{ name }}
性别:{{ sex }}
单文件组件需要在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不能使用
key和ref等特殊属性
通过对象指定数据
仅对数据类型指定:
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!');
},
};
运行后发现:
Student组件和School组件都能使用showName()方法Student组件和School组件本身在data中定义的name属性值没有被mixin覆盖,而原来没有定义的hello属性出现在data中,值为mixin的值- 生命周期钩子
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')
如果使用全局混入,Root和App也会被混入。
插件
用于增强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样式:
学生姓名:{{ name }}
性别:{{ sex }}
在School.vue中也定义.test样式:
学校名称:{{ name }}
地址:{{ address }}
那么最终显示效果根据App.vue的引入顺序决定,如果是下面的顺序,则显示为orange;当顺序调换,显示为skyblue:
使用scoped属性让样式只在局部生效School.vue和Student.vue二者中任意的样式添加scoped属性,即可恢复正常:
此外,使用npm下载less后,还可以使用less:
TodoList样例
流程
- 实现静态组件
- 展示动态数据:数据保存在哪个组件
- 交互:绑定事件监听
源码
安装nanoid用于生成唯一id:
npm i nanoid
App.vue存储待办事项,并且提供一些修改待办事项的方法,通过props传输给子组件。
MyHeader.vue负责添加新事项。
MyList.vue负责展示事项。
MyItem.vue是一个事项的组件,可以删除该事项。
MyFooter.vue展示已经完成的事项和总事项,可以全选和全不选,可以删除所有已完成的事项。
总结
v-model不能绑定props传来的值,因为props传来的值是只读的- 父组件可以通过
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); // 发布消息
}
}
},
};
由于消息的订阅与发布需要安装第三方库,所以更推荐使用事件总线。
动画与过渡
v-enter:进入的起点v-enter-active:进入过程中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!