在學習 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
方法中新增的代碼,其實還能夠進一步優化,不知道你有沒有想到該如何優化呢?快動手試試吧!