1. 程式人生 > >來點實在的:自動計算剩餘時間的任務列表

來點實在的:自動計算剩餘時間的任務列表

名字靈感來自我的書《HBase不睡覺書》 意為讓你看了也不會想睡覺的教程 :)

目標

前兩課教的是入門和檔案結構。都沒有什麼實在的東西。這次我們要來點實在的。我們要做出一個待辦列表。這個待辦列表有以下特點:

  1. 可以自動從文字中抽取出這件事情的開始時間
  2. 可以顯示當前距離這件事情的開始時間還有多久,比如:23:40 回家 (還有 6 小時 36 分 15 秒)
  3. 如果當前時間已經超過了計劃時間,則以灰色字型顯示任務,並加上刪除線

通過這個例子我們可以學到以下知識點

  1. v-for屬性
  2. v-bind:key屬性
  3. v-on屬性
  4. 在vue中使用bootstrap
  5. 在vue中使用localStorage
  6. watch屬性
  7. computed屬性
  8. 在vue中定義私有方法
  9. webpack自動打包
  10. v-if, v-else-if, v-else屬性
  11. v-show屬性

背景

  • vue版本:2.5.16

注意事項

 在說本節課的步驟之前,先提醒大家,該完程式碼記得用以下命令編譯後才能用瀏覽器看到你的更改

npx webpack

編譯後記得要訪問的頁面檔案不是根目錄下的index.html。那只是原始檔。你需要訪問 dist/index.html。

建立TodoList元件

修改App.vue

我們先來構建專案框架。這個專案只有一個元件:TodoList。

將App.vue中之前的 import 引用 修改為 import TodoList from './components/TodoList' 就像這樣

import TodoList from './components/TodoList.vue'

然後在template模板程式碼塊中引用它,並在components物件中引用它。修改完的App.vue是這樣的:

<template>
  <div id="app">
    <TodoList/>
  </div>
</template>

<script>
import TodoList from './components/TodoList.vue'

export default {
  name: 'app',
  components: {
    TodoList
  }
}
</script>

新建TodoList.vue元件

將HelloVue.vue刪掉。然後在src/components資料夾下新建TodoList.vue元件,元件內容為

<template>
   <div id="todolist">{{ message }}</div>
</template>

<script>
export default {
  name: 'TodoList',
  data: function() {
    return {
      message: '這是一個待辦列表'
    }
  }
}
</script>

照例使用 npx webpack打包,然後訪問 http://learn-vue/dist/index.html 。如果成功,你就可以 看到 “這是一個待辦列表” 的字樣。

顯示任務列表(v-for)

既然是一個待辦列表,那麼核心的資料物件就應該是一個array。讓我們來新建這個array

data: function() {
    return {
      taskList: [
        "7:00 學英語", 
        "10:00 學Vue"
      ]
    }
  }

我們來使用v-for來顯示它

<template>
   <div id="todolist">
     <table>
       <thead>
         <th>任務</th>
       </thead>
      <tbody>
        <tr v-for="task in taskList">
          <td>{{ task }}</td>
        </tr>
      </tbody>
     </table>
   </div>
</template>

顯示的效果為:

如果你的task是一個object,你可以使用以下方式來顯示它的屬性

<tr v-for="task in taskList">
   <td>{{ task.id }} {{ task.name}}</td>
</tr>

如果你使用的是 visual studio code,那麼有可能看到以下錯誤提示:

Elements in iteration expect to have 'v-bind:key' directives.

這是因為當vue要求當使用v-for來顯示列表時,需要使用v-bind:key來標定列表主鍵,就像這樣

<tr v-for="task in taskList" v-bind:key="task.id">
          <td>{{ task.id }} {{ task.name }}</td>
        </tr>

因為我們的例子過於簡單了,每個紀錄只是一行字串,所以可以忽略這個錯誤提示。在本例中我們不需要理會這個錯誤提示。但是在實際的專案中,請一定加上:key。

為什麼要加上v-bind:key?

以下引用自vue官網:

當 Vue.js 用 v-for 正在更新已渲染過的元素列表時,它預設用“就地複用”策略。如果資料項的順序被改變,Vue 將不會移動 DOM 元素來匹配資料項的順序, 而是簡單複用此處每個元素,並且確保它在特定索引下顯示已被渲染過的每個元素。這個類似 Vue 1.x 的 track-by="$index" 。

這個預設的模式是高效的,但是隻適用於不依賴子元件狀態或臨時 DOM 狀態 (例如:表單輸入值) 的列表渲染輸出

為了給 Vue 一個提示,以便它能跟蹤每個節點的身份,從而重用和重新排序現有元素,你需要為每項提供一個唯一 key 屬性。理想的 key 值是每項都有的唯一 id。這個特殊的屬性相當於 Vue 1.x 的 track-by ,但它的工作方式類似於一個屬性,所以你需要用 v-bind 來繫結動態值 

簡而言之就是:vue為了效能考慮,預設複用頁面上的dom元素。為了防止你的列表元素不更新,就要用key告訴vue,這些dom元素是不一樣的。

新增任務按鈕(v-on)

在<table>元素上面新增一個<button>元件,用來增加任務

<button>新增任務</button>

接下來,我們需要用到v-on語法來為按鈕新增對click事件的繫結

<button v-on:click="addTask">新增</button>

由此可見,v-on的語法就是 v-on:<事件名>=“js語句或者js方法名”

寫好了模板,接下來就是在default物件中增加methods屬性,並新增addTaks方法了

methods: {
    addTask: function(event) {
      this.taskList.push("新的待辦任務");
    }
  }

完整的default物件為

export default {
  name: 'TodoList',
  data: function() {
    return {
      taskList: [
        "7:00 學英語", 
        "10:00 學Vue"
      ]
    }
  },
  methods: {
    addTask: function(event) {
      this.taskList.push("新的待辦任務");
    }
  }
}

執行效果就是,每次點選新增按鈕,就會新增一個任務

美化頁面(bootstrap, css-loader, style-loader)

我覺得這樣的頁面也太醜了,所以我們來為頁面加入bootstrap。直接使用原生bootstrap比較麻煩,我們使用bootstrap-vue來為vue專案新增bootstrap:

$ npm i --save bootstrap-vue

然後我們在main.js中寫上對BootstrapVue的引用,以及相關css的引用

import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

Vue.use(BootstrapVue);

如果你現在執行npx webpack一定會看到如下錯誤

ERROR in ./node_modules/bootstrap-vue/es/components/alert/alert.css 1:0
Module parse failed: Unexpected token (1:0)
You may need an appropriate loader to handle this file type.

這是因為你目前還沒有為webpack.config.js新增css的loader。所以webpack不認識.css檔案。

我們來安裝跟css相關的loader

$ npm i --save style-loader css-loader

然後在webpack.config.js的rules節點中編寫規則來使用它

    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]

現在我們就可以使用npx webpack命令來打包專案了。

打包好後,你再看頁面,會有些許的變化,但是變化不大。這是因為我們還沒有真正的使用bootstrap。現在我們來為頁面做以下美化

  • 為todoList根div增加class: container
  • 為button按鈕增加class: btn btn-primary m-4
  • 為table增加class: table m-4

然後再來看看我們的頁面:

這下好看多了。

新增任務功能

接下來,我們增加“新增任務”的功能。首先我們要新增一個用來輸入任務內容的input輸入框。但是直接在button右邊新增輸入框看起來又很醜。所以我打算從bootstrap的網站上覆制一段<button>和<input>都包含在內的佈局程式碼,就像這段

<div class="input-group mb-3">
  <div class="input-group-prepend">
    <button class="btn btn-outline-secondary" type="button">Button</button>
  </div>
  <input type="text" class="form-control" placeholder="" aria-label="" aria-describedby="basic-addon1">
</div>

將其改造成我們需要的樣子:

  1. 將<button>元素的文字改為新增並加上v-on:click="addTask"屬性
  2. 將<input>元素的placeholder屬性修改為“請輸入任務內容”,並加上id="task_content"方便定位。

然後,將之前的

<button type="button" class="btn btn-primary m-4" v-on:click="addTask">新增</button>

替換為我們修改後的程式碼塊

<div class="input-group mb-3">
        <div class="input-group-prepend">
          <button class="btn btn-outline-secondary" type="button" v-on:click="addTask">新增</button>
        </div>
        <input type="text" id="task_content" class="form-control" placeholder="請輸入任務內容" aria-label="" aria-describedby="basic-addon1">
     </div>

接下來,我們來修改addTask任務。由於vue將物件和html dom元素進行了雙向繫結,原來我們需要用jquery來操作dom元素的大量程式碼就被修改成了一行程式碼

this.taskList.push(task_content.value);

加上獲取任務內容輸入框和清空輸入框內容的程式碼,總共只需要三行程式碼:

addTask: function(event) {
      // 獲取任務內容
      let task_content = document.querySelector("#task_content");

      // 新增任務內容到任務列表中
      this.taskList.push(task_content.value);

      // 清空任務內容輸入框
      task_content.value = '';
    }

我們把之前任務列表中初始化的兩個任務刪掉

data: function() {
    return {
      taskList: []
    }
  },

現在你只需要操作taskList物件,頁面上的任務列表也會跟著變動。現在你可以試試在任務內容框中輸入任務的內容,然後點選新增按鈕:

任務儲存:localStorage和watch方法

現在有一個問題,那就是你一重新整理頁面,你新建的任務就消失了。所以我們新建一個store.js來處理任務的儲存。store.js利用localstorage來儲存任務:

const STORAGE_KEY='todo_list'
export default{
    fetch(){
        return JSON.parse(window.localStorage.getItem(STORAGE_KEY)||'[]')
    },
    save(items){
        window.localStorage.setItem(STORAGE_KEY,JSON.stringify(items))
    }
}

然後,在TodoList.vue中引用 store.js

import Store from './store.js'

現在 data.taskList 就不只是用[]來初始化了,我們要改成從store中獲取

data: function() {
    return {
      taskList: Store.fetch()
    }
  },

現在我要介紹一個全新的屬性 watch。該屬性的作用是當你改變某個屬性的時候可以同時做一些其他的事情。比如現在我們就需要在增加任務的同時將taskList儲存到localStorage中。你可以這樣寫

watch:{
    taskList:{
      handler:function(tasks){
        Store.save(tasks)
      }
    }
  },

注意:watch跟data, methods屬性是同級的。

動態解析任務時間(computed)

現在我們要使用computed屬性來做這個神奇的功能。當你想在頁面上顯示經過處理的變數時,你可以使用各種函式,比如 如果我們要將名字中的逗號都換成下劃線,然後擷取第一個空格之前的文字。我們可能會這麼寫

name.replace(',', '_').substring(0, name.indexOf(' '));

偶爾寫一次還好,要是專案的每個地方都要這麼寫一遍就太噁心了。所以vue提供了一種屬性叫 computed。使用這個屬性我們可以定義出“虛擬的”變數,這個變數並不在data中被實際的定義出來,而是通過對實際的變數進行了計算而得出的。在這個例子中我們的需求是:

  1. 列表要能夠自動計算出任務的剩餘快完時間,比如:23:40 回家 (還有 6 小時 36 分 15 秒)
  2. 如果當前時間已經超過了計劃時間,則不顯示剩餘完成時間

此時就需要用到computed屬性。使用computed屬性可以定義虛擬的變數。這種變數依賴於data中的變數計算得出,並且可以在html中像使用data中的屬性一樣的使用他們。在我們這個例子中,我們在html模板中使用一個虛擬變數parsedTaskList。

<tr v-for="task in parsedTaskList">
          <td>{{ task }}</td>
        </tr>

我們在跟watch屬性同級的節點下增加computed屬性,並在其中增加parsedTaskList屬性。我們會在partedTaskList屬性中對taskList進行轉換,生成新的任務列表

  computed: {
    parsedTaskList: function () {
      let parsedTaskList = [];
      const regex = /[0-9]+:[0-9]+/;
      // 遍歷taskList
      for (let i=0; i<this.taskList.length; i++) {
        let task = this.taskList[i];
        
        // 解析任務中的計劃時間
        let result = task.match(regex);
        if (result != null && result.length > 0) {
          let taskTime = result[0];
          let thisMoment = moment();
          let currentDate = thisMoment.format('YYYY-MM-DD');
          let taskMoment = moment(currentDate + " " + taskTime, 'YYYY-MM-DD HH:mm');
          if (taskMoment.valueOf() < thisMoment.valueOf()) {
            parsedTaskList.push(task);
            continue;
          }
          let duration = moment.duration(taskMoment.diff(thisMoment));
          let durationText = duration.hours() + " 小時 " + duration.minutes() + " 分 " + duration.seconds() + " 秒";
          // 將剩餘時間拼接到任務上
          parsedTaskList.push(task + "(還有 " + durationText + ")'></span>");
        }
        parsedTaskList.push(task);
      }
      // 返回新的任務列表
      return parsedTaskList;
    }
  },

抽取剩餘時間的具體的過程很簡單,大家也不需要現在理解它,因為它並不是這課的核心內容,只需要知道該函式可以實現自動拼接上任務的剩餘完成時間就行了。

做到這裡我遇到了一個問題,那就是:為了專案結構的簡潔,我希望可以把這段程式碼中由任務字串轉換為帶著剩餘時間的任務字串程式碼抽取到一個私有函式中去。但是在這沒有像java中的private關鍵字可以讓我們定義私有函式。

要如何定義私有函式呢?

寫在export中的東西意思是要暴露出去的東西,所以只要你的函式寫在export中,就相當於是public函數了。要想函式不被暴露出去,只需要將函式塊寫到export以外就好了。現在我們將轉換任務字串的程式碼抽取出來,放在 export default { 這行程式碼之上:

import Store from './store.js'
import * as moment from 'moment';

const regex = /[0-9]+:[0-9]+/;
/**
 * 該函式作用是解析出字串中的時間,並將其跟當前時間比較,
 * 計算出還剩多久才會到達計劃時間,將剩餘時間拼接在字串後。
 * 如果當前時間已經過了計劃時間,則不對字串做任何改變
 * 例子:
 * 23:40 回家 -> 23:40 回家 (還有 6 小時 36 分 15 秒)
 */
const addRemainTime = (task) => {
  let result = task.match(regex);
  if (result != null && result.length > 0) {
    let taskTime = result[0];
    let thisMoment = moment();
    let currentDate = thisMoment.format('YYYY-MM-DD');
    let taskMoment = moment(currentDate + " " + taskTime, 'YYYY-MM-DD HH:mm');
    if (taskMoment.valueOf() < thisMoment.valueOf()) {
      return task;
    }
    let duration = moment.duration(taskMoment.diff(thisMoment));
    let durationText = duration.hours() + " 小時 " + duration.minutes() + " 分 " + duration.seconds() + " 秒";
    return task + " (還有 " + durationText + ")";
  }
  return task;
}

export default {

這樣做了之後,parsedTaskList屬性的內容就變成異常簡潔了:

  computed: {
    parsedTaskList: function () {
      let parsedTaskList = [];
      for (let i=0; i<this.taskList.length; i++) {
        parsedTaskList.push(addRemainTime(this.taskList[i]));
      }
      return parsedTaskList;
    }
  },

好了。在重新整理頁面之前不要忘記執行 npx webpack 來重新打包專案。完成後的效果如下

每次改完程式碼都要手動打包真的很煩!其實,有一個方法可以讓webpack自動跟蹤你的改動,並自動打包

Webpack自動打包(watch)

通過帶上 --watch引數,比如

npx webpack --watch

或者,在webpack.config.js中增加watch相關屬性可以讓webpack自動的檢測當前專案是否有變動,如果有變動webpack會自動打包。以下我採取在 webpack.config.js 中增加watch相關屬性的方式來開啟watch模式:

watch屬性預設是關閉的。所以我們需要在webpack.config.js中加上watch屬性:

watch: true,

加上watch的設定

  watchOptions: {
    aggregateTimeout: 3000, // 編譯的超時時間,單位:毫秒
    poll: 30 // 掃描專案的間隔時間,單位:秒
  },

改動後的webpack.config.js檔案內容是

var path = require('path');
const { VueLoaderPlugin } = require('vue-loader')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './src/main.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  watch: true,
  watchOptions: {
    aggregateTimeout: 3000, // 編譯的超時時間,單位:毫秒
    poll: 30 // 掃描專案的間隔時間,單位:秒
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    // 以下是HtmlWebpackPlugin的配置
    new HtmlWebpackPlugin({
      template: 'index.html',
      filename: './index.html',
      hash: true
    })
  ]
};

設定完watch屬性後,我們就可以使用 npx webpack 來啟動自動打包了

npx webpack

啟動後命令行工具處於監聽狀態,一有程式碼改動就會自動打包

[email protected]:~/Code/learn-vue$ npx webpack

webpack is watching the files…

Hash: a38478266809719e3c32
Version: webpack 4.12.1
Time: 3706ms
Built at: 2018-10-03 17:43:01
       Asset       Size  Chunks             Chunk Names
   bundle.js   1.85 MiB    main  [emitted]  main
./index.html  273 bytes          [emitted]
[./node_modules/moment/locale sync recursive ^\.\/.*$] ./node_modules/moment/locale sync ^\.\/.*$ 2.91 KiB {main} [optional] [built]
[./node_modules/vue-loader/lib/index.js??vue-loader-options!./src/App.vue?vue&type=script&lang=js] ./node_modules/vue-loader/lib??vue-loader-options!./src/App.vue?vue&type=script&lang=js 136 bytes {main} [built]
[./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib/index.js??vue-loader-options!./src/App.vue?vue&type=template&id=7ba5bd90] ./node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!./node_modules/vue-loader/lib??vue-loader-options!./src/App.vue?vue&type=template&id=7ba5bd90 259 bytes {main} [built]
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 489 bytes {main} [built]
[./node_modules/webpack/buildin/module.js] (webpack)/buildin/module.js 497 bytes {main} [built]
[./src/App.vue] 1.02 KiB {main} [built]
[./src/App.vue?vue&type=script&lang=js] 246 bytes {main} [built]
[./src/App.vue?vue&type=template&id=7ba5bd90] 194 bytes {main} [built]
[./src/main.js] 269 bytes {main} [built]
    + 316 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    [./node_modules/html-webpack-plugin/lib/loader.js!./index.html] 399 bytes {0} [built]
    [./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 489 bytes {0} [built]
    [./node_modules/webpack/buildin/module.js] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module

watch的副作用

watch的副作用就是cpu佔用率會提高,我的macbook一執行 watch模式風扇的聲音就變大,導致我一直沒敢用這個模式。

為已完成任務增加刪除線(v-if)

剩下最後一個需求了,那就是如果當前時間超過了計劃時間,則任務需要變灰並增加刪除線。我們使用v-if來實現這個功能

通過在dom元素中增加 v-if="表示式" 我們可以靈活的控制該dom元素的顯示與否。就像這樣:

<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>

如果v-if中的表示式結果為true,則該元素會被渲染出來,反之則該元素不會被渲染。在這個例子中還用到了 v-else-if 和 v-else,有著豐富程式設計經驗的你肯定一下就看懂了它們的含義,所以在此我就不解釋了。

跟v-show的區別

還有一個跟v-if用法很像的屬性叫 v-show。同樣也是定義一個表示式,根據表示式的返回結果來決定該元素是否出現。不同的是v-if的表示式返回結果為false,則該元素完全不出現在html中,而v-show不管表示式結果怎樣都會渲染該元素,只是當表示式為false時為元素增加 display:none的樣式而已。

好,現在我們就來根據任務是否已經完成來顯示不同的任務樣式。檢驗的條件是任務字串中是否出現“還有 xx 小時 xx 分 xx 秒” 字樣。

先把html模板改成

<tr v-for="task in parsedTaskList">
          <td>
            <span v-if="isDone(task)" style="color:gray;text-decoration:line-through;">{{ task }}</span>
            <span v-else >{{ task }}</span>
          </td>
        </tr>

可以看到在v-if中我們使用了一個函式isDone來判斷該任務是否完成。所以我們需要在method屬性中增加isDone方法(以下方法的定義使用了ES2015語法)

isDone (task) {
      let result = task.match(/還有\s[0-9]+\s小時\s[0-9]+\s分\s[0-9]+\s秒/);
      return result == null || result.length == 0;
    }

不使用ES2015語法的版本是

isDone: function (task) {
      let result = task.match(/還有\s[0-9]+\s小時\s[0-9]+\s分\s[0-9]+\s秒/);
      return result == null || result.length == 0;
    }

如果你使用的是Chrome,那麼就可以放心大膽的使用ES2015語法咯。

完成後,打包,重新整理頁面,效果如下

這樣就完成了本節課的所有內容了。

method和computed有什麼區別呢?

這是我學習vue時最大的疑問,我覺得method和computed用法完全就沒區別!其實他們的區別在於:computed是帶快取的,如果被依賴的變數不發生變化,則下次呼叫computed時不會重新計算結果。但是method則是每次呼叫都會重新執行以得出實時的結果。

後記

其實vue的官網教程已經寫的非常棒了!沒見過寫的這麼棒的官網文件,強力贊一個!所以原本不打算在更新新的文章了,由於有網友希望我繼續更新,所以我才繼續又寫了一篇。但是寫文太費時間了。所以未來應該不會再更新了,感謝大家的支援!這是vue官網中文文件的學習傳送門:https://cn.vuejs.org/v2/guide/