Vue 组件分析
摘要
在日常开发中逐渐对 Vue 组件的开发有所了解,并写下这篇文章进行 Vue.js 业务开发的收尾总结
# 开篇
以下是我个人关于组件化的一些看法和在开发中对 Vue 组件的一些实践和思考,如有疑惑或错误,请和我联系。观看前需要有开发 Vue 组件的基础。
# 如何去设计一个组件
其实如何去设计一个组件关键在于如何去了解一个组件,通过了解组件的方式,就会知道组件应该怎么去设计。
比如说
- 编写这个组件的目的
- 组件与外部的通信(I/O 交互)
- 传入的数据格式和回调的数据格式
- 组件内部如何处理强逻辑和解耦
- 该组件是否需要再拆分为父子组件,是否能够单独使用
- 是否能够抽离成命令式组件 ...
在考虑完各种要素后才能学会怎么去编写组件
# 如何快速去了解一个组件
在接触一个项目去了解对应的组件的时候,首先要看三个部分,props
,slot
,event
- 写 props 最好用对象的写法,这样可以针对每个属性设置类型、默认值或自定义校验属性的值。但是开发者水平参差不齐,所以最好使用对应
eslint
规则来进行校验。 - 插槽 slot,组件可以通过
<slot>
标签来指定一个插槽的位置,这样在组件内部就可以扩展内容,如果有多个插槽,则需要用到具名插槽 - event,事件处理,这里主要是先关注使用
emit
回调给外部的事件方法,然后再看外部可以通过ref
来进行调用的事件方法,再关注是否有通过$parent/$children
之类基于上下文与外部交互的方法,最后再关注组件内部实现的事件方法
简而言之,就是看这个组件的 I/O 处理,它可以被传入什么,最后又反馈了什么给外部。
# 如何去设计一个页面
老实说,组件这个词,其实一开始我并不是很懂这个概念,后来我把它当成“拼图”,就很容易去了解了
组件大致分为两类
- 一类是业务组件(有图案的拼图)
- 一类是通用组件(纯色的拼图)
业务组件和通用组件的不同点在于组件内部的逻辑是跟当前项目的细节挂钩
的,常用的第三方框架包含的组件通常是通用组件
一般来说,我们都是根据项目来进行定制业务开发,很少会有去进行组件库开发的,每个开发者在设计一个页面的时候都会有不同的选择方案,大致也有两种
- 一种是根据原型,尽量将组件开发成通用的组件,然后再从页面中编写和业务挂钩的逻辑,如果实在不行,则会退而求其次,开发成公用的业务组件
- 一种是根据原型,将所有的组件都开发成业务组件,然后在页面中直接组装,所以页面上基本没有逻辑
其实两种都有好处,但一般情况下我会选择第一种方案,将逻辑和组件抽离出来,让页面去关注逻辑数据的处理,让组件去专注于交互。
其实还有一种方案,但很不推荐,就是页面写一部分逻辑,然后组件又写一部分逻辑,页面和组件的逻辑又互相挂钩,再加上 vuex 的时候,整个项目的耦合性直接上升,如非必要,绝对不要这么做!
# 组件之间的通信处理
父子组件的通信其实很熟悉了,即通过 props
单向流由父组件传入数据到子组件,由 emit
来进行子组件进行事件回调传给父组件,不传 props 的话,父级也可以通过 ref
来访问子组件实例来调用方法
那祖孙组件的通信,还有兄弟组件之前的通信要怎么处理呢。虽然可以使用 Vuex
或者 EventBus
来全局管理一把梭,但那一般只适用于业务项目中,并不是很适用于组件库的开发。
在很多项目上都是使用 Vuex 来一把梭导致使用混乱,所以才觉得这个部分有必要去了解学习一下
# provide/inject 通信
“祖孙组件”通信或者说“层级嵌套”的组件相互通信,可以使用 provide / inject
来进行数据传递,简单的话可以理解为 ctx 上下文相互联系。但是,provide 和 inject 绑定并不是可响应的。这是刻意为之的。(如果你传入了一个可监听的对象,那么其对象的属性还是可响应的)
比如说
App.vue
export default {
components: {
Logo
},
provide() {
return {
app: this
}
},
data() {
return {
seed: 0
}
},
methods: {
handleClick() {
this.seed++
}
}
Logo.vue
export default {
inject: ["app"],
computed: {
seed() {
return this.app.seed;
}
}
};
App.vue 触发 handleClick
的时候, Logo.vue 依旧能拿到最新的 seed
# 自行实现 dispatch 和 broadcast 方法
在阅读 element-ui 部分源码的时候发现有个 mixins 是 emitte.js,看到是用来做层级嵌套的组件进行通信的方法。对应的 Vue 版本为 2.5.21
然后根据目前使用的 Vue 版本为 2.6.11
则需要再修改一下。Vue 2.5.21 的 $options 拿到组件名对应的属性为 componentName,Vue 2.6.11 则为 name
function broadcast(componentName, eventName, params) {
this.$children.forEach(child => {
const name = child.$options.name;
if (name === componentName) {
child.$emit.apply(child, [eventName].concat(params));
} else {
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default {
methods: {
// 子组件使用,向父级派发事件
dispatch(componentName, eventName, params) {
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent || parent.$root;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
parent.$emit.apply(parent, [eventName].concat(params));
}
},
// 父组件使用,向子级广播事件
broadcast(componentName, eventName, params) {
broadcast.call(this, componentName, eventName, params);
}
}
};
后来发现,在 Vue.js 1.x 中,提供了两个方法:$dispatch
和 $broadcast
,前者用于向上级派发事件,只要是它的父级(一级或多级以上),都可以在组件内通过 $on
(或 events,2.x 已废弃)监听到,后者相反,是由上级向下级广播事件的。虽然在 Vue2.x 中则废弃了该方法,但是在开源组件库中基本都会再自定义该方法来解决层级嵌套组件通信问题
来看一下具体的使用方法。有 A.vue 和 B.vue 两个组件,其中 B 是 A 的子组件,中间可能跨多级,在 A 中向 B 通信:
A.vue
import Emitter from "../mixins/emitter.js";
export default {
name: "componentA",
mixins: [Emitter],
created() {
this.$on("on-message", msg => {
console.log("msg", msg);
});
},
methods: {
handleClick() {
this.broadcast("componentB", "on-message", "I am A");
}
}
};
B.vue
import Emitter from "../mixins/emitter.js";
export default {
name: "componentB",
mixins: [Emitter],
created() {
this.$on("on-message", this.showMessage);
},
methods: {
showMessage(msg) {
console.log("msg", msg);
this.dispatch("componentA", "on-message", "I am B");
}
}
};
同理,如果是 B 向 A 通信,在 B 中调用 dispatch 方法,在 A 中使用 $on 监听事件即可。
# 找到任意组件实例——findComponents 系列方法
既然组件中有 $parent 和 $children 的属性,那就可以自己再封装一系列方法去获得对应的组件实例
/**
* 向上寻找一个指定的组件实例
* @param { Vcomponent } ctx
* @param { String } componentName
* @return { Vcomponent || undefined }
*/
export const findComponentUpward = (ctx, componentName) => {
let parent = ctx.$parent || ctx.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent || parent.$root;
if (parent) {
name = parent.$options.name;
}
}
return parent;
};
/**
* 向上寻找所有指定的组件实例
* @param { Vcomponent } ctx
* @param { String } componentName
* @return { Vcomponent[] || [] }
*/
export const findComponentsUpward = (ctx, componentName) => {
const parents = [];
const parent = ctx.$parent || ctx.$root;
const name = parent.$options.name;
if (parent && name === componentName) {
parents.push(parent);
return parents.concat(findComponentsUpward(parent, componentName));
} else {
return [];
}
};
/**
* 向下寻找一个指定的组件实例
* @param { Vcomponent } ctx
* @param { String } componentName
* @return { Vcomponent || undefined }
*/
export const findComponentDownward = (ctx, componentName) => {
const childrens = ctx.$children;
let children = undefined;
if (childrens.length > 0) {
for (const child of childrens) {
const name = child.$options.name;
if (name === componentName) {
children = child;
break;
} else {
children = findComponentDownward(child, componentName);
if (children) break;
}
}
}
return children;
};
/**
* 向下寻找一个指定的组件实例
* @param { Vcomponent } ctx
* @param { String } componentName
* @return { Vcomponent[] || [] }
*/
export const findComponentsDownward = (ctx, componentName) => {
return ctx.$children.reduce((comps, child) => {
if (child.$options.name === componentName) {
comps.push(child);
}
const foundChilds = findComponentsDownward(child, componentName);
return comps.concat(foundChilds);
}, []);
};
/**
* 找到指定的兄弟组件
* @param { Vcomponent } ctx
* @param { String } componentName
* @param { Boolean } expect true 表示 自身
* @return { Vcomponent[] || undefined }
*/
export const findBrothersComponents = (ctx, componentName, expect = true) => {
const res = ctx.$parent.$children.filter(item => {
return item.$options.name === componentName;
});
const index = res.findIndex(item => item._uid === ctx._uid);
if (expect && index !== -1) res.splice(index, 1);
return res;
};
# 自定义 Vue 指令
为什么要用到自定义 Vue 指令,是因为用它可以减少重复的代码,并且简单明了地实现某些需求,比如说权限控制,比如说聚焦状态,还有很多很多
除了官方自带的指令外,这里有比较常用 第三方的 vue 指令
官网的 Vue 指令文档比较详细,这里就不再赘述
可以简单实现一个 v-focus 指令
import Vue from "vue";
Vue.directive("focus", {
inserted(el) {
el.focus();
}
});
也可以实现一个权限控制的指令 v-permission
import Vue from "vue";
import store from "~/store/index";
Vue.directive("permission", {
inserted(el, { value }) {
if (value) {
const permissions = store.state.permissions;
if (!permissions.includes(value)) {
// 没有权限 移除Dom元素
el.parentNode && el.parentNode.removeChild(el);
}
} else {
console.error("请传入一个权限编码");
}
}
});
调用方式如下
<div v-permission="'1-1-1'">test</div>
在考虑使用组件之前,可以先考虑使用指令看看能否简单实现并且通用
# Render 函数与 Functional Render
# 从 vue-loader 来了解到 Render 函数
在频繁地使用模板文件进行开发以后,其实会开始疑惑,为什么我们可以使用 .vue 文件去开发,为什么把模板写在 template 里,最后会正常渲染到页面上。
实际上通过 webpack 配置可以知道,这是由 vue-loader
去对 .vue 文件进行处理的。vue-loader 内部则是提取出其中的逻辑代码 script
、样式代码 style
、以及 HTML 模版 template
,再分别把它们交给对应的 Loader 去处理(compile),核心的作用,就是提取。
最后对应的 loader 处理完后,vue-loader 会在转成一个 JS 对象(组件选项对象)暴露出来(可以试着打印一个 Component 看一下 vue-loader 转换出来的组件选项对象)
在这里我们着重看下 template 中的标签最后会处理转化成什么?
<img src="../image.png" />
查看 vue-loader 的文档可以看到,上面的模板代码片段会被编译成下面的 js 代码
createElement("img", {
attrs: {
src: require("../image.png") // 现在这是一个模块的请求了
}
});
而根据 vue-loader 源码 后发现也会导出到一个 render 函数到组件选项对象中,并且 createElement
实际上也是用 Vue 本身的 createElement 函数
查询 Vue 文档会发现 Vue 中的 createElement 和 Document 中的 createElement 不太一样,它其实是会返回一个 VNode 节点的,而且在通过一个 render 函数中被调用
即通过 Render 函数可以拿到 VNode,Vue 本身会再根据 VNode 去处理并映射到真实的 DOM
VNode
Vue 中的 Virtual DOM,实质上还是一个 js 对象,只是对真实 DOM 的一种抽象描述,在 Vue 中需要经过 create,diff,patch 后才能映射到真实 DOM,而 VNode 也是 Vue2.x 和 1.x 最大的区别之一。
既然实际上是会编译成 js 模块,那就意味着可以离开 .vue 模板来进行编程,尝试编写一组 template 和 Render 写法的对照
template 写法 main.vue
<template>
<div id="main" class="container" style="color: red">
<p v-if="show">内容 1</p>
<p v-else>内容 2</p>
</div>
</template>
<script>
export default {
data() {
return {
show: false
};
}
};
</script>
Render 函数写法 main.js
export default {
data() {
return {
show: false
};
},
render(h) {
const childNode = this.show ? h("p", "内容 1") : h("p", "内容 2");
return h(
"div",
{
attrs: {
id: "main"
},
class: {
container: true
},
style: {
color: "red"
}
},
[childNode]
);
}
};
这里的 h
,即 createElement
,是 Render 函数的核心,相关参数可以查看 createElement 参数。
createElement 主要有三个参数
- 要渲染的元素或组件,可以是一个 html 标签,也可以一个 Vue 组件对象,或者是一个函数,该参数必填
- 对应的属性对象,这里的配置项太多,需要时查阅就行
- 子节点,可选,String || Array,一般用数组的方式,数组里都是 VNode
事实上,使用 Render 函数其实会灵活很多,但是配置项太多,一般是不推荐使用 Render 函数的(主要会写的很痛苦,并且节点太多难以维护)所以其实是有 Babel 插件 babel-plugin-transform-vue-jsx 可以让开发者在 Vue 中使用 JSX 语法,它可以让我们回到于更接近模板的语法上。而且其实有 template
语法也是 Vue 的优势。
# Render 函数的使用场景
- 使用两个相同 slot。在 template 中,Vue.js 不允许使用两个相同的 slot,所以只能去深度复制一个 VNode,然后再添加到子节点中,示例如下
export default {
render: h => {
function cloneVNode(vnode) {
// 递归遍历所有子节点,并克隆
const clonedChildren =
vnode.children && vnode.children.map(vnode => cloneVNode(vnode));
const cloned = h(vnode.tag, vnode.data, clonedChildren);
cloned.text = vnode.text;
cloned.isComment = vnode.isComment;
cloned.componentOptions = vnode.componentOptions;
cloned.elm = vnode.elm;
cloned.context = vnode.context;
cloned.ns = vnode.ns;
cloned.isStatic = vnode.isStatic;
cloned.key = vnode.key;
return cloned;
}
const vNodes = this.$slots.default === undefined ? [] : this.$slots.default;
const clonedVNodes =
this.$slots.default === undefined
? []
: vNodes.map(vnode => cloneVNode(vnode));
return h("div", [vNodes, clonedVNodes]);
}
};
在 runtime 版本的 Vue.js 中,如果使用 Vue.extend 手动构造一个实例,使用 template 选项是会报错的,所以需要使用 render 函数来替换 template 写法
一个 Vue.js 组件,有一部分内容需要从父级传递来显示,并且需要最大化程度自定义显示内容,而且不是只将数据显示出来那么简单,可能带有一些复杂的操作。这种时候(比如 Element-ui 强大的表格自定义功能)就需要考虑使用 Render 函数 或者是作用域 slot(slot-scope)
# Functional Render
Vue.js 提供了一个 functional
的布尔值选项,设置为 true 可以使组件无状态和无实例,也就是没有 data 和 this 上下文,也就是说这个组件是一个**函数化组件(Functional Render)**详细可以看对应的Vue.js 文档—函数式组件
简单写一个示例
函数化组件 render.js:
export default {
functional: true,
props: {
render: Function
},
render: (h, ctx) => {
return ctx.props.render(h);
}
};
main.vue
<template>
<div>
<render :render="render"></render>
</div>
</template>
<script>
import Render from "./render.js";
export default {
components: { Render },
data() {
return {
render: h => {
return h(
"div",
{
style: {
color: "red"
}
},
"自定义内容"
);
}
};
}
};
</script>
另外,在 vue-loader@15.x 中,我们也可以直接在 template 上加个属性让整个文件变成函数式组件,这也许更加简单,以下是来自 vue-loader 文档 的示例
<template functional>
<div>{{ props.foo }}</div>
<div>{{ parent.$someProperty }}</div>
</template>
# 作用域插槽 v-slot
开发组件或多或少都会用过 slot 插槽,可以简单看一下示例
BookList.vue
<template>
<ul>
<li v-for="book in books" :key="book.id">
<slot>
{{ book.name }}
</slot>
</li>
</ul>
</template>
<script>
export default {
name: "BookList",
props: {
books: Array
}
};
</script>
main.vue
<template>
<book-list :books="books" />
</template>
<script>
import BookList from "~/components/BookList";
export default {
components: {
BookList
},
data() {
return {
books: [
{
id: 1,
name: "皮囊"
},
{
id: 2,
name: "这届和亲的公主不行"
},
{
id: 3,
name: "我亲爱的偏执狂"
}
]
};
}
};
</script>
最后展示效果如下
- 皮囊
- 这届和亲的公主不行
- 我亲爱的偏执狂
但是我想为每本书加上 《》
号,但是又不想直接在 BookList 组件上加,也不想在 main.vue 的数据源上加,这时候就可以用到 作用域插槽(slot-scoped) 来进行自定义模板创建了
那就来修改一下,通过给 slot 设置 bookName 属性允许外部访问,外部通过 v-slot
指令来拿到 slot 的属性对象
BookList.vue
<template>
<ul>
<li v-for="book in books" :key="book.id">
<slot :bookName="book.name">
{{ book.name }}
</slot>
</li>
</ul>
</template>
<script>
export default {
name: "BookList",
props: {
books: Array
}
};
</script>
main.vue
<template>
<book-list :books="books">
<template v-slot:default="slotProps">
《 {{ slotProps.bookName }} 》
</template>
</book-list>
</template>
<script>
import BookList from "~/components/BookList";
export default {
components: {
BookList
},
data() {
return {
books: [
{
id: 1,
name: "皮囊"
},
{
id: 2,
name: "这届和亲的公主不行"
},
{
id: 3,
name: "我亲爱的偏执狂"
}
]
};
}
};
</script>
最后就会展现成我们想要的样子了
- 《 皮囊 》
- 《 这届和亲的公主不行 》
- 《 我亲爱的偏执狂 》
v-slot 指令自 Vue 2.6.0 起被引入,提供更好的支持 slot 和 slot-scope attribute 的 API 替代方案。v-slot 完整的由来参见这份 RFC。在接 s 下来所有的 2.x 版本中 slot 和 slot-scope attribute 仍会被支持,但已经被官方废弃且不会出现在 Vue 3 中。
善用作用域插槽,就可以更改地去自定义需要的模板内容。更多可以查看文档进行了解
这里为什么要专门将“作用域插槽”拿出来讲呢?因为以前学习的使用是用 Vue2.5 版本,现在 Vue2.6 以后开始对插槽有了更好的支持。并且用好 slot-scope, 应该说用好 v-slot 这个新指令,能够做到更多的事情来替代 Render 函数的使用。
# 如何去开发一个命令式组件
使用第三方框架的时候,会发现可以使用命令式的方式去调用组件,比如 Message 和 Toast 组件于是好奇地去看了一下 element-ui 和 cube-ui 是怎样去实现的,可以看下对应的仓库地址
- element-ui Message 组件
- cube-ui Toast 组件
- github.com/didi/cube-ui/blob/dev/src/components/toast/toast.vue
- github.com/didi/cube-ui/blob/dev/src/common/helpers/create-api.js
- github.com/didi/cube-ui/tree/dev/src/modules/toast
阅读后发现,element-ui 实际上是直接手动去实例化组件并渲染在页面上,但是每次都会再销毁组件,而 cube-ui 其实则是通过 vue-create-api 这个库去实现的。
# 自己实现一个命令式组件
这里借鉴一下 element-ui 实现一个自定义命令式的 Message 组件
最后想实现的调用方式如下
this.$message.info("实现一个指令式组件1");
this.$message.info({
content: "实现一个指令式组件2"
});
this.$message.success({
content: "实现一个指令式组件3",
duration: 3
});
先确认目录结构
-- message
-- src
-- main.vue # 组件编写
-- main.js # 实例挂载
-- index.js # 暴露给外部的入口
-- app.js # Vue 主入口
先简单编写下 message/src/main.vue
<template>
<div class="message">
<div v-for="item in notices" :key="item.id" class="message-main">
<div :class="['message-content',`message-content__${item.type}`]">
{{ item.content }}
</div>
</div>
</div>
</template>
<script>
export default {
name: "Message",
data() {
return {
seed: 0,
notices: []
};
},
methods: {
add(notice) {
const id = "message_" + this.seed++;
const _notice = {
id,
...notice
};
this.notices.push(_notice);
// 定时移除,单位:秒
const duration = notice.duration;
setTimeout(() => {
this.remove(id);
}, duration * 1000);
},
remove(id) {
const notices = this.notices;
const index = notices.findIndex(i => i.id === id);
if (index !== -1) {
this.notices.splice(index, 1);
} else {
throw new Error("无法正常删除 message");
}
}
}
};
</script>
<style lang="scss" scoped>
.message {
position: fixed;
top: 24px;
left: 0;
z-index: 1000;
width: 100%;
height: 48px;
line-height: 48px;
text-align: center;
pointer-events: none;
}
.message-content {
display: inline-block;
padding: 0 16px;
margin-bottom: 16px;
font-size: 16px;
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
&__success {
color: #fff;
background: #69b9ed;
}
}
</style>
组件编写完来写下最重要的挂载 js,这里和 element-ui 的有区别,相对简单很多
message/src/main.js
import Vue from "vue";
import Message from "./main.vue";
export default function(properties) {
const props = properties || {};
const VueInstance = new Vue({
data: props,
render(h) {
// VNode
return h(Message, {
props: props
});
// 使用 babel-plugin-transform-vue-jsx 插件就可以直接使用标签来返回
// return <Message></Message>
}
});
// 由于没有指定 el,所以需要手动挂载 Vue 实例
VueInstance.$mount();
// 渲染组件到页面上
document.body.appendChild(VueInstance.$el);
// 获取挂载的组件实例
const MessageComp = VueInstance.$children[0];
// console.log('VueInstance', VueInstance)
// console.log('MessageComp', MessageComp)
return {
add(notice) {
MessageComp.add(notice);
},
remove(id) {
MessageComp.remove(id);
}
};
}
main.js 会暴露出一个函数给 index.js 去使用,编写一下 message/index.js
import newInstance from "./src/main.js";
// 使用单例模式返回单个实例
class MessageModel {
static getInstance() {
if (!MessageModel.instance) {
MessageModel.instance = newInstance();
}
return MessageModel.instance;
}
}
function notice({ duration = 1.5, content = "", type = "info" }) {
const MessageInstance = MessageModel.getInstance();
// 虽然变量名为 MessageInstance,但实际上 add 方法里还会再调一次 MessageComp 对应的 add 方法
MessageInstance.add({
content,
duration,
type
});
}
export default {
info(options) {
if (typeof options === "string") {
return notice({ content: options });
} else {
return notice(options);
}
},
success(options) {
return notice({
...options,
type: "success"
});
}
};
最后挂载到 app.js
中,就可以愉快地使用了
import Vue from "vue";
import Message from "./message";
Vue.prototype.$message = Message;
// 省略以下 new Vue 的过程
只有在调用 $message
的时候,Message 才会实例化,并且只会实例化一次,不会被生命周期销毁
# 使用 vue-create-api 创建命令式组件
在自定义命令式组件
npm install vue-create-api
然后只要修改一下 app.js
import Vue from "vue";
import CreateAPI from "vue-create-api";
import Message from "./message/src/main.vue";
// use 后会在 Vue 构造器下添加一个 createAPI 方法
Vue.use(CreateAPI, {
apiPrefix: "$create-"
});
// 调用 createAPI 生成对应 API,并挂载到 Vue.prototype 和 Message 对象上
Vue.createAPI(Message, true);
最后更改一下调用方式,就可以愉快使用了
this.$createMessage().add({
content: "实现一个指令式组件",
duration: 3
});
注意,这里的组件编写和调用其实不太符合 create-api
本身的组件调用规范,最好是传 props 进去而不是实例化后直接调用方法。但是自定义命令式组件时可以的
# MVC,MVP,MVVM 的简单了解
# MVC
先来简单说一下 MVC,即 Model-View-Controller
- 视图(View):用户界面。
- 控制器(Controller):处理消息,控制应用程序的流程,处理事件并作出响应
- 模型(Model): 用于封装与应用程序的业务逻辑相关的数据以及对数据的处理方法
这是传统后端框架常用的架构模式,具体通信流向如下:
界面触发操作 -> 控制器处理消息 -> 数据层返回新数据给界面进行渲染
示例
传统的套模板进行数据渲染的操作,像 Thinkphp,JSP 等旧式框架的使用
# MVP
刚开始知道 MVP 的人肯定会觉得很奇怪,在编程世界里,这不是一个英文单词,而是一种架构模式 Model-View-Presenter
- 控制器(Presenter):控制中枢
和 MVC 不同的是他们的通信流改变了,具体如下
界面触发操作 -> 控制器处理事件 -> 数据层返回新数据给控制器 -> 控制器组装数据并控制界面展示
这种方式就直接将 View-Model 之间的联系变成间接联系,Controller 变成了中枢控制,升级成为了 Presenter,可以更高效地利用 Model,让逻辑全放在控制器中
示例
前端使用 jQuery 来进行界面交互和后台数据接口请求,这些操作其实就是 MVP 模式的一种体现,jQuery 就是一个 Presenter 角色
# MVVM
了解过前端框架的都知道,现在的框架大多是 MVVM 的模式,即 Model-View-ViewModel
ViewModel 字面翻译过来其实就是“视图模型”的意思,但其实很含糊,它到底和其他两种模式有什么区别?
维基百科上描述
MVVM 也被称为 model-view-binder
视图模型是暴露公共属性和命令的视图的抽象。MVVM 没有 MVC 模式的控制器,也没有 MVP 模式的 presenter,有的是一个绑定器。在视图模型中,绑定器在视图和数据绑定器之间进行通信。
原来还有一个绑定器的概念
绑定器,声明性数据和命令绑定隐含在 MVVM 模式中。绑定器使开发人员免于被迫编写样板式逻辑来同步视图模型和视图。声明性数据绑定技术的出现是实现该模式的一个关键因素。
简单一点说,就是VM 中有一个绑定器(binder),这个绑定器让 V 和 VM 拥有的数据变成双向绑定,然后 VM 的底层再根据数据变化自动去控制视图的改变。
所以这就是为什么使用 MVVM 模式的前端框架只需要关注数据流,不再需要过多地关注 DOM 的改变
以 Vue 来举例
- Vue2.x 的原理是根据
Object.defineProperty
来实现数据(data)的双向绑定,也就是说,这个函数方法就是绑定器 - 绑定器(binder)内部的 set 和 get 的方法中使用“观察者(dep)”去观察数据变化通知(notify)给“订阅者(sub/watcher)”,订阅者再对比前后两个的数值(update/run 方法将数值回调)是否发生变化,然后确定是否通知视图(view)进行重新渲染(render)。
- 而这个渲染就涉及到 Virtual Dom 转化为 DOM 的过程了,先 create 成真正的 DOM 对象,再通过 diff 算法去找出需要补丁(patch)对象,然后遍历它去更新真实的 DOM 节点。
- 所以 Vue 中 V-VM 的双向数据绑定实际上是以
Object.defineProperty
为绑定器,再使用观察者设计模式来实现的 - (更多可以参考 深入响应式原理 或者 Vue 源码)
基于以上分析,与 MVP 模式相比较,其实就是把操作的 DOM 逻辑的代码移入到 VM 中进行自动化操作,开发者则更加去关注数据变化就可以了。也就是说有了 VM 后,这也使得职责更加明确,视图层开发者只需要关注视图层的设计和交互,而逻辑开发者则只需要关注到到业务逻辑层的数据。
V-VM
的双向绑定大概分析就是这样,并且这也可以同理到 M-VM
的双向绑定
小结
MVVM 和 MVP 最关键的不同在于是否有绑定器的概念,数据的操作是双向都要进行数据后进行手动调用操作,还是监听回调后自动操作
再来看下 MVVM 的通信流
界面触发操作 -> VM 处理事件逻辑 -> 数据层处理逻辑拿到新数据
拿到新数据后,VM 会通过绑定器(binder)观察到数据(metaData)变化进行数据加工,转化为展示用的数据(displayData),再通过绑定器(binder)观察展示用的数据前后有变化,则 VM 就会再自动渲染 DOM,如果数据不变则不会操作
示例
Angular,Vue,React,Omi 等框架的实现,但其实原理并不相同,只是都符合 MVVM 的思维
值得一提的是,Vue 官网是这么说的
虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例。
另外,Omi 的这篇 文章 也许更好地展示了 MVVM 的思维
# 目的
为什么在这篇文章要写对着三种模式的了解?并着重于 MVVM 模式,并将其区分于其他模式?
其目的只是在于更好地去设计组件。
- 我个人开发时,更期望将 data 中的数据设计为 metaData(元数据),然后通过 computed 加工为 displayData(展示数据)。
- 对于 computed 加工后的数据,尽量避免用 set 方法去处理,尽管也可以通过“引用类型”的特性去修改 computed 加工后的数据,但还是不要这么做,我个人倾向于,computed 出来的数据只有“只读”的状态
- 在与后端对接时,尽量保持 metaData 字段和后端返回的数据字段一致,而不是再去维护一个映射表去转换数据,这样其实会加重开发负担。
# 和 Vue 相关的一些疑点解决
# 关于 v-model
我们都知道使用 v-model 是个可以让数据进行双向绑定的指令,实际上它是一个语法糖。可以拆解为 props: value 和 events: input。就是说组件必须提供一个名为 value 的 prop,以及名为 input 的自定义事件,满足这两个条件,使用者就能在自定义组件上使用 v-model
。
可以看一下简单的示例
main.vue
<template>
<!-- 这么写也是一样的 -->
<!-- <input-number :value="value" @input="(val)=>value=val" /> -->
<input-number v-model="value" />
</template>
<script>
import InputNumber from "../component/input-number.vue";
export default {
components: { InputNumber },
data() {
return {
value: 1
};
}
};
</script>
input-number.vue
<template>
<div>
<button @click="increase(-1)">减 1</button>
<span style="padding: 6px; color: red;">{{ currentValue }}</span>
<button @click="increase(1)">加 1</button>
</div>
</template>
<script>
export default {
name: "InputNumber",
props: {
value: {
type: Number
}
},
data() {
return {
currentValue: this.value
};
},
watch: {
value(val) {
this.currentValue = val;
}
},
methods: {
increase(val) {
this.currentValue += val;
this.$emit("input", this.currentValue);
}
}
};
</script>
# .sync 修饰符
在 Vue.js 2.3.0 版本,增加了 .sync
修饰符,可以改写下 v-model 中的示例尝试一下
main.vue
<template>
<input-number :value.sync="value" />
</template>
<script>
import InputNumber from "../component/input-number.vue";
export default {
components: { InputNumber },
data() {
return {
value: 1
};
}
};
</script>
input-number.vue
<template>
<div>
<button @click="increase(-1)">减 1</button>
<span style="padding: 6px; color: red;">{{ value }}</span>
<button @click="increase(1)">加 1</button>
</div>
</template>
<script>
export default {
name: "InputNumber",
props: {
value: {
type: Number
}
},
methods: {
increase(val) {
this.$emit("update:value", this.value + val);
}
}
};
</script>
比 v-model 的实现简单多,实现的效果是一样的。v-model 在一个组件中只能有一个,但 .sync 可以设置很多个。.sync
虽好,但也有限制,比如:
- 不能和表达式一起使用(如
v-bind:title.sync="doc.title + '!'"
是无效的) - 不能用在字面量对象上(如
v-bind.sync="{ title: doc.title }"
是无法正常工作的)
一般使用 .sync
操作符是在一些“弹窗组件”上进行使用,可以不用为“弹窗组件”特意写一个回调函数
# $attrs 和 $listeners
$attrs 和 $listeners 在平时的使用场景非常少,但是在“二次封装”组件中却十分有用,比如基于 element-ui
的 el-button
组件进行再开发,就可以使用这两个钩子进行处理
<template>
<el-button v-bind="$attrs" v-on="$listeners"></el-button>
</template>
<script>
export default {
name: "SelfButton"
};
</script>
外部在使用 SelfButton
组件时,就会把对应的 prop(class 和 style 除外) 和 事件监听器(不包含 .native 修饰器) 传入给 el-button
中
<template>
<self-button type="primary" @click="handleClick" />
</template>
# 组件中 data 为什么是函数
为什么组件中的 data 必须是一个函数,然后 return 一个对象,而 new Vue 实例里,data 可以直接是一个对象?
因为组件是用来复用的,JS 里对象是引用关系,这样作用域没有隔离,而 new Vue 的实例,是不会被复用的,因此不存在引用对象的问题。
# 一些值得注意的 api
- delimiters
- 可以在
new Vue
的时候进行配置,改变纯文本插入分隔符。 - Vue 默认是
- 可以在
- errorHandler
- 使用
errorHandler
可以进行异常信息的获取。在 `Vue
- 使用
Vue 的API 文档是很完善的,偶尔闲下来可以去翻一下
# keep-alive
为什么要讲到 keep-alive,原因是——在开发后台系统的过程中,发现进入详情页后,想再返回到筛选器首页并保持进入详情页的状态这个场景,我发现有些系统是直接将参数带入到 url 中,或者存入 vuex 中去保留状态,实际上并不用如此去做,而是应该使用 keep-alive
这个抽象组件,使用 keep-alive 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。它本身就是来解决这种场景——用户在某个列表页面选择筛选条件过滤出一份数据列表,由列表页面进入数据详情页面,再返回该列表页面,我们希望:列表页面可以保留用户的筛选(或选中)状态
所以之后应该善用 keep-alive,另外要根据系统和用户习惯去使用编程式导航($router.push) 和标签式导航(router-link)的情况,而不是一味地只用编程式导航跳转
# 结语
其实整篇文章下来,虽然主题是“深入 Vue 组件”,但其实只是为了更好地,以某一种较为规范的开发模式去设计 Vue 组件,去完善整体的项目开发。也就是一种开发风格。但更重要的其实是本身的 JS 功底
基于以上内容,我再阐述一下,我个人认为开发组件需要注意的事项
- 不要急着写代码!不要急着写代码!不要急着写代码!
- 如果是全新项目,需要整体先进行规划组件的设计方案,善用 keep-alive 和 router-link
- 以业务组件模块化布局为主,
- 还是通用组件模块化布局,再以页面逻辑为中枢为主。
- 确定需要的功能模块是否可以通用,是否可以使用指令(directive)的方式进行替代组件开发?
- 确定该组件的性质划分
- 通用的功能性组件,是否可以采取命令式组件的方式去开发?
- 业务型组件,是否可以再进行拆分成函数式组件(fucntional render)或者使用作用域插槽(v-slot)去更好地自定义?
- 组件的 props,event,slot 要如何去设计定义?
- 组件与外部的通信要怎样处理最为合适?
- 关于组件的生命周期处理,请求数据最好是再 created 钩子,mounted 阶段去操作 DOM,beforeDestroy 是否需要去解绑事件?善用 nextTick 回调函数
- 尽量将 data 中的数据设计为 metaData,然后通过 computed 加工为 displayData
- 组件的数据流为单向数据流最佳,根据情况去使用 v-model 和 .sync 修饰符
- 最后根据情况去使用其他像“动态组件”,“修饰符”等方式以及其他可用的 API :::
最后,如果有能力,最好使用 TypeScript 进行开发,这能使后续更容易维护,开发也会便捷一些。
我个人不是极客,相对来说是比较保守求稳的,所以想做的是能够编写出更健壮可读的代码,但仍然需要再提升基本能力。而且个人能力有限,像递归组件,动态组件,以及如何更好地使用作用域插槽和 Render 函数都没能展开去讲。但是,这篇文章就先到此结束吧。如果确实值得再深究的话会再补完这篇文章。