# 第5章 常用指令和自定义指令
本章节相关代码存放在Github (opens new window)中。
前面我们在介绍 Vue 实例、Vue 组件的时候,也看到不少的v-if
、v-model
这样的语法,这种语法在 Vue 中称为指令(directive)。在前端的三大框架 Angular、React 和 Vue 里,Angular 的指令是做的最早以及比较完善的,但门槛较高。对比看来,Vue 里做了巧妙的减法,该减法使得 Vue 的常用指令简单易懂,同时提供的自定义指令能力也易于上手。
# 5.1 常用指令
我们先来看看常用的一些 Vue 内置指令吧。
# 5.1.1 条件渲染
条件渲染相关指令主要包括v-if
、v-else-if
、v-else
、v-show
这几个,用于条件性地渲染、隐藏一块内容。
# v-if 系列
我们来直接看代码会更好理解:
<div v-if="type === 'A'">Type A</div>
<div v-else-if="type === 'B'">Type B</div>
<div v-else>Default Type</div>
模板最终生成,我们可以这样理解:
function genThisHTML(scopeData) {
// scopeData 为 Vue 实例里绑定的 data 数据
if (scopeData.type === "A") {
return `<div>Type A</div>`;
} else if (scopeData.type === "B") {
return `<div>Type B</div>`;
} else {
return `<div>Default Type</div>`;
}
}
这样一看,是不是很清晰明朗。条件渲染指令其实是将常见的 Javascript 语法,通过 HTML 属性的形式附加在模板里,然后在 Vue 编译器编译的时候识别出来,然后匹配对应的执行逻辑而已。
# key
使用v-if
指令有个需要注意的地方是,我们第1章中介绍了 Vue 中的虚拟 DOM 算法,在 Diff 过程中会优先使用现有的元素进行调整,而并非删除原有的元素再重新插入一个元素。这样的算法背景下,当我们绑定的数据发生变更时,可能会存在这样的情况:
<template v-if="type === 'phone'">
<input type="number" placeholder="Enter your phone" />
</template>
<template v-else>
<input type="text" placeholder="Enter something" />
</template>
当我们的type
从phone
切换到其他值的时候,该<input>
元素只会更新属性值type
和placeholder
,但原先输入的内容还在:
图 5-1 未绑定key
前,更新type
前的 DOM 元素
图 5-2 未绑定key
前,更新type
后的 DOM 元素
如果我们希望能精确命中对应的元素,可以通过绑定key
的方式:
<template v-if="type === 'phone'">
<input type="number" placeholder="Enter your phone" key="phone" />
</template>
<template v-else>
<input type="text" placeholder="Enter something" key="something-else" />
</template>
这种情况下,input 会根据key
是否匹配,来控制是否重新渲染(即移除元素再重新插入)。可以理解为我们给有这样特殊需要的 input 添加了个性化的 ID,它不跟其他 input 共享页面中的 HTML 元素:
图 5-3 绑定key
后,更新type
前的 DOM 元素
图 5-4 绑定key
后,更新type
后的 DOM 元素
上面我们只讲了v-if
、v-else-if
、v-else
这几个,条件渲染的指令还有一个v-show
:
<div v-show="isShow">Something</div>
# v-show
v-show
和v-if
不一样,v-if
会在条件具备的时候才进行渲染,而v-show
的逻辑是一定渲染,但在条件具备的时候才显示:
function genVShowHTML(scopeData) {
// scopeData 为 Vue 实例里绑定的 data 数据
// 这里的 hide 类名具有样式 display: none;
return `<div ${scopeData.isShow ? "" : 'class="hide"'}>Something</div>`;
}
带有v-show
的元素始终会被渲染并保留在 DOM 中。一般来说,v-if
有更高的切换开销(因为要不停地重新渲染),而v-show
有更高的初始渲染开销。因此,如果需要非常频繁地切换,则使用v-show
较好;如果在运行时条件很少改变,则使用v-if
较好。
# 5.1.2 列表渲染
列表渲染相关的指令主要是v-for
这个指令,用来渲染列表。
# v-for
v-for
指令需要使用item in items
形式的特殊语法,除了遍历数组以外,v-for
还能遍历对象、数字:
<!-- 遍历数组时 -->
<!-- 其中 items 是源数据数组,而 item 则是被迭代的数组元素的别名,可选的第二个参数 index 为当前项的索引 -->
<ul>
<li v-for="(item, index) in items">
{{index}}: {{ item.message }}
</li>
</ul>
<!-- 遍历对象时 -->
<!-- 在遍历对象时,会按 Object.keys() 的结果遍历 -->
<!-- 其中 object 是源数据对象,而 value 则是被遍历的对象值,可选的第二个参数 key 为当前值的键名,可选的第三个参数 index 为当前项的索引 -->
<div v-for="(value, key, index) in object">
{{ index }}.{{ key }}: {{ value }}
</div>
<!-- 还能遍历数字 -->
<p v-for="n in 10">{{n}}</p>
我们依然来以模板最终生成的逻辑来理解一下:
// 遍历数组的可以解析成这样
function genVForArrayHTML(scopeData) {
// scopeData 为 Vue 实例里绑定的 data 数据
let htmlString = "<ul>";
scopeData.items.forEach((item, index) => {
htmlString += `<li>${index}: ${item.message}</li>`;
});
htmlString += "</ul>";
return htmlString;
}
// 遍历对象的可以解析成这样
function genVForObjectHTML(scopeData) {
// scopeData 为 Vue 实例里绑定的 data 数据
let htmlString = "";
Object.keys(scopeData.object).forEach((key, index) => {
htmlString += `<div>${index}.${key}: ${scopeData.object[key]}</div>`;
});
return htmlString;
}
// 遍历数字的可以解析成这样
function genVForNumberHTML(num) {
let htmlString = "";
for (let i = 1; i <= num; i++) {
htmlString += `<p>${i}</p>`;
}
return htmlString;
}
# key
同样的,由于 Vue 中虚拟 DOM 的 Diff 方法和更新页面的方式,v-for
指令渲染也会存在上面v-if
一样的问题,即 input 这样的依赖临时 DOM 状态或子组件状态的元素,需要使用key
来绑定使得可以重新渲染:
<div v-for="item in items" v-bind:key="item.id">
<!-- 内容 -->
</div>
# 数据更新检测
在 Vue 中,当我们在data
里绑定对象或者数组的时候,需要注意以下问题:
(1) data
中的对象:Vue 无法检测到对象属性的添加或删除,当实例被创建时就已经存在于data
中的属性才是响应式的,新增的属性等都不会触发视图的更新。
(2) data
中的数组:除了特殊的数组操作如push()
、pop()
、shift()
、unshift()
、splice()
、sort()
、reverse()
这些方法之外,数组中某个元素被替换、更新这种操作是无法触发视图更新的(具体可以参加第3章内容)。
对于上面这两种情况,我们一般可以使用以下处理方式:
// 数组处理方法1: 返回新数组
this.items = [...this.items, newItem];
// 数组处理方法2: Vue.set 或 vm.$set
Vue.set(vm.items, indexOfItem, newValue);
vm.$set(vm.items, indexOfItem, newValue);
// 对象处理方法1: 返回新对象
this.object = { ...this.object, key: newValue };
// 对象处理方法2: Vue.set 或 vm.$set
Vue.set(vm.object, key, value);
vm.$set(vm.object, key, value);
另外,当v-if
与v-for
处于同一节点,v-for
的优先级比v-if
更高,这意味着v-if
将分别重复运行于每个v-for
循环中。(不推荐同时使用v-if
和v-for
,因为会对可读性产生影响。)
# 5.1.3 表单绑定
# v-model
v-model
指令在表单<input>
、<textarea>
及<select>
元素上创建双向数据绑定。实际上v-model
是语法糖,它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理:
<template>
<input v-model="val" />
<!-- v-model 指令其实是下面的语法糖 -->
<input :value="val" @input="updateValue" />
<!-- 也可以这么写 -->
<input :value="val" @input="val = $event.target.value" />
</template>
<script>
export default {
data() {
return {
val: ""
};
},
methods: {
updateValue(event) {
this.val = event.target.value;
}
}
};
</script>
# 使用 Tips
当v-model
使用在多选或者选择框上时,需要注意的是:
(1) 多选时,v-model
会绑定到一个数组。
(2) 对于单选按钮,复选框及选择框的选项,v-model
绑定的值通常是静态字符串。
(3) 复选框可以使用true-value
和false-value
来设置绑定的值。
<!-- 当选中时,`picked` 为字符串 "a" -->
<input type="radio" v-model="picked" value="a" />
<!-- `toggle` 为 true 或 false -->
<input type="checkbox" v-model="toggle" />
<!-- `toggle` 为 'yes' 或 'no' -->
<input type="checkbox" v-model="toggle" true-value="yes" false-value="no" />
<!-- 当选中第一个选项时,`selected` 为字符串 "abc" -->
<select v-model="selected">
<option value="abc">ABC</option>
</select>
# 修饰符
除此之外,v-model
还支持修饰符:
表 5-1 v-model
修饰符
修饰符 | 说明 |
---|---|
.lazy | v-model 在每次input 事件触发后将输入框的值与数据进行同步,转变为使用change 事件进行同步 |
.number | 自动将用户的输入值转为数值类型 |
.trim | 自动过滤用户输入的首尾空白字符 |
# 自定义 v-model
我们在很多场景下,需要对一些表单组件封装一些逻辑,如日期选择、常见的搜索功能等。前面也说过,v-model
是语法糖:
<input v-model="something" />
<!-- 其实相当于下面的简写 -->
<input :value="something" @input="something = $event.target.value" />
所以我们如果需要自定义v-model
,需要做两个事情:
(1) 接受一个value
prop。
(2) 在有新的值时触发input
事件并将新值作为参数。
默认情况下,一个组件的v-model
会使用value
Prop 和input
事件。但是诸如单选框、复选框之类的输入类型可能把value
用作了别的目的。model
选项可以避免这样的冲突:
Vue.component("my-checkbox", {
model: {
prop: "checked", // 绑定的值
event: "change" // 自定义事件
},
props: {
checked: Boolean,
// 这样就允许拿 `value` 这个 prop 做其它事了
value: String
}
// ...
});
很多时候,我们会直接使用开源的库,例如 DatetimePicker。很多以前的工具库都依赖了 jQuery(说明它是真的好用),而开发业务的我们通常没有特别多的时间去一个个造轮子,我们会直接拿别人造好的轮子来用。这里我们来讲述下一个使用 select2 插件的自定义下拉组件的封装:
<template>
<div>
<select
class="form-control"
:placeholder="placeholder"
:disabled="disabled"
></select>
</div>
</template>
<script>
export default {
name: "Select2",
data() {
return {
select2: null
};
},
model: {
event: "change", // 使用change作为自定义事件
prop: "value" // 使用value字段,故这里其实不用写也可以
},
props: {
placeholder: {
type: String,
default: ""
},
options: {
type: Array,
default: []
},
disabled: {
type: Boolean,
default: false
},
value: null
},
watch: {
options(val) {
// 若选项改变,则更新组件选项
this.setOption(val);
},
value(val) {
// 若绑定值改变,则更新绑定值
this.setValue(val);
}
},
methods: {
setOption(val = []) {
// 更新选项
this.select2.select2({ data: val });
// 若默认值为空,且选项非空,则设置为第一个选项的值
if (!this.value && val.length) {
const { id, text } = val[0];
this.$emit("change", id);
this.$emit("select", { id, text });
this.select2.select2("val", [id]);
}
// 触发组件更新状态
this.select2.trigger("change");
},
setValue(val) {
this.select2.select2("val", [val]);
this.select2.trigger("change");
}
},
mounted() {
// 初始化组件
this.select2 = $(this.$el)
.find("select")
.select2({
data: this.options
})
.on("select2:select", ev => {
const { id, text } = ev["params"]["data"];
this.$emit("change", id);
this.$emit("select", { id, text });
});
// 初始化值
if (this.value) {
this.setValue(this.value);
}
},
beforeDestroy() {
// 销毁组件
this.select2.select2("destroy");
}
};
</script>
需要注意的是change
自定义事件和value
绑定值相关的内容,而关于 Select2 和 jQuery 相关的,大家可以去搜索一下对应的使用方式。而这个select2
组件也被封装打包成一个 npm 依赖包了,对源码感兴趣的可以搜索v-select2-component
的 npm 包来查看。
# 5.1.4 指令解析
在 Vue 中,指令是一种符合 HTML 规范的模板语法,在 AST 解析的时候,会识别匹配内置指令或是自定义指令,来进行对应的逻辑处理。第1章中我们已经介绍了,Vue 中的模板语法 AST,最终都会生成一段可执行的 Javascript 代码。我们来看看 Vue 中常见的这些内置指令的生成:
// v-once 生成的代码
function genOnce(el: ASTElement): string {
el.onceProcessed = true;
if (el.if && !el.ifProcessed) {
return genIf(el);
} else if (el.staticInFor) {
let key = "";
let parent = el.parent;
// 如果有父节点有 v-for,获取其 key
while (parent) {
if (parent.for) {
key = parent.key;
break;
}
parent = parent.parent;
}
// 缺 key 则提示
if (!key) {
process.env.NODE_ENV !== "production" &&
warn(`v-once can only be used inside v-for that is keyed. `);
return genElement(el);
}
return `_o(${genElement(el)},${onceCount++}${key ? `,${key}` : ``})`;
} else {
return genStatic(el);
}
}
// v-if 生成的代码
function genIf(el: any): string {
el.ifProcessed = true; // 避免递归
return genIfConditions(el.ifConditions.slice());
}
// v-if 条件判断生成的代码
function genIfConditions(conditions: ASTIfConditions): string {
if (!conditions.length) {
return "_e()";
}
// 多个条件,轮流处理
const condition = conditions.shift();
if (condition.exp) {
return `(${condition.exp})?${genTernaryExp(
condition.block
)}:${genIfConditions(conditions)}`;
} else {
return `${genTernaryExp(condition.block)}`;
}
// v-if 和 v-once 会生成这样的代码: (a)?_m(0):_m(1)
function genTernaryExp(el) {
return el.once ? genOnce(el) : genElement(el);
}
}
// v-for 生成的代码
function genFor(el: any): string {
const exp = el.for;
const alias = el.alias;
const iterator1 = el.iterator1 ? `,${el.iterator1}` : "";
const iterator2 = el.iterator2 ? `,${el.iterator2}` : "";
el.forProcessed = true; // 避免递归
return (
`_l((${exp}),` +
`function(${alias}${iterator1}${iterator2}){` +
`return ${genElement(el)}` +
"})"
);
}
验证了我们的 Vue 指令是模板语法,最终将指令转变成逻辑来拼接和维护真正的模板。
# 5.2 自定义指令
除了内置指令以外,Vue 也支持自定义指令。通常我们会在需要给某个元素添加简单的事件处理逻辑的时候,会使用到自定义指令。
# 5.2.1 使用场景
我们来看看简单的一个表单自动聚焦的例子:
// 注册一个全局自定义指令 `v-focus`
// 当然这里也支持通过 Vue 选项来进行局部注册指令
Vue.directive("focus", {
// 当被绑定的元素插入到 DOM 中时
inserted: function(el) {
// 聚焦元素
el.focus();
}
});
然后我们可以在模板中任何元素上使用新的v-focus
属性:
<!-- 当页面加载时,该元素将获得焦点 -->
<input v-focus />
# 5.2.2 钩子函数
自定义指令中一般会用到的钩子函数,除了上面例子中的inserted
,基本上会使用到的包括:
表 5-2 自定义指令中常用的钩子函数
钩子函数 | 说明 |
---|---|
bind | 只调用一次,指令第一次绑定到元素时调用 |
inserted | 被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中) |
update | 所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前(指令的值通过比较更新前后的值来忽略不必要的模板更新) |
componentUpdated | 指令所在组件的 VNode 及其子 VNode 全部更新后调用 |
unbind | 只调用一次,指令与元素解绑时调用 |
简单理解下上面的几种周期钩子,至于具体函数的参数和说明,大家可以去官网上搜一下。这里我们直接来讲几个实际的开发实现。
# 5.2.3 v-click-outside 实现
这是一个常见的交互,当点击某个元素的外面时,执行一些操作(例如关闭某些元素)。常见于点击内容框以外的地方,自动隐藏起内容框这样的操作。
Vue.directive("click-outside", {
bind: function(el, binding, vnode) {
el.event = function(event) {
// 检查点击是否发生在节点之内(包括子节点)
if (!(el == event.target || el.contains(event.target))) {
// 如果没有,则触发调用
// 若绑定值为函数,则执行
// 这里我们可以通过钩子函数中的 vnode.context,来获取当前组件的作用域
if (typeof vnode.context[binding.expression] == "function") {
vnode.context[binding.expression](event);
}
}
};
// 绑定事件
// 设置为true,代表在DOM树中,注册了该listener的元素,会先于它下方的任何事件目标,接收到该事件。
document.body.addEventListener("click", el.event, true);
},
unbind: function(el) {
// 解绑事件
document.body.removeEventListener("click", el.event, true);
}
});
我们可以这样使用:
<template>
<div>
<!-- 这是基于 bootstrap 常见的下拉菜单样式 -->
<div class="row" style="margin-left: 20px;">
<label class="mr5" style="font-size: 14px;">下拉菜单</label>
<!-- v-click-outside 绑定方法名 -->
<div class="btn-group" v-click-outside="closeMenu">
<!-- 这里点击会切换菜单是否可见 -->
<button
type="button"
class="btn btn-default dropdown-toggle"
@click="isMenuShown = !isMenuShown"
>
点击 <span class="caret"></span>
</button>
<ul v-show="isMenuShown" class="dropdown-menu" style="display:block;">
<li><a href="#">Action</a></li>
<li><a href="#">Another action</a></li>
<li><a href="#">Something else here</a></li>
<li role="separator" class="divider"></li>
<li><a href="#">Separated link</a></li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
isMenuShown: false
};
},
methods: {
// 该方法将菜单是否可见设置为不可见
closeMenu(ev) {
console.log({ ev });
this.isMenuShown = false;
}
}
};
</script>
这里有个需要注意的地方是,这种带条件判断的逻辑,我们需要绑定函数来使用。因为我们在自定义指令的时候,我们的钩子函数参数里面包括value
指令的绑定值,也就是说,指令会执行求值操作。如果我们直接绑定表达式的话,则每次触发都会求值,并不能跟进条件判断是否求值。
# 5.2.4 v-longpress 实现
另外一个常见的交互,是长按触发时候,需要进行某些操作。
Vue.directive("longpress", {
bind: function(el, binding, vNode) {
// 确保提供的表达式是函数
if (typeof binding.value !== "function") {
// 获取组件名称
const compName = vNode.context.name;
// 将警告传递给控制台
let warn = `[longpress:] provided expression '${binding.expression}' is not a function, but has to be `;
if (compName) {
warn += `Found in component '${compName}' `;
}
console.warn(warn);
}
// 定义变量
let pressTimer = null;
// 定义函数处理程序
// 创建计时器( 1秒后执行函数 )
el.startEvent = e => {
if (e.type === "click" && e.button !== 0) {
return;
}
if (pressTimer === null) {
pressTimer = setTimeout(() => {
// 执行函数
handler();
}, 1000);
}
};
// 取消计时器
el.cancelEvent = e => {
// 检查计时器是否有值
if (pressTimer !== null) {
clearTimeout(pressTimer);
pressTimer = null;
}
};
// 运行函数
const handler = e => {
// 执行传递给指令的方法
binding.value(e);
};
// 添加事件监听器
el.addEventListener("mousedown", el.startEvent, true);
el.addEventListener("touchstart", el.startEvent, true);
// 取消计时器
el.addEventListener("click", el.cancelEvent, true);
el.addEventListener("mouseout", el.cancelEvent, true);
el.addEventListener("touchend", el.cancelEvent, true);
el.addEventListener("touchcancel", el.cancelEvent, true);
},
unbind: function(el) {
// 解绑事件
el.removeEventListener("mousedown", el.startEvent, true);
el.removeEventListener("touchstart", el.startEvent, true);
// 取消计时器
el.removeEventListener("click", el.cancelEvent, true);
el.removeEventListener("mouseout", el.cancelEvent, true);
el.removeEventListener("touchend", el.cancelEvent, true);
el.removeEventListener("touchcancel", el.cancelEvent, true);
}
});
当然,这里为了兼容移动端,同时绑定了很多的事件,优化方式是可以区分移动端还是 PC 端来绑定不同的事件。使用方式如下:
<template>
<div v-longpress="longPress">{{text}}</div>
</template>
<script>
export default {
data() {
return {
text: "初始化"
};
},
methods: {
// 长按时执行该方法
longPress() {
this.text = "长按";
}
}
};
</script>
自定义指令在实际开发中很方便,可以快捷地支持 DOM 交互相关的功能,包括前面介绍的v-focus
、v-longpress
、v-click-outside
,我们在实际开发过程中,也需要多思考哪些内容可以进行抽象和封装,也是会收获很多。