在学习 Vue.js 组件化开发 Todo List 的时候,本身虽然也能编码实现,但若是不作笔记,只是写代码,学习的效果还不够好。只有把本身的实现思路记录下来,遇到的问题和解决方法也记录下来,用文字把这个过程梳理清楚,才能对整个项目有更加清晰、准确的认识。javascript
注:该项目经过 vue-cli
搭建,GitHub 上的地址:todo-list。css
先写一个最简单的组件,就是用 v-for
指令显示待办事项清单。数据也是用的本地的数据,这样在这一步可以把更多的精力放在学习组件的编写上。html
首先,固然是在 components
目录下新建 TodoItem.vue
文件,用来显示待办事项清单,代码以下:前端
<template> <ul> <li v-for="task in tasks" :key="task.id"> {{ task.title }} </li> </ul> </template> <script> export default { name: 'TodoItem', props: { tasks: Array } } </script>
在 script
中,name
选项定义了组件的名称 TodoItem
,props
选项则定义了组件所接收数据的名称 tasks
和类型:数组(Array)。vue
在 template
中,则在根元素 ul
内,经过 li
元素显示待办事项的名称 task.title
。加了另外一条语句 :key="task.id"
,是由于 Vue 建议在用 v-for
遍历时,为所遍历的每一项提供一个惟一的 key
属性(参考:key)。这一项不加也彻底不要紧,只不过 vue-cli
附带的 ESLint 会有错误提示,因此我这里就加上了。java
另外这里还有个小知识点,Vue 规定组件的 template
中只能有一个根元素,也就是说下面这种写法是会报错的。我的猜想,之因此会有这种规定,也是为了最终渲染出来的 HTML 结构能更加清晰。仔细想一想,这个理念也和组件化是相通的,不是嘛?ios
<!-- 错误写法 --> <template> <div></div> <div></div> </template>
这个组件最基本的内容已经写好了,接下来就在 App.vue
中引入它。git
<script> import TodoItem from "./components/TodoItem.vue"; export default { name: "app", components: { TodoItem } }; </script>
引入组件以后,固然还要为它提供数据,这样组件才有内容能够显示。这里也有个知识点,组件中的数据对象 data
必须是函数,由于这样可以保证组件实例不会修改同一个数据对象。刚开始写组件的时候可能容易忽略这个知识点,多写几回就记住了。github
export default { name: "app", components: { TodoItem }, data() { return { tasks: [ { id: "6b9a86f6-1d1a-558a-83df-f98d84cd87bd", title: "JS", content: "Learn JavaScript", completed: true, createdAt: "2017-08-02" }, { id: "1211bb33-a249-5782-bd97-0d5652438476", title: "Vue", content: "Learn Vue.js and master it!", completed: false, createdAt: "2018-01-02" } ] }; } };
为组件准备好数据以后,就能够开始用它了。组件的基本用法也很简单,按照它的要求提供数据,而后组件就会按照本身设定的样式把数据显示出来。vue-cli
<template> <div id="app"> <TodoItem :tasks="tasks" /> </div> </template>
上面的代码中,调用了 TodoItem
这个组件,而且将父组件中的数据属性 tasks
绑定到 TodoItem
这个组件的 props
选项上。在 :tasks="tasks"
这句代码中,等号前的 tasks
是子组件 TodoItem
中定义的名称,能够近似地理解为“形参”;等号后面的 tasks
则是父组件中的数据属性,能够近似地理解为“实参”。因此这种用法也能够理解成 :形参="实参"
,但愿这种写法可以帮你们更容易地理解组件传入数据的语法。而父组件的数据属性和子组件的 props
选项都用 tasks
这个名称,是为了保持代码上的一致性,刚接触组件的时候可能以为分不清谁是谁,可是代码写多了以后就能体会到这种写法的好处了,父组件只负责提供数据,子组件只负责使用数据,保持一致的命名,阅读和修改代码的时候就能很容易看出来互相之间的关系。
保存代码,而后在终端中执行 npm run serve
,构建工具就会自动编译,而后在浏览器中打开页面,若是可以看到相似下图中的效果,就说明已经写好了一个最简单的组件了,接下来就要丰富这个 Todo List 的各项功能了。
要使用 Bootstrap 的样式,首先须要把它的 CSS 文件引入进来,编辑 public
目录下的 index.html
文件,在 head
中加入下面的 CSS。后面须要引入 CSS 或者 JS 的时候,均可以在这里引入。固然了,也能够经过 npm install xxx
指令之后端库的形式引入,不过这样只能引入 JS,无法引入 CSS。不过有一天在火车上撸代码的时候,发现了之后端形式引入库的一个便利之处,就是它一旦安装好了,没有网络的状况下也彻底能够正经常使用。
<!DOCTYPE html> <html> <head> <link href="https://cdn.bootcss.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet"> </head> </html>
接下来就是搭框架,先修改 App.vue
,肯定总体框架:
<template> <div id="app" class="container"> <div class="col-md-8 offset-md-2 mt-5"> <TodoItem :tasks="tasks" /> </div> </div> </template>
在根 div
中加上 class="container"
,这样子元素就能够应用 col-md-8
这样的网格样式了。而后在子元素中加上 class="col-md-8 offset-md-2 mt-5"
,col-md-8
表示待办事项占12列宽度的网格中的8列,offset-md-2
表示往右偏移2列以后显示待办事项,这样就可以居中显示了。mt-5
则表示待办事项距离上方有必定空白,留白了才好看。
每一个待办事项要显示标题、内容、日期,能够用 Bootstrap 的 Custom Content 列表。
观察上图对应的代码能够知道,a
标签内的 h5
标签可用于显示待办事项的标题,相邻的 small
标签可用于显示时间,a
标签内最后的 small
标签则可用显示于事项的具体内容,所以 TodoItem.vue
组件中能够改为以下内容。
<template> <div class="list-group"> <a href="#" class="list-group-item list-group-item-action flex-column align-items-start" v-for="task in tasks" :key="task.id"> <div class="d-flex w-100 justify-content-between"> <h5 class="mb-1">{{ task.title }}</h5> <small>{{ task.createdAt }}</small> </div> <small>{{ task.content }}</small> </a> </div> </template>
在浏览器中看看页面效果,怎么样,还不错吧?
在实际业务中,数据都是放在服务器上,每每会在前端页面加载完成以后,再向服务器请求数据。这样先后端分离,让前端页面只关注界面部分,数据由后端负责提供,将先后端解耦,就下降了互相之间的依赖性。
要向服务器请求数据,能够用 axios 这个库,和前面引入 Bootstrap 的 CSS 同样,编辑 public
目录下的 index.html
文件,将 axios 这个库的连接加进来。
<!DOCTYPE html> <html> <head> <script src="https://cdn.bootcss.com/axios/0.17.1/axios.min.js"></script> </head> </html>
而后再编辑父组件 App.vue
,将数据属性 tasks
的初始值设置为空数组,在 Vue 实例的 created
这个生命周期钩子中获取数据。数据方面参考一个简单的 JSON 存储服务这篇文章的建议 ,放在 myjson 上。
const tasksUrl = "https://api.myjson.com/bins/xxxxx"; export default { name: "app", components: { TodoItem }, data() { return { tasks: [] }; }, methods: { fetchData(jsonUrl, obj) { axios .get(jsonUrl) .then(response => response.data) .then(data => { data.forEach(ele => { obj.push(ele); }); }) .catch(console.log()); }, }, created() { this.fetchData(tasksUrl, this.tasks); } };
从上面的代码能够看到,数据属性的值保存在 tasksUrl
这个 URL 中,经过 axios 获取数据。在 Vue 中更新数组,须要用特定的变异方法,才能触发视图的更新,也就是上面代码中的 obj.push(ele)
。
另外,上面将更新数据部分的代码抽离成一个单独的函数 fetchData
,这样可以提升代码的可读性。不然若是 created
这个钩子中须要执行五六个操做的时候,把具体的代码全放到这里面,那代码就乱得无法看了。
v-cloak
优化加载体验为了优化用户体验,能够用 v-cloak
指令,实现组件在数据加载完成以后才显示的功能。
具体的测试结果,能够看视频:http://7xq4gx.com1.z0.glb.clouddn.com/v-cloak_fast-3g.mp4。
在上面这个视频中,经过 Chrome 开发者工具将网速限制为 "Fast 3G" 模式,以便更清楚地展现这个过程。而后点击刷新按钮加载页面,可以看到页面在成功获取到服务器上的数据以后,才会渲染组件内容并显示出来,在这以前页面则一直是空白状态。
前面知道怎么用组件显示待办事项清单了,那么显示一个菜单列表也很容易了,照葫芦画瓢就行。
首先在父组件 App.vue
中准备数据 menus
。
export default { name: "app", components: { TodoItem, TodoMenu }, data() { return { tasks: [], menus: [ { tag: "all", text: "所有" }, { tag: "doing", text: "未完成" }, { tag: "done", text: "已完成" } ] }; } }
而后选择按钮的样式,本身选用了 Outline buttons,组件代码以下:
<template> <div> <button type="button" class="btn btn-outline-secondary" v-for="menu in menus" :key="menu.id"> {{ menu.text }} </button> </div> </template> <script> export default { name: 'TodoMenu', props: { menus: { type: Array, required: true } } } </script>
与以前编写 TodoItem 组件时相比,代码上主要的区别在于 props
的定义更加详细了,理由参见 Vue.js 官方文档中的风格指南:Prop 定义。
下面是当前的页面效果:
基本的功能作出来了,接着来调整一下 TodoMenu 组件的样式,让它更好看一些。
首先是要给按钮之间加上间距,也是前面提到过的留白,就跟设计 PPT 同样,把页面塞得满满的其实很难看。查看 Bootstrap 的文档 Margin and padding,知道了能够用 mr-x
这样的类来设置右边距,测试了几个值以后,最终肯定为 mr-2
。
而后还要给上面的一排按钮和下面的待办事项清单之间也加上间距,这里就用 mb-3
设置按钮的下边距,以前在 TodoItem 组件中设置的 mt-5
则删掉。
<template> <div> <button type="button" class="btn btn-outline-primary mr-2 mb-3" v-for="menu in menus" :key="menu.id"> {{ menu.text }} </button> </div> </template>
如今的页面效果就是这个样子的了:
查看 Bootstrap 的文档能够知道,给按钮添加一个 active
类,按钮就会处于被点击的状态。这样一来,只须要修改 menus
的数据结构,给每一个对象添加一个名为 active
的布尔型变量,而后给 TodoMenu 组件动态绑定 active
类,就能实现页面加载完成后突出显示第一个按钮的功能了。
// App.vue menus: [ { tag: "all", text: "所有", active: true }, { tag: "doing", text: "未完成", active: false }, { tag: "done", text: "已完成", active: false } ]
<!-- TodoMenu.vue 只列出了新增的部分 --> <template> <div> <button :class="{active: menu.active}"> </button> </div> </template>
除了要在网页加载完成后突出显示第一个按钮,还须要在用户点击各个按钮以后,突出显示用户所点击的按钮,这样可以让用户很清楚地看到本身所选中的是哪一个按钮。
实现这个需求的流程以下(用了库 ramda):
menus
中 active
属性为 true
的对象,也就是以前被点击的按钮对应的数据。menus
中当前被点击的按钮对应的对象:这个须要在子组件 TodoMenu.vue
中触发事件,将被点击的按钮所对应的数据(menu.tag
)传递给父组件 App.vue
,而后在父组件中查找该数据所对应的对象,若是和第一次查找的对象相同,说明先后两次点击了同一个按钮,那么就不用重复操做了。不然就须要把前一次点击的按钮的 active
属性设置为 false
,而后将当前被点击的按钮的 active
属性设置为 true
,这样就可以突出显示被点击的按钮了。新增的代码以下:
<!-- index.html --> <head> <script src="https://cdn.bootcss.com/ramda/0.25.0/ramda.min.js"></script> </head>
<!-- TodoMenu.vue --> <template> <div> <button @click="activeButton(menu.tag)"> </button> </div> </template> <script> export default { methods: { activeButton (tag) { this.$emit('active', tag); } } } </script>
上面是组件 TodoMenu.vue
新增的代码,用户点击按钮以后,会执行该组件内的 activeButton
函数。在函数中会触发 active
事件,并将当前按钮所对应对象的 tag
属性的值传给父组件。
<!-- App.vue --> <template> <div id="app"> <div class="col-md-8 offset-md-2 mt-5"> <TodoMenu :menus="menus" @active="activeButton" /> </div> </template> <script> export default { methods: { activeButton(tag) { let prevIndex = R.findIndex(R.propEq('active', true))(this.menus); let currIndex = R.findIndex(R.propEq('tag', tag))(this.menus); if (prevIndex !== currIndex) { this.menus[prevIndex].active = false; this.menus[currIndex].active = true; } } } } </script>
而上面的这段代码则是父组件 App.vue
中新增的代码,父组件监听到了子组件触发的 active
事件,就会执行父组件中的 activeButton
函数,对比两次点击的是否为同一按钮,而后根据结果执行对应的操做:若是点击的是不一样的按钮,则将以前所点击的按钮对应的对象属性 active
值设置为 false
,并将当前点击的按钮对应的对象属性的 active
的值设置为 true
,Vue 监听到对象属性的变化,从而将类名动态绑定到 HTML 标签上,实现按钮的突出显示。
PS:本身以前的实现方案,是经过 jQuery 先将 menus
中全部对象的 active
属性设置为 false
,而后用原生 JS 将触发了监听事件对象的 active
属性设置为 true
,虽然代码也很简洁,可是代码的逻辑仍是不如用 ramda 这个库的实现方式清晰。
这个需求能够在上一个需求的流程里完成,就是页面加载完成时,显示所有的待办事项;以后每次用户点击按钮,和前一次突出显示的按钮进行对比,若是相同,说明显示的仍是那些待办事项,天然不用作什么操做;若是不一样,那就显示按钮所对应分类的待办事项。
export default { data() { return { currTag: "" } }, computed: { filteredTasks() { if (this.currTag === "all") { return JSON.parse(JSON.stringify(this.tasks)); } else if (this.currTag === "doing") { return R.filter(task => task.completed === false)(this.tasks); } else if (this.currTag === "done") { return R.filter(task => task.completed === true)(this.tasks); } else { return null; } } }, methods: { fetchData(jsonUrl, obj) { axios .get(jsonUrl) .then(response => response.data) .then(data => { data.forEach(ele => { obj.push(ele); }); }) .then((this.currTag = "all")) .catch(console.log()); } } }
在上面的代码中,经过字符串属性 currTag
标记当前所点击的按钮,计算属性 filteredTaks
则根据 currTag
的值筛选出所要显示的待办事项。而在 fetchData
方法中,新增的 .then((this.currTag = "all"))
会在获取到数据以后设置所要显示的事项类别,这样整个流程就完整了。
上面这些只是功能上的变更,在界面部分也要对应调整,这样才能有更好的用户体验。具体来讲,就是对于已完成的待办事项,复选框应为选中状态,而且文字的颜色要淡一些,这样才能和未完成的待办事项区分开来。
而实际的代码其实很简单,就是将传入组件的数据与 HTML 元素动态绑定:
<!-- 将 task.completed 属性与复选框的 checked 属性相绑定 --> <input type="checkbox" :checked="task.completed"> <!-- 将 task.completed 与包含文字的 div 元素的 text-muted 这个类相绑定 --> <div class="col-md-11 d-flex w-100 justify-content-between" :class="{'text-muted': task.completed}"> </div>
下面是调整好界面以后的效果图:
首先设计编辑界面的基本样式,在这里用的是 Bootstrap 中的 Card 这个组件,这样能够把内部的元素都包裹到 card
中。待办事项的标题和内容显示在 textarea
元素中,待办事项的建立时间则显示在 card-footer
中。这个组件的代码以下所示:
<!-- TodoEdit.vue --> <template> <div class="card mt-3 mb-5"> <div class="card-body"> <div class="form-group"> <textarea id="title" class="form-control font-weight-bold" rows="1" v-model="task.title"> </textarea> <textarea id="content" class="form-control mt-1" rows="3" v-model="task.content"> </textarea> </div> </div> <div class="card-footer text-muted"> 建立于:{{ task.createdAt }} </div> </div> </template> <script> export default { name: "TodoEdit", props: { task: { type: Object } } } </script>
从上面的代码中能够看到,将 id
为 title
的 textarea
与 task.title
属性进行了双向绑定,id
为 content
的 textarea
则与 task.content
属性进行了双向绑定,分别用来显示待办事项的标题和内容。
在父组件 App.vue
中,对象类型的数据属性 currTask
保存子组件 TodoEdit.vue
中所要显示的待办事项,并经过布尔类型的计算属性 renderEdit
决定是否要渲染子组件 TodoEdit.vue
。在用户尚未点击待办事项的时候,还不须要渲染编辑界面,数据属性 currTask
仍是个空对象,计算属性 renderEdit
为 false
。在用户点击了某个待办事项以后,须要在编辑界面中显示数据属性 currTask
中的内容,计算属性 renderEdit
为 true
,这样才会渲染子组件 TodoEdit.vue
。
父组件 App.vue
中新增的代码以下所示:
<!-- App.vue --> <template> <TodoEdit :task="currTask" v-if="renderEdit" /> </template> <script> export default { data() { return { currTask: {} } }, computed: { renderEdit() { return Object.keys(this.currTask).length > 0 && this.currTask.constructor === Object; } }, methods: { editTask(task) { this.currTask = JSON.parse(JSON.stringify(task)); } } } </script>
从上面的代码能够看到,在页面及数据加载完成以后,用户点击待办事项以前,不会显示编辑界面。用户点击待办事项以后,将当前事项的信息保存至数据属性 currTask
中,计算属性 renderEdit
此时的值也为 true
,便会渲染子组件 TodoEdit.vue
,并将数据属性 currTask
的内容显示在子组件中。
完成以后的效果以下图所示:
按照上面的方法完善代码以后,如今能够显示待办事项的编辑界面了。可是点击待办事项的话,浏览器地址栏中的地址会在最后附加上一个 #
字符:http://localhost:8080/#
。若是不想有这种变化,那么就能够去掉 TodoItem.vue
组件的 href
属性,而后设置鼠标悬浮至该组件的 a
标签时显示手型指针便可:
<style scoped> a:hover { cursor: pointer; } </style>
此外,因为 TodoEdit.vue
组件中,显示待办事项标题和内容用的都是 textarea
标签,而这个标签是能够经过拖动其右下角的标记来改变其大小的。可是对于待办事项而言,标题的文字数量通常都很少,不但愿改变其大小,那么就要为这个标签进行单独的设置,设置其 resize
属性为 none
便可。
<style scoped> #title { resize: none; } </style>
此时的效果以下所示:
这个功能所要实现的效果,就是用户连续屡次点击同一个待办事项时,编辑界面会在显示/隐藏两种状态之间来回切换,给用户以更好的使用体验。
最开始的思路:
prevId
用于保存用户上一次点击的待办事项的 id
属性,而且将用户本次点击的待办事项的 id
属性与之进行对比。id
属性保存在 prevId
中,这样用户下一次再点击待办事项,就能与更新后的 prevId
属性进行对比。prevId
属性就不必更新了,同时要切换编辑界面的显示状态。从前面的代码能够知道,计算属性 renderEdit
的值决定了是否要渲染组件 TodoEdit.vue
,数据属性 currTask
非空就会渲染。而用户首次点击待办事项以后,currTask
就永远都是非空的了,也就意味着编辑界面一直会被渲染。而这里须要实现的功能,是要让这个组件在显示/隐藏两种状态之间来回切换,须要注意的是,组件的“渲染”和“显示”是两回事,被渲染出来的组件,能够经过设置其 display
这个 CSS 属性的值为 false
来把它隐藏了。那么 Vue.js 中有没有相似的方式实现这个功能呢?固然有!那就是 v-show
指令。该指令后跟的表达式只要为真值,就会显示该元素,不然就会隐藏该元素。这不恰好就是咱们须要的功能吗?这样一来,就能够经过优化代码逻辑,让上面新建的数据属性 prevId
来完成两件事:一方面这个数据属性能够用来保存每次点击的待办事项的 id
属性,另外一方面还能够用它来决定是否要显示编辑界面。啊哈,一箭双雕,是否是很爽?另外,prevId
这个名称只是表示了它最原始的含义,其实能够重命名为 showEdit
,用来表示它最终的业务逻辑,这样在阅读代码的时候就更容易理解了。下面就是优化后的代码逻辑:
showEdit
为空。id
至 showEdit
。id
与 showEdit
相同,则清空 showEdit
。id
与 showEdit
不一样,则更新至 showEdit
中。流程已经很清楚了,代码天然也是水到渠成:
<template> <TodoEdit v-show="showEdit" /> </template> <script> export default { data() { return { showEdit: "" } }, methods: { editTask(task) { // 仅列出该方法中新增的部分 !this.showEdit ? this.showEdit = task.id : this.showEdit === task.id ? this.showEdit = '' : this.showEdit = task.id; } } } </script>
俗话说优化无止境,上面的 editTask
方法中新增的代码,其实还能够进一步优化,不知道你有没有想到该如何优化呢?快动手试试吧!