Vue 2 技术栈归纳与精粹

Vue 是一款高度封装的开箱即用的一栈式的前端框架,既可以结合 webpack 进行编译式前端开发,也适用基于 gulp、grunt 等自动化工具直接挂载至全局window使用。本文成文于 Vue2.4.x 版本发布之初,笔者生产环境当前使用的最新版本为 2.6.12。在经历多个前端重度交互项目的开发实践之后,笔者结合官方文档对 Vue 技术栈进行了全面的梳理、归纳和注解,因此本文可以作为 Vue2 官方《Get Started》 的补充性读物。

Vue 2 框架体系及技术栈日趋完善,相较于React+Reflux/Redux/MobX技术栈,Vue 更加贴近 W3C 技术规范(例如实现仍处于 W3C 草案阶段的<template><slot>is等新特性,提供了良好易用的模板书写环境),并且开源生态更加完整且易于配置,将 React 手动编码才能处理的细节,整合成为最佳实践并抽象为语法糖(比如 Vuex 中提供的store的模块化特性),使得开发人员得以将注意力聚焦于业务逻辑本身。

Vue2 的 API 结构相比 Angular 更加简洁,可以自由结合 TypeScript 或是 ECMAScript6 使用,并不特定于具体的预处理语言去获得最佳使用体验,框架本身的特性也并不强制依赖于各类炫酷的语法糖。Vue2 总体是一款非常轻量的技术栈,设计实现上紧随 W3C 技术规范,着力于处理HTML 模板组件化事件和数据的作用域分离多层级组件通信三个单页面前端开发当中的重点问题。本文在行文过程中,穿插描述了 Angular、React 等前端框架的异同与比较,供徘徊于各类前端技术选型的开发人员参考。

Vue 与 Angular 的比较

组件化

Angular 的设计思想照搬了 Java Web 开发当中 MVC 分层的概念,通过Controller切割并控制页面作用域,然后通过Service来实现复用,是一种对页面进行纵向分层的解耦思想。而 Vue 允许开发人员将页面抽象为若干独立的组件,即对页面的 DOM 结构进行横向的切割,通过组件的拼装来完成功能的复用、作用域控制。每个组件只提供props作为单一接口,并采用 Vuex 进行state tree的管理,从而便捷的实现组件间状态的通信与同步。

Angular 在1.6.x版本开始提供component()方法和Component Router来提供组件化开发的体验,但是依然需要依赖于controllerservice的划分,实质上依然没有摆脱 MVC 纵向分层思想的桎梏。

双向绑定与响应式绑定

Vue 遍历 data 对象上的所有属性,并通过原生Object.defineProperty()方法将这些属性转换为getter/setter只支持 IE9 及以上浏览器)。Vue 内部通过这些 getter/setter 追踪依赖,在属性被修改时触发相应变化,从而完成模型到视图的双向绑定。每个 Vue 组件实例化时,都会自动调用$watch()遍历自身的 data 属性,并将其记录为依赖项,当这些依赖项的 setter 被触发时会通知 watcher 重新计算新值,然后触发 Vue 组件的render()函数重新渲染组件。

与 Aangular 双向数据绑定不同,Vue 组件不能检测到实例化后 data 属性的添加、删除,因为 Vue 组件在实例化时才会对属性执行 getter/setter 处理,所以 data 对象上的属性必须在实例化之前存在,Vue 才能够正确的进行转换。因而,Vue 提供的并非真正意义上的双向绑定,更准确的描述应该是单向绑定,响应式更新,而 Angular 即可以通过$scope影响 view 上的数据绑定,也可以通过视图层操作$scope上的对象属性,属于真正意义上的视图与模型的双向绑定

1
2
3
4
5
6
7
var vm = new Vue({
data: {
a: 1
}
});
vm.a = 1; // 响应的
vm.b = 2; // 非响应的

因此,Vue 不允许在已经实例化的组件上添加新的动态根级响应属性(即直接挂载在 data 下的属性),但是可以使用Vue.set(object, key, value)方法添加响应式属性。

1
2
3
4
5
6
7
Vue.set(vm.someObject, "b", 2);

// vm.$set()实例方法是Vue.set()全局方法的别名
this.$set(this.someObject, "b", 2);

// 使用Object.assign()或_.extend()也可以添加响应式属性,但是需要创建同时包含原属性、新属性的对象,从而有效触发watch()方法
this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 });

Vue 对 DOM 的更新是异步的,观察到数据变化后 Vue 将开启一个队列,缓冲在同一事件循环(Vue 的 event loop 被称为tick [tɪk] n.标记,记号)中发生的所有数据变化。如果同一个 watcher 被多次触发,只会向这个队列中推入一次。

Vue 内部会通过原生 JavaScript 的Promise.thenMutationObserversetTimeout(fn, 0)来执行异步队列当中的 watcher。

在需要人为操作 DOM 的场景下,为了在 Vue 响应数据变化之后再更新 DOM,可以手动调用Vue.nextTick(callback),并将 DOM 操作逻辑放置在 callback 回调函数中,从而确保响应式更新完成之后再进行 DOM 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<div id="example">{{message}}</div>

<script>
// 使用Vue实例上的.$nextTick()
var vue = new Vue({
el: "#example",
data: {
message: "123"
}
});
vue.message = "new message"; // 更改数据
vue.$el.textContent === "new message"; // false
vue.nextTick(function() {
vm.$el.textContent === "new message"; // true
});
</script>

<script>
// 组件内使用vm.$nextTick(),不需要通过全局Vue,且回调函数中this自动指向当前Vue实例
Vue.component("example", {
template: "<span>{{ message }}</span>",
data: function() {
return {
message: "没有更新"
};
},
methods: {
updateMessage: function() {
this.message = "更新完成";
console.log(this.$el.textContent); // 没有更新
this.$nextTick(function() {
console.log(this.$el.textContent); // 更新完成
});
}
}
});
</script>

Object.defineProperty()

Vue2 的响应式绑定依靠 JavaScript 原生提供的Object.defineProperty()实现,下面通过手动完成了一个简单的响应式绑定,来展示其内在的工作机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<label>输入:</label>
<input id="input" type="text"><br/>
<label>输出:</label>
<input id="output" type="text" ></input>

<script>
const store = {};
const output = [];

Object.defineProperty(store, "element", {
// 为store对象的element属性设置监听方法
set: value => {
output["element"] = value;
},
get: () => {
return output["element"];
}
});

let inputDOM = document.querySelector("#input");
let outputDOM = document.querySelector("#output");

inputDOM.onkeyup = () => {
store.element = inputDOM.value; // 为store对象设置element属性时候,会触该属性的set方法,此时inputDOM的value值将会赋予outputDOM。
outputDOM.value = output["element"];
};
</script>

上面的示例中,通过操作 DOM 的方式完成数据绑定显然不够优雅,目前使用较为广泛的是 Vue2 所使用的 Mustache 胡须语法{{``}},因此如何更加语义化的完成数据绑定,成为下一步需要着手解决的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<label>输入:</label>
<input id="input" type="text" />
<!-- 使用了Handlebar的胡须表达式 -->
<p id="output">Handlebar编译后输出:{{element}}</p>

<script>
const store = {};
const output = [];

Object.defineProperty(store, "element", {
// 为store对象的element属性设置监听方法
set: value => {
output["element"] = value || "";
},
get: () => {
return output["element"];
}
});

let inputDOM = document.querySelector("#input");
let outputHTML = document.querySelector("#output").innerHTML;

compile(store, outputHTML); // 初始化编译

// 绑定输入框事件,并且实时编译DOM
inputDOM.onkeyup = () => {
store.element = inputDOM.value;
compile(store, outputHTML);
};

// 通过Handlebar编译使用了胡须语法的DOM
function compile(store, html) {
const template = Handlebars.compile(html);
document.querySelector("#output").innerHTML = template(store);
}
</script>

看起来工作得学不错,不过生产环境下需要完成的功能更多,比如对用户输入的内容进行清洗,以防止 XSS;通过虚拟 DOM 和 diff 算法,更有效率的插入编译后的 DOM 等等,当然这些工作 Vue2 已经为你完成,开发人员只需要专注于业务代码本身。

虚拟 DOM

Vritual DOM这个概念最先由 React 引入,是一种 DOM 对象差异化比较方案,即将 DOM 对象抽象成为 Vritual DOM 对象(即 render()函数渲染的结果),然后通过差异算法对 Vritual DOM 进行对比并返回差异,最后通过一个补丁算法将返回的差异对象应用在真实 DOM 结点。

Vue 当中的 Virtual DOM 对象被称为VNodetemplate当中的内容会被编译为 render()函数,而 render()函数接收一个 createElement()函数,并最终返回一个 VNode 对象),补丁算法来自于另外一个开源项目snabbdom,即将真实的 DOM 操作映射成对虚拟 DOM 的操作,通过减少对真实 DOM 的操作次数来提升性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
➜  vdom git:(dev) tree
├── create-component.js
├── create-element.js
├── create-functional-component.js
├── helpers
│   ├── extract-props.js
│   ├── get-first-component-child.js
│   ├── index.js
│   ├── is-async-placeholder.js
│   ├── merge-hook.js
│   ├── normalize-children.js
│   ├── resolve-async-component.js
│   └── update-listeners.js
├── modules
│   ├── directives.js
│   ├── index.js
│   └── ref.js
├── patch.js
└── vnode.js

VNode 的设计出发点与 Angular 的$digest循环类似,都是通过减少对真实 DOM 的操作次数来提升性能,但是 Vue 的实现更加轻量化,摒弃了 Angular 为了实现双向绑定而提供的$apply()$eval()封装函数,有选择性的实现 Angular 中$compile()$watch()类似的功能。

Vue 对象的选项

通过向构造函数new Vue()传入一个option对象去创建一个 Vue 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
var vm = new Vue({
// 数据
data: "声明需要响应式绑定的数据对象",
props: "接收来自父组件的数据",
propsData: "创建实例时手动传递props,方便测试props",
computed: "计算属性",
methods: "定义可以通过vm对象访问的方法",
watch: "Vue实例化时会调用$watch()方法遍历watch对象的每个属性",
// DOM
el: "将页面上已存在的DOM元素作为Vue实例的挂载目标",
template: "可以替换挂载元素的字符串模板",
render: "渲染函数,字符串模板的替代方案",
renderError: "仅用于开发环境,在render()出现错误时,提供另外的渲染输出",
// 生命周期钩子
beforeCreate: "发生在Vue实例初始化之后,data observer和event/watcher事件被配置之前",
created: "发生在Vue实例初始化以及data observer和event/watcher事件被配置之后",
beforeMount: "挂载开始之前被调用,此时render()首次被调用",
mounted: "el被新建的vm.$el替换,并挂载到实例上之后调用",
beforeUpdate: "数据更新时调用,发生在虚拟DOM重新渲染和打补丁之前",
updated: "数据更改导致虚拟DOM重新渲染和打补丁之后被调用",
activated: "keep-alive组件激活时调用",
deactivated: "keep-alive组件停用时调用",
beforeDestroy: "实例销毁之前调用,Vue实例依然可用",
destroyed: "Vue实例销毁后调用,事件监听和子实例全部被移除,释放系统资源",
// 资源
directives: "包含Vue实例可用指令的哈希表",
filters: "包含Vue实例可用过滤器的哈希表",
components: "包含Vue实例可用组件的哈希表",
// 组合
parent: "指定当前实例的父实例,子实例用this.$parent访问父实例,父实例通过$children数组访问子实例",
mixins: "将属性混入Vue实例对象,并在Vue自身实例对象的属性被调用之前得到执行",
extends: "用于声明继承另一个组件,从而无需使用Vue.extend,便于扩展单文件组件",
provide&inject: "2个属性需要一起使用,用来向所有子组件注入依赖,类似于React的Context",
// 其它
name: "允许组件递归调用自身,便于调试时显示更加友好的警告信息",
delimiters: "改变模板字符串的风格,默认为{{}}",
functional: "让组件无状态(没有data)和无实例(没有this上下文)",
model: "允许自定义组件使用v-model时定制prop和event",
inheritAttrs: "默认情况下,父作用域的非props属性绑定会应用在子组件的根元素上。当编写嵌套有其它组件或元素的组件时,可以将该属性设置为false关闭这些默认行为",
comments: "设为true时会保留并且渲染模板中的HTML注释"
});

Vue 实例通常使用vmView Model)变量来命名。

属性计算 computed

在 HTML 模板表达式中放置太多业务逻辑,会让模板过重且难以维护。因此,可以考虑将模板中比较复杂的表达式拆分到 computed 属性当中进行计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- 不使用计算属性 -->
<div id="example">
{{ message.split("").reverse().join("") }}
</div>

<!-- 将表达式抽象到计算属性 -->
<div id="example">
<p>Original message: "{{ message }}"</p>
<p>Computed reversed message: "{{ reversedMessage }}"</p>
</div>

<script>
var vm = new Vue({
el: "#example",
data: {
message: "Hello"
},
computed: {
reversedMessage: function() {
return this.message
.split("")
.reverse()
.join("");
}
}
});
</script>

计算属性只在相关依赖发生改变时才会重新求值,这意味只要上面例子中的 message 没有发生改变,多次访问 reversedMessage 计算属性总会返回之前的计算结果,而不必再次执行函数,这是 computed 和 method 的一个重要区别。

计算属性默认只拥有getter方法,但是可以自定义一个setter方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script>
... ... ...
computed: {
fullName: {
// getter
get: function () {
return this.firstName + " " + this.lastName
},
// setter
set: function (newValue) {
var names = newValue.split(" ")
this.firstName = names[0]
this.lastName = names[names.length - 1]
}
}
}
... ... ...
// 下面语句触发setter方法,firstName和lastName也会被相应更新
vm.fullName = "John Doe"
</script>

观察者属性 watch

通过 watch 属性可以手动观察 Vue 实例上的数据变动,当然也可以调用实例上的vm.$watch达到相同的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<div id="watch-example">
<p>Ask a yes/no question: <input v-model="question" /></p>
<p>{{ answer }}</p>
</div>

<script>
var watchExampleVM = new Vue({
el: "#watch-example",
data: {
question: "",
answer: "I cannot give you an answer until you ask a question!"
},
watch: {
// 如果question发生改变,该函数就会运行
question: function(newQuestion) {
this.answer = "Waiting for you to stop typing...";
this.getAnswer();
}
},
methods: {
// _.debounce是lodash当中限制操作频率的函数
getAnswer: _.debounce(
function() {
if (this.question.indexOf("?") === -1) {
this.answer = "Questions usually contain a question mark. ;-)";
return;
}
this.answer = "Thinking...";
var vm = this;
axios
.get("https://yesno.wtf/api")
.then(function(response) {
vm.answer = _.capitalize(response.data.answer);
})
.catch(function(error) {
vm.answer = "Error! Could not reach the API. " + error;
});
},
// 这是用户停止输入等待的毫秒数
500
)
}
});
</script>

使用 watch 属性的灵活性在于,当监测到数据变化的时候,可以做一些设置中间状态之类的过渡处理。

混合属性 mixins

用来将指定的 mixin 对象复用到 Vue 组件当中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// mixin对象
var mixin = {
created: function() {
console.log("混合对象的钩子被调用");
},
methods: {
foo: function() {
console.log("foo");
},
conflicting: function() {
console.log("from mixin");
}
}
};

// vue属性
var vm = new Vue({
mixins: [mixin],
created: function() {
console.log("组件钩子被调用");
},
methods: {
bar: function() {
console.log("bar");
},
conflicting: function() {
console.log("from self");
}
}
});

// => "混合对象的钩子被调用"
// => "组件钩子被调用"
vm.foo(); // => "foo"
vm.bar(); // => "bar"
vm.conflicting(); // => "from self"

同名组件 option 对象的属性会被合并为数组依次进行调用,其中 mixin 对象里的属性会被首先调用。如果组件 option 对象的属性值是一个对象,则 mixin 中的属性会被忽略掉。

渲染函数 render()

用来创建 VNode,该函数接收createElement()方法作为第 1 个参数,该方法调用后会返回一个虚拟 DOM(即 VNode)。

直接使用表达式,或者在render()函数内通过createElement()进行手动渲染,Vue 都会自动保持blogTitle属性的响应式更新。

1
2
3
4
5
6
7
<h1>{{ blogTitle }}</h1>

<script>
render: function (createElement) {
return createElement("h1", this.blogTitle)
}
</script>

如果组件是一个函数组件,render()还会接收一个 context 参数,以便为没有实例的函数组件提供上下文信息。

通过 render()函数实现虚拟 DOM 比较麻烦,因此可以使用 Babel 插件babel-plugin-transform-vue-jsx在 render()函数中应用 JSX 语法。

1
2
3
4
5
6
7
8
9
10
11
12
import AnchoredHeading from "./AnchoredHeading.vue";

new Vue({
el: "#demo",
render(h) {
return (
<AnchoredHeading level={1}>
<span>Hello</span> world!
</AnchoredHeading>
);
}
});

Vue 对象全局 API

1
2
3
4
5
6
7
8
9
10
11
Vue.extend(options); // 通过继承一个option对象来创建一个Vue实例。
Vue.nextTick([callback, context]); // 在下次DOM更新循环结束之后执行延迟回调。
Vue.set(target, key, value); // 设置对象的属性,如果是响应式对象,将会触发视图更新。
Vue.delete(target, key); // 删除对象的属性,如果是响应式对象,将会触发视图更新。
Vue.directive(id, [definition]); // 注册或获取全局指令。
Vue.filter(id, [definition]); // 注册或获取全局过滤器。
Vue.component(id, [definition]); // 注册或获取全局组件。
Vue.use(plugin); // 安装Vue插件。
Vue.mixin(mixin); // 全局注册一个mixin对象。
Vue.compile(template); // 在render函数中编译模板字符串。
Vue.version; // 提供当前使用Vue的版本号。

Vue.mixin(mixin)

使用全局 mixins 将会影响到所有之后创建的 Vue 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 为自定义选项myOption注入一个处理器。
Vue.mixin({
created: function() {
var myOption = this.$options.myOption;
if (myOption) {
console.log(myOption);
}
}
});

new Vue({
myOption: "hello!"
});

// => "hello!"

Vue.directive(id, [definition])

Vue 允许注册自定义指令,用于对底层 DOM 进行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Vue.directive("focus", {
bind: function() {
// 指令第一次绑定到元素时调用,只会调用一次,可以用来执行一些初始化操作。
},
inserted: function(el) {
// 被绑定元素插入父节点时调用。
},
update: function() {
// 所在组件的VNode更新时调用,但是可能发生在其子VNode更新之前。
},
componentUpdated: function() {
// 所在组件VNode及其子VNode全部更新时调用。
},
unbind: function() {
// 指令与元素解绑时调用,只会被调用一次。
}
});

钩子之间共享数据可以通过HTMLElementdataset属性来进行(即 HTML 标签上通过data-格式定义的属性)。

上面的钩子函数拥有如下参数:

  • el: 指令绑定的 HTML 元素,可以用来直接操作 DOM。
  • vnode: Vue 编译生成的虚拟节点。
  • oldVnode: 之前的虚拟节点,仅在updatecomponentUpdated钩子中可用。
  • binding: 一个对象,包含以下属性:
    • name: 指令名称,不包括v-前缀。
    • value: 指令的绑定值,例如v-my-directive="1 + 1"value的值是2
    • oldValue: 指令绑定的之前一个值,仅在updatecomponentUpdated钩子中可用。
    • expression: 绑定值的字符串形式,例如v-my-directive="1 + 1"当中expression的值为"1 + 1"
    • arg: 传给指令的参数,例如v-my-directive:fooarg的值是"foo"
    • modifiers: 包含修饰符的对象,例如v-my-directive.foo.barmodifiers的值是{foo: true, bar: true}

上面参数除el之外,其它参数都应该是只读的,尽量不要对其进行修改操作。

Vue.filter(id, [definition])

Vue 可以通过定义过滤器,进行一些常见的文本格式化,可以用于 mustache 插值和 v-bind 表达式当中,使用时通过管道符|添加在表达式尾部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- in mustaches -->
{{ message | capitalize }}

<!-- in v-bind -->
<div v-bind:id="rawId | formatId"></div>

<!-- capitalize filter -->
<script>
new Vue({
filters: {
capitalize: function(value) {
if (!value) return "";
value = value.toString();
return value.charAt(0).toUpperCase() + value.slice(1);
}
}
});
</script>

过滤器可以串联使用,也可以传入参数。

1
2
<span>{{ message | filterA | filterB }}</span>
<span>{{ message | filterA("arg1", arg2) }}</span>

Vue.use(plugin)

Vue 通过插件来添加一些全局功能,Vue 插件都会覆写其install()方法,该方法第 1 个参数是Vue构造器, 第 2 个参数是可选的option对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
MyPlugin.install = function(Vue, options) {
// 1. 添加全局方法或属性
Vue.myGlobalMethod = function() {};

// 2. 添加全局资源
Vue.directive("my-directive", {
bind(el, binding, vnode, oldVnode) {}
});

// 3. 注入组件
Vue.mixin({
created: function() {}
});

// 4. 添加实例方法
Vue.prototype.$myMethod = function(methodOptions) {};
};

通过全局方法Vue.use()使用指定插件,使用的时候也可以传入一个 option 对象。

1
Vue.use(MyPlugin, { someOption: true });

vue-router 等插件检测到 Vue 是全局对象时会自动调用Vue.use(),如果在 CommonJS 模块环境中,则需要显式调用Vue.use()

实例属性和方法

Vue 实例暴露了一系列带有前缀$的实例属性与方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
let vm = new Vue();
vm = {
// Vue实例属性的代理
$data: "被watch的data对象",
$props: "当前组件收到的props",
$el: "Vue实例使用的根DOM元素",
$options: "当前Vue实例的初始化选项",
$parent: "父组件Vue对象的实例",
$root: "根组件Vue对象的实例",
$children: "当前实例的直接子组件",
$slots: "访问被slot分发的内容",
$scopedSlots: "访问scoped slots",
$refs: "包含所有拥有ref注册的子组件",
$isServer: "判断Vue实例是否运行于服务器",
$attrs: "包含父作用域中非props的属性绑定",
$listeners: "包含了父作用域中的v-on事件监听器",
// 数据
$watch: "观察Vue实例变化的表达式、计算属性函数",
$set: "全局Vue.set的别名",
$delete: "全局Vue.delete的别名",
// 事件
$on: "监听当前实例上的自定义事件,事件可以由vm.$emit触发",
$once: "监听一个自定义事件,触发一次之后就移除监听器",
$off: "移除自定义事件监听器",
$emit: "触发当前实例上的事件",
// 生命周期
$mount: "手动地挂载一个没有挂载的Vue实例",
$forceUpdate: "强制Vue实例重新渲染,仅影响实例本身和插入插槽内容的子组件",
$nextTick: "将回调延迟到下次DOM更新循环之后执行",
$destroy: "完全销毁一个实例"
};

$refs 属性

组件指定ref属性之后,可以通过组件的$refs实例属性对其进行访问 。

1
2
3
4
5
6
7
8
<div id="parent">
<user-profile ref="profile"></user-profile>
</div>

<script>
var parent = new Vue({ el: "#parent" });
var child = parent.$refs.profile; // 访问子组件
</script>

\(refs会在组件渲染完毕后填充,是非响应式的,仅作为需要直接访问子组件的应急方案,因此要**避免在模板或计算属性中使用\)refs**。

生命周期

每个 Vue 实例在创建时,都需要经过一系列初始化过程(设置数据监听、编译模板、挂载实例到 DOM、在数据变化时更新 DOM),并在同时运行一些钩子函数,让开发人员能够在特定生命周期内执行自己的代码。

不要在 Vue 实例的属性和回调上使用箭头函数,比如created: () => console.log(this.a)vm.$watch("a", newValue => this.myMethod())。因为箭头函数的 this 与父级上下文绑定,并不指向 Vue 实例本身,所以前面代码中的this.athis.myMethod将会是undefined

通过 jQuery 对 DOM 进行的操作可以放置在Mounted属性上进行,即当 Vue 组件已经完成在 DOM 上挂载的时候。

数据绑定

Vue 视图层通过Mustache["mʌstæʃ]语法与 Vue 实例中的 data 属性进行响应式绑定,但是也可以通过内置指令v-once完成一个单向的绑定,再或者通过v-html指令将绑定的字符串输出为 HTML,虽然这样很容易招受 XSS 攻击。

1
2
3
<span>Message: {{ result }}</span>
<span v-once>一次性绑定: {{ msg }}</span>
<div v-html="rawHtml"></div>

Mustache 不能用于 HTML 属性,此时需要借助于v-bind指令。

1
2
<div v-bind:id="dynamicId"></div>
<button v-bind:disabled="isButtonDisabled">Button</button>

绑定 HTML 的 class 和 style

直接操作classstyle属性是前端开发当中的常见需求,Vue 通过v-bind:classv-bind:style指令有针对性的对这两种操作进行了增强。

v-bind:class

绑定 HTML 的class属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- Vue对象中的data -->
<script>
... ...
data: {
isActive: true,
hasError: false,
classObject: {
active: true,
"text-danger": false
}
}
... ...
</script>

<!-- 直接绑定class到一个对象 -->
<div v-bind:class="classObject"></div>

<!-- 直接绑定class到对象的属性 -->
<div class="static" v-bind:class="{ active: isActive, "text-danger": hasError }"></div>

<!-- 渲染结果 -->
<div class="static active"></div>

可以传递一个数组给v-bind:class从而同时设置多个 class 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- Vue对象中的data -->
<script>
... ...
data: {
activeClass: "active",
errorClass: "text-danger"
}
... ...
</script>

<!-- 绑定class到计算属性 -->
<div v-bind:class="[activeClass, errorClass]"></div>

<!-- 渲染结果 -->
<div class="active text-danger"></div>

<!-- 使用三目运算符,始终添加errorClass,只在isActive为true时添加activeClass -->
<div v-bind:class="[isActive ? activeClass : "", errorClass]"></div>

<!-- 在数组中使用对象可以避免三目运算符的繁琐 -->
<div v-bind:class="[{ active: isActive }, errorClass]"></div>

当在自定义组件上使用class属性时,这些属性将会被添加到该组件的根元素上面,这一特性同样适用于v-bind:class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 声明一个组件 -->
<script>
Vue.component("my-component", {
template: "<p class="foo bar">Hi</p>",
data: {
isActive: true
},
})
</script>

<!-- 添加2个class属性 -->
<my-component class="baz boo"></my-component>

<!-- 渲染结果 -->
<p class="foo bar baz boo">Hi</p>

<!-- 使用v-bind:class -->
<my-component v-bind:class="{ active: isActive }"></my-component>

<!-- 渲染结果 -->
<p class="foo bar active">Hi</p>

v-bind:style

绑定 HTML 的style属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
... ...
data: {
styleObject: {
color: "red",
fontSize: "13px"
},
styleHeight: {
height: 10rem;
}
styleWidth: {
width: 20rem;
}
}
... ...
</script>

<div v-bind:style="styleObject"></div>

<!-- 使用数组可以将多个样式合并到一个HTML元素上面 -->
<div v-bind:style="[styleHeight, styleWidth]"></div>

使用v-bind:style时 Vue 会自动添加prefix 前缀,常见的 prefix 前缀如下:

  • -webkit- Chrome、Safari、新版 Opera、所有 iOS 浏览器(包括 iOS 版 Firefox),几乎所有 WebKit 内核浏览器。
  • -moz- 针对 Firefox 浏览器。
  • -o- 未使用 WebKit 内核的老版本 Opera。
  • -ms- 微软的 IE 以及 Edge 浏览器。

使用 JavaScript 表达式

Vue 对于所有数据绑定都提供了 JavaScript 表达式支持,但是每个绑定只能使用1个表达式。

1
2
3
4
5
6
7
8
9
10
<span>{{ number + 1 }}</span>
<button>{{ ok ? "YES" : "NO" }}</button>
<p>{{ message.split("").reverse().join("") }}</p>
<div v-bind:id=""list-" + id"></div>

<!-- 这是语句,不是表达式 -->
{{ var a = 1 }}

<!-- if流程控制属于多个表达式,因此不会生效,但可以使用三元表达式 -->
{{ if (ok) { return message } }}

v-model 双向数据绑定

v-model指令实质上是v-onv-bind的糖衣语法,该指令会接收一个value属性,存在新值时则触发一个input事件

1
2
3
4
5
6
7
<!-- 使用v-model的版本 -->
<input v-model="something" />
<!-- 使用v-on和v-bind的版本 -->
<input v-bind:value="something" v-on:input="something = $event.target.value" />
<!-- 也可以自定义输入域的响应式绑定 -->
<custom-input v-bind:value="something" v-on:input="something = arguments[0]">
</custom-input>

单选框、复选框一类的输入域将 value 属性作为了其它用途,因此可以通过组件的model选项来避免冲突:

内置指令

带有v-前缀,当表达式值发生变化时,会响应式的将影响作用于 DOM。指令可以接收后面以:表示的参数被指令内部的 arg 属性接收),或者以.开头的修饰符指定该指令以特殊方式绑定)。

1
2
3
4
5
6
7
8
9
10
<p v-if="seen">Hello world!</p>

<!-- 绑定事件 -->
<a v-bind:href="url"></a>

<!-- 绑定属性 -->
<a v-on:click="doSomething">
<!-- .prevent修饰符会告诉v-on指令对于触发的事件调用event.preventDefault() -->
<form v-on:submit.prevent="onSubmit"></form
></a>

Vue 为v-bindv-on这两个常用的指令提供了简写形式:@

1
2
3
4
5
6
7
<!-- v-bind -->
<a v-bind:href="url"></a>
<a :href="url"></a>

<!-- v-on -->
<a v-on:click="doSomething"></a>
<a @click="doSomething"></a>

目前,Vue 在2.4.2版本当中提供了如下的内置指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<html
v-text = "更新元素的textContent"
v-html = "更新元素的innerHTML"
v-show = "根据表达式的true/false,切换HTML元素的display属性"
v-for = "遍历内部的HTML元素"
v-pre = "跳过表达式渲染过程,可以显示原始的Mustache标签"
v-cloak = "保持在HTML元素上直到关联实例结束编译,可以隐藏未编译的Mustache"
v-once = "只渲染元素和组件一次"
></html>

<!-- 根据表达式的true和false来决定是否渲染元素 -->
<div v-if="type === "A"">A</div>
<div v-else-if="type === "B"">B</div>
<div v-else-if="type === "C"">C</div>
<div v-else>Not A/B/C</div>

<!-- 动态地绑定属性或prop到表达式 -->
<p v-bind:attrOrProp
.prop = "被用于绑定DOM属性"
.camel = "将kebab-case特性名转换为camelCase"
.sync = "语法糖,会扩展成一个更新父组件绑定值的v-on监听器"
></p>

<!-- 绑定事件监听器 -->
<button
v-on:eventName
.stop = "调用event.stopPropagation()"
.prevent = "调用event.preventDefault()"
.capture = "添加事件监听器时使用capture模式"
.self = "当事件是从监听器绑定的元素本身触发时才触发回调"
.native = "监听组件根元素的原生事件"-
.once = "只触发一次回调"
.left = "点击鼠标左键触发"
.right = "点击鼠标右键触发"
.middle = "点击鼠标中键触发"
.passive = "以{passive: true}模式添加监听器"
.{keyCode | keyAlias} = "触发特定键触事件"
>
</button>

<!-- 表单控件的响应式绑定 -->
<input
v-model
.lazy = "取代input监听change事件"
.number = "输入字符串转为数字"
.trim = "过滤输入的首尾空格" />

组件

组件可以扩展 HTML 元素功能,并且封装可重用代码。可以通过Vue.component( id, [definition] )注册或者获取全局组件。

1
2
3
4
5
6
7
8
// 注册组件,传入一个扩展过的构造器
Vue.component("my-component", Vue.extend({ ... }))

// 注册组件,传入一个option对象(会自动调用Vue.extend)
Vue.component("my-component", { ... })

// 获取注册的组件(始终返回构造器)
var MyComponent = Vue.component("my-component")

下面代码创建了一个 Vue 实例,并将自定义组件my-component挂载至 HTML 当中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
// 注册自定义组件
Vue.component("my-component", {
template: "<div>A custom component!</div>"
});

// 创建Vue根实例
new Vue({
el: "#example"
});
</script>

<!-- 原始模板 -->
<div id="example">
<my-component></my-component>
</div>

<!-- 渲染结果 -->
<div id="example">
<div>A custom component!</div>
</div>
  • is 属性

浏览器解析完 HTML 之后才会渲染 Vue 表达式,但是诸如<ul> <ol> <table> <select>限制了可以被包裹的 HTML 元素,而<option>只能出现在某些 HTML 元素内部,造成 Vue 表达式可能不会被正确的渲染。因此,Vue 提供is作为属性别名来解决该问题。

1
2
3
4
5
6
7
8
9
<!-- 不正确的方式 -->
<table>
<my-row>...</my-row>
</table>

<!-- 使用is的正确方式 -->
<table>
<tr is="my-row"></tr>
</table>
  • data 必须是函数

Vue.component()传入的 data 属性不能是对象,而必须是函数。这样做的目的是避免组件在相同模板的多个位置被复用时,仅仅返回对象会造成组件间的数据被相互污染,而通过函数每次都返回全新的 data 对象能完美的规避这个问题。

1
2
3
4
5
6
7
8
9
Vue.component("simple-counter", {
template: "<button v-on:click="counter += 1">{{ counter }}</button>",
data: function () {
return {
a: "",
b: ""
}
}
});
  • 父子组件之间的通信

父组件通过props向下传递数据给子组件,子组件通过events给父组件发送消息,即props down, events up

props

虽然每个组件的作用域都是独立的,但是可以通过props属性向子组件传递数据,这是一种单向数据流的体现形式。

1
2
3
4
5
6
Vue.component("child", {
// 声明props
props: ["message"],
// 和data属性一样,prop也可以在vm通过this.message进行引用
template: "<span>{{ message }}</span>"
});

不要在子组件内部修改 props,这样会导致后台报错。

命名方式转换

因为 HTML 并不区分大小写,所以 kebab-case(驼峰)风格命名的 props,在组件中会以 camelCased(短横线隔开)风格被接收。

1
2
3
4
5
6
7
8
9
10
<!-- camelCase in JavaScript -->
<script>
Vue.component("child", {
props: ["myMessage"],
template: "<span>{{ myMessage }}</span>"
})
<script>

<!-- kebab-case in HTML -->
<child my-message="hello!"></child>

动态 props

可以通过v-bind指令,响应式的绑定父组件数据到子组件的 props。当父组件数据变化时,该变化也会传导至子组件。

1
2
3
4
5
<div>
<input v-model="parentMsg" />
<br />
<child v-bind:my-message="parentMsg"></child>
</div>

使用v-bind可以让其参数值能够以 JavaScript 表达式的方式被解析,否则所有传入的 props 都会被子组件认为是字符串类型。

1
2
3
4
<!-- 传递的是字符串"1" -->
<comp some-prop="1"></comp>
<!-- 传递实际的 number -->
<comp v-bind:some-prop="1"></comp>

验证 props

可以为组件的 props 指定验证规则,如果传入数据不符合要求,Vue 会发出相应警告,这样可以有效提高组件的健壮性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Vue.component("example", {
props: {
// 基础类型检测
propA: Number,
// 多种类型
propB: [String, Number],
// 必传且是字符串
propC: {
type: String,
required: true
},
// 数字,有默认值
propD: {
type: Number,
default: 100
},
// 数组或对象的默认值由1个工厂函数返回
propE: {
type: Object,
default: function() {
return { message: "hello" };
}
},
// 自定义验证函数
propF: {
validator: function(value) {
return value > 10;
}
}
}
});

props会在组件实例创建之前进行校验。

组件的非 props 属性

组件可以接收任意传入的属性,这些属性都会被添加到组件 HTML 模板的根元素上(无论有没有在 props 中定义)。

1
2
3
4
5
6
7
8
9
10
<!-- 带有属性的自定义组件 -->
<bs-date-input data-3d-date-picker="true" class="date-picker-theme-dark">
</bs-date-input>

<!-- 渲染出来的组件,class属性被合并 -->
<input
type="date"
data-3d-date-picker="true"
class="form-control date-picker-theme-dark"
/>

父组件传递给子组件的属性可能会覆盖子组件本身的属性,因而会对子组件造成破坏和污染。

事件

子组件可以通过 Vue 的自定义事件与父组件进行通信。

每个 Vue 实例都实现了如下 API,但是并不能直接通过$on 监听子组件冒泡的事件,而必须使用 v-on 指令。

  1. $on(eventName) 监听事件
  2. $emit(eventName) 触发事件

$on$emit并不是addEventListenerdispatchEvent的别名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<div id="counter-event-example">
<p>{{ total }}</p>
<button-counter v-on:increment="incrementTotal"></button-counter>
<button-counter v-on:increment="incrementTotal"></button-counter>
</div>

<script>
Vue.component("button-counter", {
template: "<button v-on:click="incrementCounter">{{ counter }}</button>",
data: function () {
return {
counter: 0
}
},
methods: {
// 子组件事件
incrementCounter: function () {
this.counter += 1
this.$emit("increment") //向父组件冒泡事件
}
},
})

new Vue({
el: "#counter-event-example",
data: {
total: 0
},
methods: {
// 父组件事件
incrementTotal: function () {
this.total += 1
}
}
})
</script>
  • .native修饰符

开发人员也可以在组件的根元素上监听原生事件,这个时候需要借助到.native修饰符。

1
<my-component v-on:click.native="doTheThing"></my-component>
  • .sync修饰符

Vue 中的props本质是不能进行响应式绑定的,以防止破坏单向数据流,造成多个子组件对父组件状态形成污染。但是生产环境下,props响应式绑定的需求是切实存在的。因此,Vue 将.sync修饰符封装为糖衣语法,父组件在子组件的 props 使用该修饰符后,父组件会为 props 自动绑定v-on事件,子组件则在监听到 props 变化时向父组件$emit更新事件,从而让父组件的props能够与子组件进行同步。

1
2
3
4
5
<!-- 使用.sync修饰符 -->
<comp :foo.sync="bar"></comp>

<!-- 被自动扩展为如下形式,该组件的子组件会通过this.$emit("update:foo", newValue)显式触发更新事件 -->
<comp :foo="bar" @update:foo="val => bar = val"></comp>
  • 平行组件通信

非父子关系的组件进行通信时,可以使用一个的 Vue 实例作为中央事件总线

1
2
3
4
5
6
7
var bus = new Vue()
// 触发组件A中的事件
bus.$emit("id-selected", 1)
// 在组件B监听事件
bus.$on("id-selected", function (id) {
... ... ...
})

更好的方式是借助 VueX 或者 Redux 之类的 flux 状态管理库。

slot

可以将父组件的内容混入到子组件的模板当中,此时可以在子组件中使用<slot>作为父组件内容的插槽。

父组件模板的内容在父组件作用域内编译,子组件模板的内容在子组件作用域内编译。

匿名插槽

当子组件只有一个没有属性的<slot>时,父组件全部内容片段将插入到插槽所在的 DOM 位置,并替换插槽标签本身。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- 子组件my-component的模板 -->
<div>
<h2>Child</h2>
<slot>
父组件没有需要插入的内容时显示
</slot>
</div>

<!-- 父组件模板中使用my-component -->
<div>
<h1>Parent</h1>
<child>
<p>Content 1</p>
<p>Content 2</p>
</child>
</div>

<!-- 渲染结果 -->
<div>
<h1>Parent</h1>
<div>
<h2>Child</h2>
<p>Content 1</p>
<p>Content 2</p>
</div>
</div>

<slot>标签中的内容会在子组件作用域内编译,并在父组件没有需要插入的内容时才会显示。

具名插槽

可以通过<slot>元素的name属性来配置如何分发内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- 子组件 -->
<div id="app">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>

<!-- 父组件 -->
<app>
<div slot="header">Header</div>
<p>Content 1</p>
<p>Content 2</p>
<div slot="footer">Footer</div>
</app>

<!-- 渲染结果 -->
<div id="app">
<header>
<div>Header</div>
</header>
<main>
<p>Content 1</p>
<p>Content 2</p>
</main>
<footer>
<p>Footer</p>
</footer>
</div>

匿名 slot 会作为没有匹配内容的父组件片段的插槽。

作用域插槽

子组件通过props传递数据给<slot>插槽,父组件使用带有scope属性的<template>来表示表示当前作用域插槽的模板,scope值对应的变量会接收子组件传递来的 props 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- 子组件通过props传递数据给插槽 -->
<div class="child">
<slot text="hello from child"></slot>
</div>

<!-- 父组件使用带有scope属性的<template> -->
<div class="parent">
<child>
<template scope="props">
<span>hello from parent</span>
<span>{{ props.text }}</span>
</template>
</child>
</div>

<!-- 渲染结果 -->
<div class="parent">
<div class="child">
<span>hello from parent</span>
<span>hello from child</span>
</div>
</div>

函数化组件

即无状态(没有 data)无实例(没有 this 上下文)的组件,渲染开销较小,且不会出现在Vue devtools当中。

1
2
3
4
5
6
7
Vue.component("my-component", {
functional: true,
// 通过提供context参数为没有实例的函数组件提供上下文信息
render: function(createElement, context) {},
// Props可选
props: {}
});

动态组件

使用<component>元素并动态绑定其is属性,可以让多个组件使用相同的 Vue 对象挂载点,并实现动态切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
var vm = new Vue({
el: "#example",
data: {
currentView: "home"
},
components: {
home: {
/* ... */
},
posts: {
/* ... */
},
archive: {
/* ... */
}
}
});
</script>

<component v-bind:is="currentView">
<!-- 组件在vm.currentview变化时改变! -->
</component>

如果需要将切换的组件保持在内存,保留其状态并且避免重新渲染,可以使用 Vue 内置的keep-alive指令。

1
2
3
4
5
<keep-alive>
<component :is="currentView">
<!-- 非活动组件将被缓存! -->
</component>
</keep-alive>

组件异步加载

Vue 允许将组件定义为工厂函数,从而异步的解析组件定义。Vue 只会在组件渲染时才触发工厂函数,并将结果缓存起来用于后续渲染。定义组件的工厂函数将会接收 resolve(接收到从服务器下载的 Vue 组件 options 时被调用)和 reject(当远程 Vue 组件 options 加载失败时调用)回调函数作为参数。

1
2
3
4
5
6
7
8
Vue.component("async-example", function(resolve, reject) {
setTimeout(function() {
// 将组件定义传递到resolve回调函数当中
resolve({
template: "<div>I am async!</div>"
});
}, 1000);
});

可以结合 Webpack 提供的代码切割功能,将 Vue 组件的 options 对象提取到单独 JavaScript 文件,从而实现异步的按需加载。

1
2
3
4
5
6
7
// 使用webpack的require()来进行异步代码块切割
Vue.component("async-webpack-example", function(resolve) {
require(["./my-async-component"], resolve);
});

// 使用webpack的import()来进行异步代码块切割
Vue.component("async-webpack-example", () => import("./my-async-component"));

从 Vue 2.3.0 版本开始,可以通过下面的方式来定义一个异步组件。

1
2
3
4
5
6
7
const AsyncWebpackExample = () => ({
component: import("./MyComp.vue"), // 需要加载的组件
loading: LoadingComp, // loading时渲染的组件
error: ErrorComp, // 出错时渲染的组件
delay: 200, // 渲染loading组件前的等待时间(默认:200ms)
timeout: 3000 // 最长等待时间,超出则渲染error组件(默认:Infinity)
});

在路由组件上使用这种写法,需要使用 vue-router 的 2.4.0 以上版本。

组件的循环引用

循环引用,即两个组件互相引用对方,例如下面代码中tree-foldertree-folder-contents两个组件同时成为了对方的父或子节点,如果使用 Webpack 模块化管理工具requiring/importing组件的时候,会报出Failed to mount component: template or render function not defined.错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<p>
<span>{{ folder.name }}</span>
<tree-folder-contents :children="folder.children" />
</p>
</template>

<template>
<ul>
<li v-for="child in children">
<tree-folder v-if="child.children" :folder="child" />
<span v-else>{{ child.name }}</span>
</li>
</ul>
</template>

因为tree-foldertree-folder-contents相互引用对方之后,无法确定组件加载的先后顺序陷入死循环,所以需要事先指明 webpack 组件加载的优先级。解决上面例子中 Vue 组件循环引用的问题,可以在tree-folder组件的beforeCreate()生命周期函数内注册引发问题的tree-folder-contents组件。

1
2
3
beforeCreate: function () {
this.$options.components.TreeFolderContents = require("./tree-folder-contents.vue").default
}

组件命名约定

JavaScript 中命名组件组件时可以使用kebab-casecamelCasePascalCase,但 HTML 模板中只能使用kebab-case格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
<kebab-cased-component></kebab-cased-component>
<camel-cased-component></camel-cased-component>
<pascal-cased-component></pascal-cased-component>
<!-- 也可以通过自关闭方式使用组件 -->
<kebab-cased-component />

<script>
components: {
"kebab-cased-component": {},
"camelCasedComponent": {},
"PascalCasedComponent": {}
}
</script>

推荐 JavaScript 中通过PascalCase方式声明组件, HTML 中则通过kebab-case方式使用组件。

组件递归

当局部注册的 Vue 组件递归调用自身时,需要在创建组件时添加name选项,全局注册的组件则可以省略该属性,因为 Vue 会自动进行添加。

1
2
3
4
5
6
7
8
9
10
11
12
// 局部注册
new Vue({
el: "#my-component",
name: "my-component",
template: "<div><my-component></my-component></div>"
});

// 全局注册
Vue.component("my-component", {
// name: "my-component", 可以省略name属性
template: "<div><my-component></my-component></div>"
});

组件递归出现死循环时,会提示max stack size exceeded错误,所以需要确保递归操作都拥有一个终止条件(比如使用 v-if 并返回 false)。

组件模板

  • 可以在 Vue 组件上使用inline-template属性,组件会将内嵌的 HTML 内容作为组件本身的模板进行渲染,而非将其作为slot分发的内容。
1
2
3
4
5
6
<my-component inline-template>
<div>
<p>These are compiled as the component"s own template.</p>
<p>Not parent"s transclusion content.</p>
</div>
</my-component>
  • 也可以通过在<script>标签内使用type="text/x-template"id属性来定义一个内嵌模板。
1
2
3
4
5
6
7
8
9
<script type="text/x-template" id="hello-world-template">
<p>Hello hello hello</p>
</script>

<script>
Vue.component("hello-world", {
template: "#hello-world-template"
});
</script>

Vuex 状态管理

Vuex 是专门为 Vue 应用程序提供的状态管理模式,每个 Vuex 应用的核心是store仓库),即装载应用程序state状态)的容器,每个应用通常只拥有一个store实例。

Vuex 的state是响应式的,即store中的state发生变化时,相应组件也会进行更新,修改store当中state的唯一途径是提交mutations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment(state) {
state.count++;
}
}
});

store.commit("increment"); // 通过store.state来获取状态对象

console.log(store.state.count); // 通过store.commit()改变状态

State

store当中获取state的最简单办法是在计算属性中返回指定的state,每当state发生改变的时候都会重新执行计算属性,并且更新关联的 DOM。

1
2
3
4
5
6
7
8
const Counter = {
template: `<div>{{ count }}</div>`,
computed: {
count() {
return store.state.count;
}
}
};

Vuex 提供store选项,将state从根组件注入到每个子组件中,从而避免频繁import store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 父组件中注册store属性
const app = new Vue({
el: "#app",
store: store,
components: { Counter },
template: `
<div class="app">
<counter></counter>
</div>`
});

// 子组件,store会注入到子组件,子组件可通过this.$store进行访问
const Counter = {
template: `<div>{{ count }}</div>`,
computed: {
count() {
return this.$store.state.count;
}
}
};

Vuex 提供mapState()辅助函数,避免使用多个state的场景下,多次去声明计算属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在单独构建的版本中辅助函数为 Vuex.mapState
import { mapState } from "vuex";

export default {
computed: mapState({
count: state => state.count,
// 传递字符串参数"count"等同于`state => state.count`
countAlias: "count",
countPlusLocalState(state) {
return state.count + this.localCount;
}
})
};

// 当计算属性名称与state子节点名称相同时,可以向mapState传递一个字符串数组
computed: mapState([
"count" // 映射this.count到store.state.count
]);

mapState()函数返回一个包含有state相关计算属性的对象,这里可以通过 ES6 的对象展开运算符...将该对象与 Vue 组件本身的computed属性进行合并。

1
2
3
4
computed: {
localComputed () {},
...mapState({})
}

Vuex 允许在store中定义getters可视为 store 的计算属性),getters的返回值会根据其依赖被缓存,只有当依赖值发生了改变才会被重新计算。该方法接收state作为第 1 个参数,其它getters作为第 2 个参数。可以直接在store上调用getters来获取指定的计算值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const store = new Vuex.Store({
state: {
todos: [
{ id: 1, text: "...", done: true },
{ id: 2, text: "...", done: false }
]
},
getters: {
doneTodos: (state, getters) => {
return state.todos.filter(todo => todo.done);
}
}
});

// 获取doneTodos = [{ id: 1, text: "...", done: true }]
store.getters.doneTodos;

这样就可以方便的根据store中现有的state派生出新的state,从而避免在多个组件中复用时造成代码冗余。

1
2
3
4
5
computed: {
doneTodosCount () {
return this.$store.getters.doneTodos // 现在可以方便的在Vue组件使用store中定义的doneTodos
}
}

Vuex 提供的mapGetters()辅助函数将store中的getters映射到局部计算属性。

1
2
3
4
5
6
7
8
9
10
11
import { mapGetters } from "vuex";

export default {
computed: {
// 使用对象展开运算符将getters混入computed计算属性
...mapGetters([
"doneTodosCount",
(doneCount: "doneTodosCount") // 映射store.getters.doneTodosCount到别名this.doneCount
])
}
};

Mutations

修改 store 中的 state 的唯一方法是提交 mutation([mjuː"teɪʃ(ə)n] n.变化),mutations 类似于自定义事件,拥有一个字符串事件类型和一个回调函数(接收 state 作为参数,是对 state 进行修改的位置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const store = new Vuex.Store({
state: {
count: 1
},
mutations: {
// 触发类型为increment的mutation时被调用
increment(state) {
state.count++; // 变更状态
}
}
});

// 触发mutation
store.commit("increment");

可以通过 store 的commit()方法触发指定的 mutations,也可以通过store.commit()向 mutation 传递参数。

1
2
3
4
5
6
7
8
9
10
11
12
// commit()
store.commit({
type: "increment",
amount: 10
})

// store
mutations: {
increment (state, payload) {
state.count += payload.amount
}
}

mutation 事件类型建议使用常量,并且将这些常量放置在单独文件,便于管理和防止重复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mutation-types.js
export const SOME_MUTATION = "SOME_MUTATION"

// store.js
import Vuex from "vuex"
import { SOME_MUTATION } from "./mutation-types"

const store = new Vuex.Store({
state: { ... },
mutations: {
// 可以通过ES6的计算属性命名特性去使用常量作为函数名
[SOME_MUTATION] (state) {
// mutate state
}
}
})

mutation()必须是同步函数,因为 devtool 无法追踪回调函数中对state进行的异步修改。

Vue 组件可以使用this.$store.commit("xxx")提交 mutation,或者使用mapMutations()将 Vue 组件中的methods映射为store.commit调用(需要在根节点注入store)。

1
2
3
4
5
6
7
8
9
10
11
12
import { mapMutations } from "vuex";

export default {
methods: {
...mapMutations([
"increment" // 映射this.increment()为this.$store.commit("increment")
]),
...mapMutations({
add: "increment" // 映射this.add()为this.$store.commit("increment")
})
}
};

Actions

Action 用来提交 mutation,且 Action 中可以包含异步操作。Action 函数接受一个与 store 实例具有相同方法和属性的context对象,因此可以通过调用context.commit提交一个 mutation,或者通过context.statecontext.getters来获取 state、getters。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment(state) {
state.count++;
}
},
actions: {
increment(context) {
context.commit("increment");
}
}
});

生产环境下,可以通过 ES6 的解构参数来简化代码。

1
2
3
4
5
6
actions: {
// 直接向action传递commit方法
increment ({ commit }) {
commit("increment")
}
}

Action 通过store.dispatch()方法进行分发,mutation当中只能进行同步操作,而action内部可以进行异步的操作。下面是一个购物车的例子,代码中分发了多个 mutations,并进行了异步 API 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
actions: {
checkout ({ commit, state }, products) {

const savedCartItems = [...state.cart.added] // 把当前购物车的物品备份起来
commit(types.CHECKOUT_REQUEST) // 发出结账请求,然后清空购物车
// 购物Promise分别接收成功和失败的回调
shop.buyProducts(
products,
() => commit(types.CHECKOUT_SUCCESS), // 成功操作
() => commit(types.CHECKOUT_FAILURE, savedCartItems) // 失败操作
)
}
}

组件中可以使用this.$store.dispatch("xxx")分发 action,或者使用mapActions()将组件的methods映射为store.dispatch需要在根节点注入store)。

1
2
3
4
5
6
7
8
9
10
11
12
import { mapActions } from "vuex";

export default {
methods: {
...mapActions([
"increment" // 映射this.increment()为this.$store.dispatch("increment")
]),
...mapActions({
add: "increment" // 映射this.add()为this.$store.dispatch("increment")
})
}
};

store.dispatch可以处理action回调函数当中返回的Promise,并且store.dispatch本身仍然返回一个Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
actions: {
// 定义一个返回Promise对象的actionA
actionA ({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit("someMutation") // 触发mutation
resolve()
}, 1000)
})
},
// 也可以在actionB中分发actionA
actionB ({ dispatch, commit }) {
return dispatch("actionA").then(() => {
commit("someOtherMutation") // 触发另外一个mutation
})
}
}

// 现在可以分发actionA
store.dispatch("actionA").then(() => {
... ... ...
})

可以体验通过 ES7 的异步处理特性async/await来组合 action。

1
2
3
4
5
6
7
8
9
actions: {
async actionA ({ commit }) {
commit("gotData", await getData())
},
async actionB ({ dispatch, commit }) {
await dispatch("actionA") //等待actionA完成
commit("gotOtherData", await getOtherData())
}
}

Module

整个应用使用单一状态树的情况下,所有 state 都会集中到一个 store 对象,因此 store 可能变得非常臃肿。因此,Vuex 允许将 store 切割成模块(module),每个模块拥有自己的statemutationactiongetter、甚至是嵌套的子模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const moduleA = {
state: {},
mutations: {},
actions: {},
getters: {}
};

const moduleB = {
state: {},
mutations: {},
actions: {}
};

const store = new Vuex.Store({
modules: {
a: moduleA,
b: moduleB
}
});

store.state.a; // moduleA的状态
store.state.b; // moduleB的状态

module 内部的mutations()getters()接收的第 1 个参数是模块的局部状态对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
const moduleA = {
state: { count: 0 },
mutations: {
increment(state) {
state.count++; // 这里的state是模块的局部状态
}
},
getters: {
doubleCount(state) {
return state.count * 2;
}
}
};

模块内部 action 当中,可以通过context.state获取局部状态,以及context.rootState获取全局状态。

1
2
3
4
5
6
7
8
9
10
const moduleA = {
// ...
actions: {
incrementIfOddOnRootSum({ state, commit, rootState }) {
if ((state.count + rootState.count) % 2 === 1) {
commit("increment");
}
}
}
};

模块内部的getters()方法,可以通过其第 3 个参数接收到全局状态。

1
2
3
4
5
6
7
const moduleA = {
getters: {
sumWithRootCount(state, getters, rootState) {
return state.count + rootState.count;
}
}
};

严格模式

严格模式下,如果 state 变化不是由mutation()函数引起,将会抛出错误。只需要在创建store的时候传入strict: true即可开启严格模式。

1
2
3
const store = new Vuex.Store({
strict: true
});

不要在生产环境下启用严格模式,因为它会深度检测不合法的 state 变化,从而造成不必要的性能损失,我们可以通过在构建工具中增加如下判断避免这种情况。

1
2
3
const store = new Vuex.Store({
strict: process.env.NODE_ENV !== "production"
});

严格模式下,在属于 Vuex 的 state 上使用 v-model 指令会抛出错误,此时需要手动绑定 value 并监听 input、change 事件,并在事件回调中手动提交 action。另外一种实现方式是直接重写计算属性的 get 和 set 方法。

总结

  1. 应用层级的状态应该集中到单个 store 对象中。
  2. 提交mutation是更改状态的唯一方法,并且这个过程是同步的。
  3. 异步逻辑都应该封装到action里面。

Webpack Vue Loader

vue-loader是由 Vue 开源社区提供的 Webpack 加载器,用来将.vue后缀的单文件组件转换为 JavaScript 模块,每个.vue单文件组件可以包括以下部分:

  1. 一个<template>
  2. 一个<script>
  3. 多个<style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>只能有1个</template>

<script>
只能有1个;
</script>

<style>
可以有多个
</style>
<style>
可以有多个
</style>
<style>
可以有多个
</style>

CSS 作用域

.vue单文件组件的<style>标签上添加scoped属性,可以让该<style>标签中的样式只作用于当前组件。使用 scoped 时,样式选择器尽量使用 class 或者 id,以提升页面渲染性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 单文件组件定义 -->
<style scoped>
.example {
color: red;
}
</style>

<template>
<div class="example">Hank</div>
</template>

<!-- 转换结果 -->
<style>
.example[data-v-f3f3eg9] {
color: blue;
}
</style>

<template>
<div class="example" data-v-f3f3eg9>Hank</div>
</template>

可以在一个组件中同时使用带scoped属性和不带该属性的<style/>,分别用来定义组件私有样式和全局样式。

CSS 模块化

在单文件组件.vue<style>标签上添加module属性即可打开 CSS 模块化特性。CSS Modules用于模块化组合 CSS,vue-loader已经集成了 CSS 模块化特性。

1
2
3
4
5
6
7
8
<style module>
.red {
color: red;
}
.bold {
font-weight: bold;
}
</style>

CSS 模块会向 Vue 组件中注入名为$style计算属性,从而实现在组件的<template/>中使用动态的class属性进行绑定。

1
2
3
4
5
<template>
<p :class="$style.red">
This should be red
</p>
</template>

动画

Vue 在插入、更新、移除DOM的时候,提供了如下几种方式去展现进入(entering)和离开(leaving)的过渡效果。

  1. 在 CSS 过渡和动画中应用 class。
  2. 钩子过渡函数中直接操作 DOM。
  3. 使用 CSS、JavaScript 动画库,如Animate.cssVelocity.js

transition 组件

Vue 提供了内置组件<transition/>来为 HTML 元素、Vue 组件添加过渡动画效果,可以在条件展示使用v-ifv-show)、动态组件展示组件根节点的情况下进行渲染。<transition/>主要用来处理单个节点,或者同时渲染多个节点当中的一个。

自动切换的 class 类名

在组件或 HTML 进入(entering)和离开(leaving)的过渡效果当中,Vue 将会自动切换并应用下图中的六种 class 类名。

可以使用<transition/>name属性来自动生成过渡 class 类名,例如下面例子中的name: "fade"将自动拓展为.fade-enter.fade-enter-active等,name属性缺省的情况下默认类名为v

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<div id="demo">
<button v-on:click="show = !show">Toggle</button>
<transition name="fade">
<p v-if="show">hello</p>
</transition>
</div>

<script>
new Vue({
el: "#demo",
data: {
show: true
}
});
</script>

<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

自定义 CSS 类名

结合Animate.css使用时,可以在<transition/>当中通过以下属性自定义 class 类名。

1
2
3
4
5
6
7
8
9
<transition
enter-class="animated"
enter-active-class="animated"
enter-to-class="animated"
leave-class="animated"
leave-active-class="animated"
leave-to-class="animated"
>
</transition>

自定义 JavaScript 钩子

结合Velocity.js使用时,通过 v-on 在属性中设置钩子函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<transition
v-on:before-enter="beforeEnter"
v-on:enter="enter"
v-on:after-enter="afterEnter"
v-on:enter-cancelled="enterCancelled"
v-on:before-leave="beforeLeave"
v-on:leave="leave"
v-on:after-leave="afterLeave"
v-on:leave-cancelled="leaveCancelled"
>
</transition>

<script>
// ...
methods: {
beforeEnter: function (el) {},
enter: function (el, done) { done() },
afterEnter: function (el) {},
enterCancelled: function (el) {},
beforeLeave: function (el) {},
leave: function (el, done) { done() },
afterLeave: function (el) {},
leaveCancelled: function (el) {} // 仅用于v-show
}
</script>

显式设置过渡持续时间

可以使用<transition>上的duration属性设置一个以毫秒为单位的显式过渡持续时间。

1
2
3
4
<transition :duration="1000"> Hank </transition>

<!-- 可以分别定制进入、移出的持续时间 -->
<transition :duration="{ enter: 500, leave: 800 }"> Hank </transition>

组件首次渲染时的过渡

通过<transition>上的appear属性设置组件节点首次被渲染时的过渡动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 自定义CSS类名 -->
<transition
appear
appear-class="custom-appear-class"
appear-to-class="custom-appear-to-class"
appear-active-class="custom-appear-active-class"
>
</transition>

<!-- 自定义JavaScript钩子 -->
<transition
appear
v-on:before-appear="customBeforeAppearHook"
v-on:appear="customAppearHook"
v-on:after-appear="customAfterAppearHook"
v-on:appear-cancelled="customAppearCancelledHook"
>
</transition>

HTML 元素的过渡效果

Vue 组件的 key 属性

key 属性主要用在 Vue 虚拟 DOM 算法中去区分新旧 VNodes,不显式使用key的时候,Vue 会使用性能最优的自动比较算法。显式的使用key,则会基于key的变化重新排列元素顺序,并移除不存在key的元素。具有相同父元素的子元素必须有独特的key,因为重复的key会造成渲染错误。

1
2
3
4
<ul>
<!-- 最常见的用法是在使用v-for的时候 -->
<li v-for="item in items" :key="item.id">...</li>
</ul>
元素的的交替过渡

可以通过 Vue 提供的v-ifv-else属性来实现多组件的交替过渡,最常见的过渡效果是一个列表以及描述列表为空时的消息。

1
2
3
4
5
6
<transition>
<table v-if="items.length > 0">
<!-- ... -->
</table>
<p v-else>Sorry, no items found.</p>
</transition>

Vue 中具有相同名称的元素切换时,需要通过关键字key作为标记进行区分,否则 Vue 出于效率的考虑只会替换相同标签内的内容,因此为<transition>组件中的同名元素设置key是一个最佳实践

1
2
3
4
<transition>
<button v-if="isEditing" key="save">Save</button>
<button v-else key="edit">Edit</button>
</transition>

一些场景中,可以通过给相同 HTML 元素的key属性设置不同的状态来代替冗长的v-ifv-else

1
2
3
4
5
6
7
8
9
10
<!-- 通过v-if和v-else来实现 -->
<transition>
<button v-if="isEditing" key="save">Save</button>
<button v-else key="edit">Edit</button>
</transition>

<!-- 设置动态的key属性来实现 -->
<transition>
<button v-bind:key="isEditing">{{ isEditing ? "Save" : "Edit" }}</button>
</transition>

而对于使用了多个v-if的多元素过渡,也可以通过动态的key属性进行大幅度的简化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- 多个v-if实现的多元素过渡 -->
<transition>
<button v-if="docState === "saved"" key="saved"> Edit </button>
<button v-if="docState === "edited"" key="edited"> Save </button>
<button v-if="docState === "editing"" key="editing"> Cancel </button>
</transition>

<!-- 通过动态key属性可以大幅简化模板代码 -->
<transition>
<button v-bind:key="docState"> {{ buttonMessage }} </button>
</transition>

<script>
...
computed: {
buttonMessage: function () {
switch (this.docState) {
case "saved": return "Edit"
case "edited": return "Save"
case "editing": return "Cancel"
}
}
}
</script>

Vue 组件的过渡效果

多个 Vue 组件之间的过渡不需要使用key属性,只需要使用动态组件即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<transition name="component-fade" mode="out-in">
<component v-bind:is="view"></component>
</transition>

<script>
new Vue({
el: "#transition-components-demo",
data: {
view: "v-a"
},
components: {
"v-a": {
template: "<div>Component A</div>"
},
"v-b": {
template: "<div>Component B</div>"
}
}
})
<script>

<style>
.component-fade-enter-active, .component-fade-leave-active {
transition: opacity .3s ease;
}
.component-fade-enter, .component-fade-leave-to {
opacity: 0;
}
<style>

选择 HTML 元素或 Vue 组件的过渡模式

<transition>的默认进入(enter)和离开(leave)行为同时发生,所以当多个需要切换显示的 HTML 元素或 Vue 组件处于相同位置的时候,这种同时生效的进入和离开过渡不能满足所有需求,Vue 可以通过<transition-gruop>组件的mode属性来选择如下过渡模式。

  • in-out:新元素先进行过渡,完成之后当前显示的元素再过渡离开。
  • out-in:当前显示的元素先进行过渡,完成之后新元素再过渡进入。
1
2
3
4
5
<transition name="fade" mode="out-in">
<button v-if="docState === "saved"" key="saved"> Edit </button>
<button v-if="docState === "edited"" key="edited"> Save </button>
<button v-if="docState === "editing"" key="editing"> Cancel </button>
</transition>

transition-group 组件

<transition-group>用来设置多个 HTML 元素或 Vue 组件的过渡效果,不同于<transition>,该组件默认会被渲染为一个真实的<span>元素,但是开发人员也可以通过<transition-group>组件的tag属性更换为其它合法的 HTML 元素。<transition-group>组件内部的元素必须要提供唯一的key属性值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<div id="list-demo" class="demo">
<button v-on:click="add">Add</button>
<button v-on:click="remove">Remove</button>
<transition-group name="list" tag="p">
<span v-for="item in items" v-bind:key="item" class="list-item">
{{ item }}
</span>
</transition-group>
</div>

<script>
new Vue({
el: "#list-demo",
data: {
items: [1, 2, 3, 4, 5, 6, 7, 8, 9],
nextNum: 10
},
methods: {
randomIndex: function() {
return Math.floor(Math.random() * this.items.length);
},
add: function() {
this.items.splice(this.randomIndex(), 0, this.nextNum++);
},
remove: function() {
this.items.splice(this.randomIndex(), 1);
}
}
});
</script>

<style>
.list-item {
display: inline-block;
margin-right: 10px;
}
.list-enter-active,
.list-leave-active {
transition: all 1s;
}
.list-enter,
.list-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>

<transition-group>实现的列表过渡效果在添加、移除某个 HTML 元素时,相临的其它 HTML 元素会瞬间移动至新位置,这个过程并非平滑的过渡。为解决这个问题,<transition-group>提供v-move特性去覆盖移动过渡期间所使用的 CSS 类名。开启该特性,即可以通过name属性手动设置(下面例子中的name="flip-list".flip-list-move),也可以直接使用move-class属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<div id="flip-list-demo" class="demo">
<button v-on:click="shuffle">Shuffle</button>
<transition-group name="flip-list" tag="ul">
<li v-for="item in items" v-bind:key="item">
{{ item }}
</li>
</transition-group>
</div>

<script>
new Vue({
el: "#flip-list-demo",
data: {
items: [1,2,3,4,5,6,7,8,9]
},
methods: {
shuffle: function () {
this.items = _.shuffle(this.items)
}
}
})
</script>

<style>
.flip-list-move {
transition: transform 1s;
}
<style>

可以通过响应式的绑定<transition><transition-gruop>上的 name 属性,从而能够根据组件自身的状态实现具有动态性的过渡效果。

1
<transition v-bind:name="transitionName"></transition>

Vue 2 技术栈归纳与精粹

http://www.uinio.com/Web/Vue/

作者

Hank

发布于

2016-11-27

更新于

2017-05-03

许可协议