# 第5章 常用指令和自定义指令

本章节相关代码存放在Github (opens new window)中。

前面我们在介绍 Vue 实例、Vue 组件的时候,也看到不少的v-ifv-model这样的语法,这种语法在 Vue 中称为指令(directive)。在前端的三大框架 Angular、React 和 Vue 里,Angular 的指令是做的最早以及比较完善的,但门槛较高。对比看来,Vue 里做了巧妙的减法,该减法使得 Vue 的常用指令简单易懂,同时提供的自定义指令能力也易于上手。

# 5.1 常用指令

我们先来看看常用的一些 Vue 内置指令吧。

# 5.1.1 条件渲染

条件渲染相关指令主要包括v-ifv-else-ifv-elsev-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>

当我们的typephone切换到其他值的时候,该<input>元素只会更新属性值typeplaceholder,但原先输入的内容还在:

image
图 5-1 未绑定key前,更新type前的 DOM 元素

image
图 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 元素:

image
图 5-3 绑定key后,更新type前的 DOM 元素

image
图 5-4 绑定key后,更新type后的 DOM 元素

上面我们只讲了v-ifv-else-ifv-else这几个,条件渲染的指令还有一个v-show

<div v-show="isShow">Something</div>

# v-show

v-showv-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-ifv-for处于同一节点,v-for的优先级比v-if更高,这意味着v-if将分别重复运行于每个v-for循环中。(不推荐同时使用v-ifv-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-valuefalse-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会使用valueProp 和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指令的绑定值,也就是说,指令会执行求值操作。如果我们直接绑定表达式的话,则每次触发都会求值,并不能跟进条件判断是否求值。

点击此处查看页面效果 (opens new window) 点击此处查看源码 (opens new window)

# 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-focusv-longpressv-click-outside,我们在实际开发过程中,也需要多思考哪些内容可以进行抽象和封装,也是会收获很多。

点击此处查看页面效果 (opens new window) 点击此处查看源码 (opens new window)