前言
公司目前制作一个H5活动,特别是有一定统一结构的活动,都要码一个重复的轮子。后来接到一个基于模板的活动设计系统的需求,便有了一下的内容。首先会对使用Vue进行开发的一些前期需要的技术储备进行简单介绍。
组件化
需求一到,接就是怎么实现,技术选型自然成为了第一个问题。鉴于目前web前端mvvm框架以及组件化开发方式的流行,决定技术栈采用:vue + vuex + es6 + browserify。
这里首先简单说下web前端组件化开发方式的历程:
最早的组件化结构,代码结构可能如下:
- lib/components/calendar |- calendar.css |- calendar.js |- calendar.html
将同功能的组件文件放到同一目录下,结构清晰、职责明确,视图、样式、脚本的关系显著,也易于单元测试,是独立展示和交互的最小单元。
后来:
在之前基础上对组件进行了生命周期的加工(初始化、获取资源、渲染、更新、销毁等),理顺了组件的各个阶段,有助于对组件实现(从初始化到销毁)的理解。并且借助于组件各个阶段的钩子可以对组件有更好的利用和扩展。对外暴露接口,数据绑定或者说数据仓库的加入,各种xMD模块加载器的出现,也让这种这种开发方式上升了一个层级。ExtJs、YUI等都是这方面的专家。
再后来:
有了之前发展,进步是很大的,但依然不够。组件的可复用性(基础样式,基础逻辑,基础属性、可复用的稳定业务逻辑等)、组件间通信、全局状态管理、甚至是能否有更好的代码组织方式等依然是问题。Angular、React、Polymer、Vue等mvvm框架和webpack、browserify等构建、预编译工具的出现正试图解决这些问题。
ES6
在正式开始vue之前,因为本项目用到了es6,那么就谈谈大家都关注的EcmaScript6。多余的就不说了,es6经历了多年的苦,终于在2015年下半年定稿,正式名称:EcmaScript2015。每个刚开始接触es6的人应该都有这么一个问题,es6的出现到底是为了什么,或者说它解决了什么。老版本es4/5虽然坑多,就像Brendan Eich评价js一样:"优秀之处并非原创,原创之处并不优秀"。但我们不也是去其槽粕,留其精髓,一路填坑走过了吗?
来直接一点,es6常用的特性有:class类的支持、箭头函数、对象和数组的解构、默认参数、不定参数、对象合并、let与const关键字、for of迭代、字符串模板、对象字面量增强、同名对象字面量缩写、模块化import/export、map、promise、* yeild生成器等。
这里挑出几个常用的简单说下:
首先class:
在没有class的时候,创建类的一种比较标准的方式是将非函数的属性放到构造函数里,函数属性在原型链里添加。类的继承的实现就更为多样:对象冒充、call/apply方式、原型链方式等。es6的class和extends关键字的出现给出了一个统一的规范
class People { constructor (name, age, gender){ this.name = name } sayName (){ return this.name } } class Student extends People { constructor (name, age, gender, skill){ super(name, age, gender) this.skill = skill } saySkill (){ return this.skill } } let tom = new Student('tom', 16, 'male', 'computer') tom.sayName() // => 'tom' tom.saySkill() // => 'computer' tom.__proto__ == Student.prototype // => true Student.__proto__ == People // => true
可以看出虽然是新的规范,但是还是遵守js的原则:对象的__proto__指向它的构造函数(类)的prototype。es6对象字面量的__proto__注入也能快速的实现继承。
在纯Vue组件中,我们不会自己写class,因为Vue是高度封装的,我们只需要给底层的class传入我们的配置对象即可,但了解es6的class也是有必要的。Vue的这点相对react来说,是一个显著不同的地方。
接下来是let:
es6之前js只有函数作用域,let的出现有了块级作用域,也就算是if、else、for这类也有了作用域,块内用let声明的变量外面是访问不到的,在js预解析的时候,是不会被提升到当前函数作用域的前面的。基于该特性,在for迭代的时候,每次迭代都会产生一个块级作用域的独立的迭代变量,让最后的结果就是我们期待的结果。
var arr = []; for (let i = 0; i < 10; i ++){ arr[i] = function (){ return i } } arr[6]() // => 6 //如果用var声明i,无论多少次迭代,外层的i始终被每次迭代的函数内部引用着(闭包),不会被当做垃圾回收,最后的结果都指向同一个i,值为10。 //以往为了避免这个问题,通常会这么做: for (var i = 0; i < 10; i ++){ arr[i] = (function (i){ return function (){ return i } })(i) }
最后讲讲箭头函数:
es6之前的function有一个特点:函数内部的上下文并不是由该函数写在那里决定的,而是由谁调用决定的,谁调用函数内部的this就指向谁。然后我们有些时候并不想让他这样,但又没办法,只能通过先保存this,或者call/apply,或者bind来调整上下文。箭头函数的出现解决了这个宁人苦恼的问题,因为箭头函数内的上下文(this)是由函数写在哪决定的,无论被哪个对象调用,上下文都不会改变。
// 在window上下文中 var obj = { test1 : function (){ window.setTimeout(function (){ console.info(this) }, 100) }, test2 : function (){ window.setTimeout(() => { console.info(this) }, 100) }, test3: () => console.info(this), test4: function (){ console.info(this) }, test5 (){ // 对象增强写法 console.info(this); } } obj.test1() // => Window {} obj.test2() // => obj {} obj.test3() // => Window {} obj.test4() // => obj {} obj.test5() // => obj {}
箭头函数this锁定特性值得细细品味,在目前的Vue或者React应用中有妙用。
用普通函数还是箭头函数并非绝对,箭头函数也不能完全替代普通函数,要用哪个由具体逻辑决定,前提是要先了解他们的区别。
箭头函数还有一个特点就是能够简化return的书写。
var a = function (n){ return n } var b = (n) => n //可以省略return和花括号 var c = n => n //如果只有一个参数,中括号也可以省略 a(1) // => 1 b(1) // => 1 c(1) // => 1
从这几个简单的例子可以看出,es6不仅仅是新增了几颗糖,对之前js的一些不友好的地方的改善才是重点。
Vue
进入正题,
Vue.js(读音 /vjuː/, 类似于 view)是一个构建数据驱动的 web 界面的库。Vue.js 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。
Vue.js 自身不是一个全能框架——它只聚焦于视图层。因此它非常容易学习,非常容易与其它库或已有项目整合。另一方面,在与相关工具和支持库一起使用时,Vue.js 也能完美地驱动复杂的单页应用。
— 文中关于vue的大部分内容引用自vue的官方文档,感谢作者的工作!
响应的数据绑定:
<div id="app"> <p>{{ message }}</p> <input v-model="message"> </div>
new Vue({ el : '#app', data : { message : 'Hello Vue.js!' } })
结果:改变输入框的值,<p>标签的文本也会对应改变。
基本工作原理:<input>输入框的值与vue实例的message属性进行了绑定,<p>标签的文本也与message属性进行了绑定。输入框值的变化会改变message的值,message值的变化会反应到<p>标签的文本上。
Vue.js 的核心是一个响应的数据绑定系统,它让数据与 DOM 保持同步非常简单。在使用 jQuery 手工操作 DOM 时,我们的代码常常是命令式的、重复的与易错的。Vue.js 拥抱数据驱动的视图概念。通俗地讲,它意味着我们在普通 HTML 模板中使用特殊的语法将 DOM “绑定”到底层数据。一旦创建了绑定,DOM 将与数据保持同步。每当修改了数据,DOM 便相应地更新。这样我们应用中的逻辑就几乎都是直接修改数据了,不必与 DOM 更新搅在一起。这让我们的代码更容易撰写、理解与维护。
组件系统:
组件系统是 Vue.js 另一个重要概念,因为它提供了一种抽象,让我们可以用独立可复用的小组件来构建大型应用。如果我们考虑到这点,几乎任意类型的应用的界面都可以抽象为一个组件树:
实际上,一个典型的用 Vue.js 构建的大型应用将形成一个组件树。
你可能已经注意到 Vue.js 组件非常类似于自定义元素——它是 Web 组件规范的一部分。实际上 Vue.js 的组件语法参考了该规范。例如 Vue 组件实现了 Slot API 与 is 特性。但是,有几个关键的不同:
Web 组件规范仍然远未完成,并且没有浏览器实现。相比之下,Vue.js 组件不需要任何补丁,并且在所有支持的浏览器(IE9 及更高版本)之下表现一致。必要时,Vue.js 组件也可以放在原生自定义元素之内。
Vue.js 组件提供了原生自定义元素所不具备的一些重要功能,比如组件间的数据流,自定义事件系统,以及动态的、带特效的组件替换。
组件系统是用 Vue.js 构建大型应用的基础。另外,Vue.js 生态系统也提供了高级工具与多种支持库,它们和 Vue.js 一起构成了一个更加“框架”性的系统。
这里简单介绍下vue最常用也较重要的两块:响应式原理和组件系统。
响应式原理:
Vue.js的数据观测实现原理和Angular有着本质的不同。了解Angular的读者可能知道,Angular的数据观测采用的是脏检查(dirty checking)机制。每一个指令都会有一个对应的用来观测数据的对象,叫做watcher;一个作用域中会有很多个watcher。每当界面需要更新时,Angular会遍历当前作用域里的所有watcher,对它们一一求值,然后和之前保存的旧值进行比较。如果求值的结果变化了,就触发对应的更新,这个过程叫做digest cycle。
脏检查有两个问题:
1.任何数据变动都意味着当前作用域的每一个watcher需要被重新求值,因此当watcher的数量庞大时,应用的性能就不可避免地受到影响,并且很难优化。
2.当数据变动时,框架并不能主动侦测到变化的发生,需要手动触发digest cycle才能触发相应的DOM 更新。Angular通过在DOM事件处理函数中自动触发digest cycle部分规避了这个问题,但还是有很多情况需要用户手动进行触发。
Vue.js采用的则是基于依赖收集的观测机制。从原理上来说,和老牌MVVM框架Knockout是一样的。依赖收集的基本原理是:
1.将原生的数据改造成 “可观察对象”。一个可观察对象可以被取值,也可以被赋值。
2.在watcher的求值过程中,每一个被取值的可观察对象都会将当前的watcher注册为自己的一个订阅者,并成为当前watcher的一个依赖。
3.当一个被依赖的可观察对象被赋值时,它会通知所有订阅自己的watcher重新求值,并触发相应的更新。
4.依赖收集的优点在于可以精确、主动地追踪数据的变化,不存在上述提到的脏检查的两个问题。但传统的依赖收集实现,比如Knockout,通常需要包裹原生数据来制造可观察对象,在取值和赋值时需要采用函数调用的形式,在进行数据操作时写法繁琐,不够直观;同时,对复杂嵌套结构的对象支持也不理想。
Vue.js利用了ES5的Object.defineProperty方法,直接将原生数据对象的属性改造为getter和setter(这是ES5的特性,需要js解释引擎的支持,无法通过各种打shim补丁来实现。这也是为什么Vue不支持IE8及以下版本的原因),在这两个函数内部实现依赖的收集和触发,而且完美支持嵌套的对象结构。对于数组,则通过包裹数组的可变方法(比如push)来监听数组的变化。这使得操作Vue.js的数据和操作原生对象几乎没有差别[注:在添加/删除属性,或是修改数组特定位置元素时,需要调用特定的函数,如obj.$add(key, value)才能触发更新。这是受ES5的语言特性所限。在操作对象类型数据的时候一定要注意这点,否则无法实现响应。
变化检测:
受 ES5 的限制,Vue.js 不能检测到对象属性的添加或删除。因为 Vue.js 在初始化实例时将属性转为 getter/setter,所以属性必须在 data 对象上才能让 Vue.js 转换它,才能让它是响应的。例如:
var data = {a : 1} var vm = new Vue({ data : data }) //vm.a 和 data.a 现在是响应的 vm.b = 2 //vm.b 不是响应的 data.b = 2 //data.b 不是响应的
不过,有办法在实例创建之后添加属性并且让它是响应的。
对于 Vue 实例,可以使用 $set(key, value) 实例方法:
vm.$set('b', 2) //vm.b 和 data.b 现在是响应的
对于普通数据对象,可以使用全局方法 Vue.set(object, key, value):
Vue.set(data, 'c', 3) //vm.c 和 data.c 现在是响应的
有时你想向已有对象上添加一些属性,例如使用 Object.assign() 或 _.extend() 添加属性。但是,添加到对象上的新属性不会触发更新。这时可以创建一个新的对象,包含原对象的属性和新的属性:
// 不使用 Object.assign(this.someObject, {a : 1,b : 2}) this.someObject = Object.assign({}, this.someObject, {a : 1, b : 2})
计算属性的奥秘:
你应该注意到 Vue.js 的计算属性不是简单的 getter。计算属性持续追踪它的响应依赖。在计算一个计算属性时,Vue.js 更新它的依赖列表并缓存结果,只有当其中一个依赖发生了变化,缓存的结果才无效。因此,只要依赖不发生变化,访问计算属性会直接返回缓存的结果,而不是调用 getter。
为什么要缓存呢?假设我们有一个高耗计算属性 A,它要遍历一个巨型数组并做大量的计算。然后,可能有其它的计算属性依赖 A。如果没有缓存,我们将调用 A 的 getter 许多次,超过必要次数。
由于计算属性被缓存了,在访问它时 getter 不总是被调用。考虑下例:
var vm = new Vue({ data : { msg : 'hi' }, computed : { example : function (){ return Date.now() + this.msg } } })
计算属性 example 只有一个依赖: vm.msg 。 Date.now() 不是 响应依赖,因为它跟 Vue 的数据观察系统无关。因而,在访问 vm.example 时将发现时间戳不变,除非 vm.msg 变了。
有时希望 getter 不改变原有的行为,每次访问 vm.example 时都调用 getter。这时可以为指定的计算属性关闭缓存:
computed : { example : { cache : false, get : function (){ return Date.now() + this.msg } } }
现在每次访问 vm.example 时,时间戳都是新的。但是,只是在 JavaScript 中访问是这样的;数据绑定仍是依赖驱动的。如果在模块中这样绑定计算属性 {{example}} ,只有响应依赖发生变化时才更新DOM。
组件系统
组件(Component)是 Vue.js 最强大的功能之一。组件可以扩展 HTML 元素,封装可重用的代码。在较高层面上,组件是自定义元素,Vue.js 的编译器为它添加特殊功能。在有些情况下,组件也可以是原生 HTML 元素的形式,以 is 特性扩展。
1.创建和注册组件:
可以用 Vue.extend() 创建一个组件构造器:
var MyComponent = Vue.extend({ template : '<div>A custom component!</div>' })
要把这个构造器用作组件,需要用 Vue.component(tag, constructor) 注册(这个注册是全局的):
//全局注册组件,tag 为 my-component Vue.component('my-component', MyComponent)
组件在注册之后,便可以在父实例的模块中以自定义元素 <my-component> 的形式使用。要确保在初始化根实例之前注册了组件:
<div id="example"> <my-component></my-component> </div>
最后渲染为:
<div id="example"> <div>A custom component!</div> </div>
当然,可以让组件只能用在其它组件内,用实例选项 components 注册,比如:
var Child = Vue.extend({ /* ... */ }) var Parent = Vue.extend({ template : '...', components : { // <my-component> 只能用在父组件模板内 'my-component': Child } })
这种局部注册的方式也适用于其它资源,比如指令、过滤器和过渡。他们都支持全局和局部组件注册。
前面提到组件是可以被复用的,多个实例可能会共享一个组件构造器,那么请注意一个组件选项的问题:
传入 Vue 构造器的多数选项也可以用在 Vue.extend() 中,不过有两个特例: data 和 el。试想如果我们简单地把一个对象作为 data 选项传给 Vue.extend() :
var data = {a : 1} var MyComponent = Vue.extend({ data : data })
这么做的问题是 MyComponent 所有的实例将共享同一个 data 对象!因为对象是引用传递的,这基本不是我们想要的,因此我们应当使用一个函数作为 data 选项,让这个函数返回一个新对象:
var MyComponent = Vue.extend({ data : function (){ return {a : 1} } })
同理,el 选项用在 Vue.extend() 中时也须是一个函数。
2.使用props传递数据
当一个组件内部还有一个子组件的时候,由于组件实例的作用域是孤立的,这意味着不能并且不应该在子组件的模板内直接引用父组件的数据。这时,父组件可以使用props把数据传给子组件:
“prop” 是组件数据的一个字段,期望从父组件传下来。子组件需要显式地用 props 选项 声明 props:
Vue.component('child', { //camelCase in JavaScript props : ['myMessage'], template : '<span>{{ myMessage }}</span>' })
然后向它传入一个普通字符串:
<child my-message="hello!"></child>
子组件的渲染结果:
由于命名的习惯,请注意camelCase和kebab-case:HTML 特性不区分大小写。名字形式为 camelCase 的 prop 用作特性时,需要转为 kebab-case(短横线隔开)。
根据vue响应的特性,props也可以是动态的:
类似于用 v-bind 绑定 HTML 特性到一个表达式,也可以用 v-bind 绑定动态 Props 到父组件的数据。每当父组件的数据变化时,也会传导给子组件:
<div> <input v-model="parentMsg"> <br/> <child v-bind:my-message="parentMsg"></child> </div>
也可以使用v-bind的缩写语法来简化绑定:
<child :my-message="parentMsg"></child>
渲染结果:
改变输入框的值,子组件的文本会跟着改变
关于props的其他介绍,请参考 :props
3.父子组件的通信
子组件可以用 this.$parent 访问它的父组件。根实例的后代可以用 this.$root 访问它。父组件有一个数组 this.$children ,包含它所有的子元素。
尽管可以访问父链上任意的实例,不过子组件应当避免直接依赖父组件的数据,尽量显式地使用 props 传递数据。另外,在子组件中修改父组件的状态是非常糟糕的做法,因为:
这让父组件与子组件紧密地耦合;
只看父组件,很难理解父组件的状态。因为它可能被任意子组件修改!理想情况下,只有组件自己能修改它的状态。
Vue 实例实现了一个自定义事件接口,用于在组件树中通信。这个事件系统独立于原生 DOM 事件,用法也不同。
每个 Vue 实例都是一个事件触发器:
-
使用 $on() 监听事件;
-
使用 $emit() 在它上面触发事件;
-
使用 $dispatch() 派发事件,事件沿着父链冒泡;
-
使用 $broadcast() 广播事件,事件向下传导给所有的后代。
不同于 DOM 事件,Vue 事件在冒泡过程中第一次触发回调之后自动停止冒泡,除非回调明确返回 true 。
一个简单的例子:
<!-- 子组件模板 --> <template id="child-template"> <input v-model="msg"> <button v-on:click="notify">Dispatch Event</button> </template> <!-- 父组件模板 --> <div id="events-example"> <p>Messages: {{ messages | json }}</p> <child></child> </div>
在子组件的输入框输入值以后,点击按钮,父组件的Messages:[]文本会对应变化
4.再来说说动态组件
多个组件可以使用同一个挂载点,然后动态地在它们之间切换。使用保留的 <component> 元素,动态地绑定到它的 is 特性:
new Vue({ el : 'body', data : { currentView : 'home' }, components : { home : { /* ... */ }, posts : { /* ... */ }, archive : { /* ... */ } } })
<component :is="currentView"> <!-- 组件在 vm.currentview 变化时改变 --> </component>
如果把切换出去的组件保留在内存中,可以保留它的状态或避免重新渲染。为此可以添加一个 keep-alive 指令参数:
<component :is="currentView" keep-alive> <!-- 非活动组件将被缓存 --> </component>
其他动态组件的详细介绍,请参考:动态组件
在创建复杂应用的时候,动态组件或许就显得不那么灵活了,这时可以使用路由,vue-router路由扩展可以看做是动态组件的升级版,可参考:vue-router
5.最后,组件实例的生命周期:
Vue 实例在创建时有一系列初始化步骤——例如,它需要建立数据观察,编译模板,创建必要的数据绑定。在此过程中,它也将调用一些生命周期钩子,给自定义逻辑提供运行机会。例如 created 钩子在实例创建后调用:
var vm = new Vue({ data : { a : 1 }, created : function (){ // this 指向 vm 实例 console.log('a is: ' + this.a) } }) // => "a is: 1"
也有一些其它的钩子,在实例生命周期的不同阶段调用,如 compiled、 ready 、destroyed。钩子的 this 指向调用它的 Vue 实例。一些用户可能会问 Vue.js 是否有“控制器”的概念?答案是,没有。组件的自定义逻辑可以分割在这些钩子中。
声明周期的图示:
组件的简单介绍就到这里。
Vuex
在大型应用中,状态管理常常变得复杂,因为状态分散在许多组件内,在不同的作用域内。以vue来说,当使用vue-router以及组件化开发(.vue)来构建大型单页应用的时候,组件之间状态的数据的传递会很困难,虽然props、dispatch、broadcast等能够进行跨组件的数据传递,但是大量使用它们会使组件之间的耦合程度很高,组件越多,层级越多,维护起来就越复杂。怎么办呢?能否在全局提供一个状态管理构架?
这里得提出一个概念:Flux
Flux是Facebook用来构建用户端的web应用的应用程序体系架构。它通过利用数据的单向流动为React的可复用的视图组件提供了补充。相比于形式化的框架它更像是一个架构思想,不需要太多新的代码你就可以马上使用Flux构建你的应用。
Flux应用主要包括三部分:dispatcher、store和views(React components),千万不要和MVC(model-View-Controller)搞混。Controller在Flux应用中也确实存在,但是是以controller-view的形式。view通常处于应用的顶层,它从stores中获取数据,同时将这些数据传递给它的后代节点。另外,action creators - dispatcher辅助方法 - 一个被用来提供描述应用所有可能存在的改变的语义化的API。把它理解为Flux更新闭环的第四个组成部分可以帮助你更好的理解它。
一句话:Flux就是手动将Action从数据流底层视图中的事件手动绑定到数据顶层的数据流架构。
单向数据流的设计目的:任何UI不能直接对数据有写操作,就是防止同一份数据有多个地方同时在写。相对于直接进行双向绑定,编码稍微会复杂一点,但换来了排错和维护的便捷。
Flux 架构常用于 React 应用中,但它的核心理念也可以适用于 Vue.js 应用。比如 Vuex 就是一个借鉴于 Flux,但是专门为 Vue.js 所设计的状态管理方案。React 生态圈中最流行的 Flux 实现 Redux 也可以通过简单的绑定和 Vue 一起使用。
什么是Vuex
Vuex 是一个专门为 Vue.js 应用所设计的集中式状态管理架构。它借鉴了 Flux 和 Redux 的设计思想,但简化了概念,并且采用了一种为能更好发挥 Vue.js 数据响应机制而专门设计的实现。
为什么需要它?
当你的应用还很简单的时候,你多半并不需要 Vuex。也不建议过早地使用 Vuex。但如果你正在构建一个中型以上规模的 SPA,你很有可能已经需要思考应该如何更好地归纳 Vue 之外,应用的其他组成部分。这就是 Vuex 要大显身手的时刻。
我们在单独使用 Vue.js 的时候,通常会把状态储存在组件的内部。也就是说,每一个组件都拥有当前应用状态的一部分,整个应用的状态是分散在各个角落的。然而我们经常会需要把状态的一部分共享给多个组件。一个常见的解决策略为:使用定制的事件系统,让一个组件把一些状态“发送”到其他组件中。这种模式的问题在于,大型组件树中的事件流会很快变得非常繁杂,并且调试时很难去找出究竟哪错了。
为了更好的解决在大型应用中状态的共用问题,我们需要对组件的 组件本地状态(component local state) 和 应用层级状态(application level state) 进行区分。应用级的状态不属于任何特定的组件,但每一个组件仍然可以监视(Observe)其变化从而响应式地更新 DOM。通过汇总应用的状态管理于一处,我们就不必到处传递事件。因为任何牵扯到一个以上组件的逻辑,都应该写在这里。此外,这样做也能让我们更容易地记录并观察状态的变更(Mutation,原意为突变),甚至可以实现出华丽如时光旅行一般的调试效果。(译注:是时候安利一波 vue-devtools 了)
Vuex 也对如何管理分撒各地的状态增加了一些约束,但仍保留有足够面对真实使用场景的灵活性。
一定需要它吗?
Vuex有这么多好处,但这并不代表我们一定就要在项目中使用它。假如我们的项目是一个管理平台系统,一般无非是列表跳转详情这种搭配,不同列表页面、不同详情页面之间没有什么相互关联或者需要共享的状态,也不会出现某一个需要获取到所有详情页面的状态这种需求。这时候我们是不需要Vuex的,使用它只会增加项目的复杂度。下面将介绍到的活动发布系统,最后创建的时候需要获取到所有组件的数据,这个时候使用Vuex显得十分有必要。如果你都不知道是否需要Vuex,那就不用它。
最简单的store
创建 Vuex store 的过程相当直截了当 - 只要提供一个初始化的 state 对象,以及一些 mutations:
import Vuex from 'vuex' const state = { count : 0 } const mutations = { INCREMENT (state){ state.count ++ } } export default new Vuex.Store({ state, mutations })
现在,你可以通过 store.state 来读取 state 对象,还可以通过 dispatch 某 mutation 的名字来触发这些状态变更:
store.dispatch('INCREMENT') console.log(store.state.count) // -> 1
如果你倾向于对象风格的分发方式,你可以用这种语法:
// 效果同上 store.dispatch({ type : 'INCREMENT' })
再次强调,我们通过分发 mutation 的方式,而非直接改变 store.state ,是因为我们想要更明确地追踪到状态的变化。这个简单的约定能够让你的意图更加明显,这样你在阅读代码的时候能更容易地解读应用内部的状态改变。此外,这样也让我们有机会去实现一些能记录每次状态改变,保存状态快照的调试工具。有了它,我们甚至可以实现如时间穿梭般的调试体验。
Vuex 使用 单一状态树 —— 是的,用一个对象就包含了全部的应用层级状态。至此它便作为一个『唯一数据源(SSOT)』而存在。这也意味着,每个应用将仅仅包含一个 store 实例。单状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。
以上只是一个用来展示 store 究竟是什么的一个极简例子。再谈谈三哥核心概念:State(状态),Mutations(变更) 和 Actions(动作)。
State和Getters
1.安装 Vuex 并且将您的根组件引入 store 实例:
import Vue from 'vue' import Vuex from 'vuex' import store from './store' import MyComponent from './MyComponent' // 关键点,教 Vue 组件如何处理与 Vuex 相关的选项 Vue.use(Vuex) var app = new Vue({ el : '#app', store, // 把 store 对象提供给 “store” 选项,这可以把 store 的实例注入所有的子组件 components : { MyComponent } })
通过在根实例中注册 store
选项,该 store 实例会注入到根组件下的所有子组件中,且子组件能通过 this.$store 访问到。不过事实上,我们几乎不会需要直接引用它。
2.在子组件中,通过在 vuex.getters 选项里定义的 getter 方法来读取状态:
// MyComponent.js
export default { template : '...', data (){ ... }, // 此处为我们从 store 实例中取回状态的位置 vuex : { getters : { // 该 getter 函数将会把仓库的 `store.state.count` 绑定为组件的 `this.count` count : (state) => state.count } } }
请留意 vuex 的这个特殊选项(译注:getters 子对象)。它是我们指定当前组件能从 store 里获取哪些状态信息的地方。它的每个属性名将对应一个 getter 函数。该函数仅接收 store 的整个状态树作为其唯一参数,之后既可以返回状态树的一部分,也可以返回从状态树中求取的计算值。而返回结果,则会依据这个 getter 的属性名添加到组件上,用法与组件自身的计算属性一毛一样。
组件不能直接修改store实例的状态:
请始终记得非常重要的这点,就是:组件永远都不应该直接改变 Vuex store 的状态。因为我们想要让状态的每次改变都很明确且可追踪,Vuex 状态的所有改变都必须在 store 的 mutation handler (变更句柄) 中管理。为了强化该规则,在开启(严格模式(Strict Mode))时,若有 store 的状态在 mutation 句柄外被修改,Vuex 就会报错。现在有了这一规则,我们 Vue 组件的职能就少了很多:他们通过只读的 getter 与 Vuex store 的状态相绑定,组件唯一能影响全局状态的方法就是想办法触发 mutations(我们接下来会谈到)。若有必要,组件仍然能够处理和操作本地状态,但是我们不再在单独的组件中放置任何数据请求或全局状态变更的逻辑。这些操作全部都集中于 Vuex 相关的文件中,这样能让大型应用变得更容易理解和维护。
Mutation
Mutations 本质上是一个事件系统:每个 mutation 都有一个 事件名 (name) 和 一个 回调函数 (handler). 任何一个 Mutation handler 的第一个参数永远为所属 store 的整个 state 对象:
import Vuex from 'vuex' const store = new Vuex.Store({ state : { count : 1 }, mutations : { INCREMENT (state){ // 改变 state state.count ++ } } })
用全部大写命名 mutation 是一个惯例,方便将它和 actions 区分开。
你不能直接调用 mutation handler. 这里传入 Store 构造函数的选项更像是在注册事件回调:当INCREMENT
事件被触发时,调用这个 handler。触发 mutation handler 的方法是 dispatch 一个 mutation 的事件名:
store.dispatch('INCREMENT')
Mutation必须是同步函数:
因为当 mutation 触发的时候,回掉函数还没有被调用,我们不知道什么时候回调函数实际上被调用。任何在回调函数中进行的的状态的改变都是不可追踪的。
Mutation必须遵守Vue的响应系统规则:
1.尽可能在创建 store 时就初始化 state 所需要的所有属性。
2.当添加一个原本不存在的属性时,需要使用 Vue.set(obj, 'newProp', 123) 或者拷贝并替换原本的对象。利用 stage 2 的语言特性 object spread syntax,我们可以使用这样的语法: state.obj = {...state.obj, newProp : 123}
Actions
Actions 是用于分发 mutations 的函数。按照惯例,Vuex actions 的第一个参数是 store 实例,附加上可选的自定义参数。
// 最简单的 action function increment (store){ store.dispatch('INCREMENT') } // 带附加参数的 action // 使用 ES2015 参数解构 function incrementBy ({dispatch}, amount){ dispatch('INCREMENT', amount) }
乍一眼看上去感觉多此一举,我们直接分发 mutations 岂不更方便?实际上并非如此,还记得 mutations 必须同步执行这个限制么?Actions 就不受约束!我们可以在 action 内部执行异步操作,比如执行一个ajax请求数据的操作:
function getData ({dispatch}){ ajax ({ url : "...", data : {...}, success : (data) => { dispatch("SET_DATA", data) } }) }
我们可以这样在组件中调用actions:
// 某组件内部 // 导入actions import {incrementBy} from './actions' const vm = new Vue({ vuex : { getters : { ... }, // state getters actions : { incrementBy } } })
上述代码所做的就是把原生的 incrementBy action 绑定到组件的 store 实例中,暴露给组件一个 vm.increamentBy 实例方法。所有传递给 vm.increamentBy 的参数变量都会排列在 store 变量后面然后一起传递给原生的 action 函数,所以调用 vm.incrementBy(1) 等价于 incrementBy(vm.$store, 1) 。虽然多写了一些代码,但是组件的模板中调用 action 更加省力了:
<button v-on:click="incrementBy(1)">increment by one</button>
通常在大型 App 中,action 应该按不同目的进行 分组 / 模块化 的管理,具体请参考: Actions
下面再谈谈一个重要的东西,数据流:
为了更好地理解 Vuex app 中的数据流,我们来开发一个简单的计数器 app。注意:这个例子仅仅是为了更好地解释概念,在实际情况中并不需要在这种简单的场合使用 Vuex.
// store.js import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) // 应用初始状态 const state = { count : 0 } // 定义所需的 mutations const mutations = { INCREMENT (state){ state.count ++ }, DECREMENT (state){ state.count -- } } // 创建 store 实例 export default new Vuex.Store({ state, mutations })
// actions.js export const increment = ({ dispatch }) => dispatch('INCREMENT') export const decrement = ({ dispatch }) => dispatch('DECREMENT')
<!- temmplate --> <div> Clicked: {{ count }} times <button v-on:click="increment">+</button> <button v-on:click="decrement">-</button> </div>
// 仅需要在根组件中注入 store 实例一次即可 import store from './store' import {increment, decrement} from './actions' const app = new Vue({ el : '#app', store, vuex : { getters: { count: state => state.count }, actions: { increment, decrement } } })
你会注意到组件本身非常简单:它所做的仅仅是绑定到 state、然后在用户输入时调用 actions。
你也会发现整个应用的数据流是单向的,正如 Flux 最初所定义的那样:
1.用户在组件中的输入操作触发 action 调用。
2.Actions 通过分发 mutations 来修改 store 实例的状态。
3.Store 实例的状态变化反过来又通过 getters 被组件获知。
最后:Vuex 并不强制要求所有的状态都必须放在 Vuex store 中 ,如果有些状态你觉得并没有需要对其变化进行追踪,那么你完全可以把它放在 Vuex 外面(比如作为组件的本地状态)。比如公共组件对外的接口,通过props传递数据更为有效。
Vuex的完整介绍请参考:Vuex
vue-devtools
vue-devtools是chrome的一个vue开发插件,可以在chrome商店下载crx扩展包进行安装。提供Components和Vuex预览(state变化跟踪等)功能,有助于开发和调试。
可以看到组件的prop属性、计算属性、vue getter属性等,以及Vuex中的触发的mutation、state 当前的值等我们可能关注的内容都直观地展示了出来。
Vue模块化
对于大型项目,为了更好地管理代码使用模块构建系统非常必要。推荐代码使用 CommonJS 或 ES6 模块,然后使用 Webpack 或 Browserify 打包。
Webpack 和 Browserify 不只是模块打包器。两者都提供了源码转换 API,通过它可以用其它预处理器转换源码。例如,借助 babel-loader 或 babelify 代码可以使用 ES2015/2016 语法。
你可以使用 Webpack + vue-loader 或 Browserify + vueify 构建这些单文件 Vue 组件。
选择哪种构建工具取决于你的经验和需求。Webpack 的功能更强大,如代码分割,将静态资源当作模块,提取组件的 CSS 到单独的一个文件等,不过它的配置相对复杂一点。如果你不需要 Webpack 的那些功能,使用 Browserify 更简单,最快的构建方式是使用官方出品的脚手架工具 vue-cli。参考:vue-cli
活动模板设计系统
这个设计系统只是对活动模板要展示的内容进行设计,具体的样式和交互由活动h5页面根据视觉和交互设计来定夺。活动里面的每一个子项都可以抽象为一个组件,h5展示端拿到每个组件的内容再套上对应组件的样式和交互逻辑,最终就形成了一个h5活动页面。
每一个活动组件对应三个模式组件:
1.标签组件,通过拖动来创建对应类型的组件
2.预览组件,展示当前组件各项的内容
3.编辑组件,用来编辑当前选中的组件的各项内容
完成后大概是这样的,以一个最简单的节标题组件为例:
如上图所示:左侧容器排列着这些常用组件的标签。将活动需要的组件标签拖入预览区域后,会生成对应的预览组件和编辑组件;点击这个预览组件,组件编辑区域会显示对应的编辑组件;在编辑组件中可以对组件各项进行编辑。编辑完成后,通过事先的数据绑定,预览区域对应的组件就会更新视图,显示组件当前的最新内容。
以上就是这个系统的一个大概方案,下面谈谈具体的实现。
首先,从标签区域开始:
标签组件是每个活动组件的开端,也就说每一个标签组件必须有一个属性来标识它代表的是哪一个活动组件。那就先给它们指定类型 type:
节标题 type :'sectionTitle'
投票 type :'vote'
正文 type :'content'
用户 type :'user'
图片 type :'image'
视频 type :'video'
音频 type :'audio'
跳转链接 type :'link'
然后每当我们拖动一个标签组件到预览区域,再根据该标签组件的type生成对应的预览和编辑组件。预览和编辑组件需要确定的无非就是有哪些编辑项,这些编辑项是什么内容。以节标题组件为例,它就只有一个编辑项:节标题的文本。也就是说节标题的预览组件用来显示节标题的文本,编辑组件需要有一个文本域来对节标题文本进行编辑,在模板事先绑定好对应的数据,文本域的数据变化会反应到预览组件的DOM上。
我们需要有一个保存所有组件数据(对象)的容器,可以使用一个数组。
我更喜欢操作一个数组而不是对象的原因:vue对数组的基本方法(push、splice、shift等)都进行了封装,通过这些方法来改变数组的数据,结果都是响应的。而在保持响应的情况下,改变对象的数据要麻烦些,特别是复杂的嵌套对象。如果使用对象可以通过id直接匹配到对应数据,通过数组需要遍历一下。但是有了es6的for of,代码还是很简单,而且也不是在操作DOM,性能影响不大。
//widgetData.js [ {id : "100",type : "vote", ...}, //投票 {id : "101",type : "image", ...}, //图片 {id : "102",type : "video", ...}, //视频 ]
每个组件数据对象的id属性是唯一的,是拖入标签组件时生成的,这个id属性是关联预览组件与对应编辑组件的关键,通过它可以找到每个预览组件对应的编辑组件。为什么不通过type来判断呢?因为每个活动可能有多个相同的组件,比如节标题。通过type没法确定对应关系。
这里我们通过Vuex创建一个store来存储及修改这个数组(官方点的说法就是管理state状态)。按照上面提到的Vuex的数据流规则:UI不允许直接修改数据。在编辑项里面改变某项输入框的值,并不是直接改变了对应组件数据中那一项的值,而是通过DOM事件触发对应的action,action再派发对应的mutaion处理函数来修改state。这种方式可以确保所有对某项组件数据的修改都是通过触发某一个公共的action来完成的,这个action就是进行某项修改的统一和唯一的入口。
当我们知道需要生成什么预览和编辑组件的时候,并放进组件数据容器的时候,我们就必须知道这个组件到底有哪些编辑项(除了组件类型外,我们放入的这个组件数据对象还需要哪些属性),这时候我们就需要一个map,来管理组件type和组件编辑项的关系,以活动的投票组件为例:
根据需求,投票组件需要有以下编辑项:
1.投票的标题
2.投票项,每项要有一个名称,后续每项可能还会有其他属性(类似正确选项的标记等)
//typeDataMap.js export default { vote : { type : "vote", title : "投票标题文本", items : [ {name : "投票项1"}, //每个投票项 {name : "投票项2"}, {name : "投票项3"} ] } }
只要知道是什么类型,通过 typeData[type] 就能获取到组件数据并存入组件数据容器了。由于我们在预览组件和编辑组件的模板视图已事先对DOM进行了数据绑定,当我们改变组件容器中某个组件的数据项时,更新就会反应到DOM上。当我们保存整个模板的时候,只需要取出组件数据容器中的值就行了,其实也就是那个数组本身。H5展示端通过这个组件数据数组,可以拿到组件的数据以及排序,按照定好的模板渲染出来即可。当然,像投票组件这类有交互数据的组件,该系统设计的模板只是确定了要展示的固定的内容。具体的投票总数、每项投票数等属性需要后端处理后插入到对应组件数据里面,供展示端显示。
整个系统大概的设计思想就是这样的,下面挑些具体的来讲讲:
标签组件
因为标签组件的表现和交互逻辑等都是一致的,这里做了一个公共可复用的标签组件,对外接收两个参数:title(标签文本)和type(标签类型)。在标签容器组件创建一个包含所有标签组件数据对象的数组,在模板视图中遍历这个数组,就创建了所有的标签组件。
公共标签组件的统一的属性和方法等存入了一个对象字面量里面,导入以后通过mixin方式混合,组件就会拥有这些属性和方法。目前这样做的意义不大,因为已经有一个公共的标签组件了,mixin里面的东西完全可以直接写到这个公共组件内。但如果每个类型的标签组件都是一个单独的.vue组件文件,mixin的好处就体现出来了:可复用、易维护。
具体实现的代码,省略掉样式
//labelWrapper.vue 标签组件容器(组件标签区域) <template> <div class="label-wrapper"> <div class="label-title">组件标签区域</div> <div class="label-box"> <common-label v-for="label in labelArr" :title="label.title" :type="label.type"></common-label> </div> </div> </template> <script> import commonLabel from './widget/commonLabel.vue' //导入公共标签组件 export default { name : "label_wrapper", components : { commonLabel //注册为子组件(es6同名对象字面量缩写) }, data (){ return { labelArr : [ {title : "节标题", type : "sectionTitle"}, {title : "投票", type : "vote"}, {title : "正文", type : "content"}, {title : "用户", type : "user"}, {title : "图片", type : "image"}, {title : "视频", type : "video"}, {title : "音频", type : "audio"}, {title : "跳转链接", type : "link"} ] } } } </script> <style lang="stylus"> /*...*/ </style>
//commonLabel.vue 公共标签组件 <template> <div class="label-item-wrapper" title="拖入模板设计区域" draggable="true" @dragstart="dragStart"> <img class="label-icon" alt="{{title}}" :src="iconUrl"> <span class="label-text">{{title}}</span> </div> </template> <script> //导入mixin import labelMixin from './mixin/labelMixin' export default { name : "label", props : { title : String, type : String }, mixins : [labelMixin], computed : { iconUrl (){ return this.type + '.png' } } } </script> <style lang="stylus"> /*...*/ </style>
//labelMixin.js import typeDataMap from './typeDataMap' export default { methods : { dragStart (e){ var id = parseInt(Date.now() + "" + parseInt(Math.random() * 90)) var widgetData = typeDataMap[this.type] var dt = e.dataTransfer widgetData['id'] = id dt.setData("id", id) dt.setData("type", this.type) dt.setData("widgetData", JSON.stringify(widgetData)) } } }
预览组件
预览组件相对较简单,除了数据的绑定,就是拖动排序。拖动排序的实现是通过html5原生的drag事件,基于vue数据驱动的原理,拖动的时候并不需要去手动改变预览区域内各组件的DOM顺序,只需要改变组件数据数组里面各数据对象的index即可,数据的变化会反应到DOM上。简单的节标题预览组件:
<template> <div class="preview-item-wrapper" draggable="true" :class="{'active': isActive}" @click="showEdit" @dragover="allowDrop" @dragstart="dragStart" @drop="dropIn" > <span class="preview-item-del" :class="{'active': isActive}" title="删除该组件"> <div v-on:click="delMe">x</div> </span> <label class="preview-item-label">- 节标题 -</label> <div class="preview-item-input-wrapper"> <div class="title-text">{{text}}</div> </div> </div> </template> <script> //导入action import {addPreviewAndData, deleteWidgetPreview, changeWidgetEdit, changPreviewAndDataIndex} from '../../../store/actions' //导入mixin import previewMixin from './mixin/previewMixin' export default { name : "sectionTitle_preview", mixins : [previewMixin], props : { id : Number, index : Number }, computed : { //mixin外的私有属性 text (){ for (let value of this.widgetDataArr) if (value.id == this.id) return value.text } }, vuex : { //绑定mixin需要的属性和方法 getters : { widgetDataArr : (state) => state.widgetDataArr, currentEditWidgetId : (state) => state.currentEditWidgetId }, actions : { addPreviewAndData, deleteWidgetPreview, changeWidgetEdit, changPreviewAndDataIndex } } } </script> <style lang="stylus"> /*...*/ </style>
/** * previewMixin.js * 预览组件的mixin * @提取同类组件之间可复用的计算属性与方法 */ export default { computed : { //该预览组件是否为当前点击的 isActive (){ return this.id == this.currentEditWidgetId } }, methods : { //删除该预览组件 delMe (){ this.deleteWidgetPreview(this.id) }, //显示该预览组件对应的编辑组件 showEdit (){this.changeWidgetEdit(this.id) }, //允许向该预览组件拖放其他组件 allowDrop (e){ e.preventDefault(); }, //开始拖放该预览组件 dragStart (e){ var dt = e.dataTransfer dt.setData("index", this.index) }, //向该预览组件拖放其他组件(预览组件或者标签组件) dropIn (e){ e.preventDefault() e.stopPropagation() var dt = e.dataTransfer var id = parseInt(dt.getData("id")) if (id){ //有id表明拖入的是标签组件 var type = dt.getData("type") var widgetData = JSON.parse(dt.getData("widgetData"))this.changeWidgetEdit(id) this.addValidation(id) //添加组件验证项 } else { var index = parseInt(dt.getData("index")) this.changPreviewAndDataIndex(index, this.index) } //清空dataTransfer dt.clearData() } } }
编辑组件
还是以节标题组件为例:
<template> <div class="edit-item-wrapper"> <label class="edit-item-label">节标题文本</label> <validator name="titleValidator"> <div class="edit-item-input-wrapper"> <textarea class="title-edit-input" placeholder="必填项,16字以内" v-model="text" v-validate:text="{ required: {rule: true,message: '请填写节标题文本'}, maxlength: {rule: 16,message: '节标题文本限制在16字以内'} }" @input="inputValue" @valid="onValid" @invalid="onInvalid" ></textarea> <div class="edit-input-err" v-if="$titleValidator.text.required">{{$titleValidator.text.required}}</div> <div class="edit-input-err" v-if="$titleValidator.text.maxlength">{{$titleValidator.text.maxlength}}</div> </div> </validator> </div> </template> <script> //导入action import {changeWidgetData, changeValidation} from '../../../store/actions' //导入mixin import editMixin from './mixin/editMixin' export default { name : "title_edit", mixins : [editMixin], props : { id : Number }, computed : { //mixin外的私有属性 text (){ for (let value of this.widgetDataArr) if (value.id == this.id) return value.text } }, methods : { //mixin外的私有方法 inputValue (e){ this.changeWidgetData(this.id, 'text', e.target.value) } }, vuex : { getters : { widgetDataArr : (state) => state.widgetDataArr }, actions : { changeWidgetData, changeValidation } } } </script> <style lang="stylus"> /*...*/ </style>
/** * editMixin.js * 编辑组件的mixin */ export default { data (){ return { //isValid : false } }, methods : { onValid (){ //验证通过 this.isValid = true this.changeValidation(this.id, true) }, onInvalid (){ //验证失败 this.isValid = false this.changeValidation(this.id, false) } } }
还有一些公共组件以及store等就不再介绍了,前面的讲解已基本包含,差不多就到这里了。最后完成后是这样的:
活动的展示端
在编辑完所有组件后,保存该活动ID和一个包含所有组件数据对象的数组到server端数据库中。我司的活动是用H5做的,H5页面按活动ID到server获取到该活动的组件数据,按照数组中的顺序和内容依次渲染即可生成对应的H5活动页面。当然H5端也需要有一套组件化的实现方案,并与活动发布端有统一的组件和相应属性的命名规范等。
这个活动模板在某种意义上是对活动内容结构的高度抽象,如果再加上每个组件的行为,就相当于react中的虚拟DOM一样,由javascript维护的一套抽象的内容结构树。任何端拿到了这个结构树,都可以按照它的需求渲染出它这个端要展示的东西。用react举例来描述就应该是这样的:jsx的结构在编译后被抽象成了虚拟DOM,在web端虚拟DOM被渲染成了html结构(react js),在手机native端,虚拟DOM被渲染成了native的组件结构(react native)。也就是说,如果用在lua端,wpf端(如果可能的话),也可以映射成它们的组件结构。其实也有点类似于服务端的DAO层,定义了统一数据访问接口,底层的用什么数据库可以随意切换。这点比框架或者说该项目本身意义更重大。