前面我们在介绍 Vue 实例、Vue 组件的时候,也看到不少的v-if
、v-model
这样的语法,这种语法在 Vue 中称为指令(directive)。在前端的三大框架 Angular、React 和 Vue 里,Angular 的指令是做的最早以及比较完善的,但门槛较高。对比看来,Vue 里做了巧妙的减法,该减法使得 Vue 的常用指令简单易懂,同时提供的自定义指令能力也易于上手。
1. 常用指令
我们先来看看常用的一些 Vue 内置指令吧。
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
较好。
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
,因为会对可读性产生影响。)
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 包来查看。
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 指令是模板语法,最终将指令转变成逻辑来拼接和维护真正的模板。
2. 自定义指令
除了内置指令以外,Vue 也支持自定义指令。通常我们会在需要给某个元素添加简单的事件处理逻辑的时候,会使用到自定义指令。
2.1 使用场景
我们来看看简单的一个表单自动聚焦的例子:
// 注册一个全局自定义指令 `v-focus` // 当然这里也支持通过 Vue 选项来进行局部注册指令 Vue.directive("focus", { // 当被绑定的元素插入到 DOM 中时 inserted: function(el) { // 聚焦元素 el.focus(); } });
然后我们可以在模板中任何元素上使用新的v-focus
属性:
<!-- 当页面加载时,该元素将获得焦点 --> <input v-focus />
2.2 钩子函数
自定义指令中一般会用到的钩子函数,除了上面例子中的inserted
,基本上会使用到的包括:
表 5-2 自定义指令中常用的钩子函数
钩子函数 | 说明 |
---|---|
bind |
只调用一次,指令第一次绑定到元素时调用 |
inserted |
被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中) |
update |
所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前(指令的值通过比较更新前后的值来忽略不必要的模板更新) |
componentUpdated |
指令所在组件的 VNode 及其子 VNode 全部更新后调用 |
unbind |
只调用一次,指令与元素解绑时调用 |
简单理解下上面的几种周期钩子,至于具体函数的参数和说明,大家可以去官网上搜一下。这里我们直接来讲几个实际的开发实现。
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
指令的绑定值,也就是说,指令会执行求值操作。如果我们直接绑定表达式的话,则每次触发都会求值,并不能跟进条件判断是否求值。
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
,我们在实际开发过程中,也需要多思考哪些内容可以进行抽象和封装,也是会收获很多。