Vue學習筆記(六) 長樂未央

北冥有隻魚發表於2023-01-08

例子仍然來自Mdn Web Docs,加上了我自己的理解。長樂未央,長毋相忘。某種意義上算是MDN web docs 中Vue教程的翻譯,但又加上了自己的理解。

地址是: https://developer.mozilla.org...

進化: 編輯元件

現在我們的元件仍然不太完美,因為它不能編輯,輸錯了,就輸錯了。對此我們的解決方案是引入一個編輯元件。還是固定的步驟,首先在src/components下建立一個檔案,我們將其命名為ToDoItemEditForm.vue。然後將下面你的程式碼複製到這個檔案中:

<template>
  <form class="stack-small" @submit.prevent="onSubmit">
    <div>
      <label class="edit-label">Edit Name for &quot;{{label}}&quot;</label>
      <input :id="id" type="text" autocomplete="off" v-model.lazy.trim="newLabel" />
    </div>
    <div class="btn-group">
      <button type="button" class="btn" @click="onCancel">
        取消
        <span class="visually-hidden">正在編輯 {{label}}</span>
      </button>
      <button type="submit" class="btn btn__primary">
        儲存
        <span class="visually-hidden">對{{label}}進行修改</span>
      </button>
    </div>
  </form>
</template>
<script>
  export default {
    props: {
      label: {
        type: String,
        required: true,
      },
      id: {
        type: String,
        required: true,
      },
    },
    data() {
      return {
        newLabel: this.label,
      };
    },
    methods: {
      onSubmit() {
        if (this.newLabel && this.newLabel !== this.label) {
          this.$emit("item-edited", this.newLabel);
        }
      },
      onCancel() {
        this.$emit("edit-cancelled");
      },
    },
  };
</script>
<style scoped>
  .edit-label {
    font-family: Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    color: #0b0c0c;
    display: block;
    margin-bottom: 5px;
  }
  input {
    display: inline-block;
    margin-top: 0.4rem;
    width: 100%;
    min-height: 4.4rem;
    padding: 0.4rem 0.8rem;
    border: 2px solid #565656;
  }
  form {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
  }
  form > * {
    flex: 0 0 100%;
  }
</style>

我們大致的解讀一下這個檔案,在這個檔案裡面我們建立了一個表單,表單中的input用於編輯待辦事項的名稱,用v-model和data中的newLabel建立了雙向繫結。同時我們宣告瞭這個元件接收的訊息(變數)prop。

在這個表單中有一個儲存和取消按鈕。

  • 點選儲存按鈕時,元件會透過emit發出item-edited事件。
  • 點選取消按鈕時,元件會透過emit發出edit-cancelled事件

現在我們來改造一下待辦元件,為待辦元件新增上編輯和刪除功能。我們的設計目標是在待辦下面出現一個編輯和刪除按鈕, 當點選編輯按鈕的時候,待辦元件隱藏,編輯元件出現。這是一種互斥的關係,我們需要用一個變數來表示這樣的狀態,我們在data裡面進行宣告:

data () {
    return {
      isDone: this.done,
      isEditing: false
    }
}

那怎麼通關isEditing來控制待辦元件的顯示和不顯示呢,我們可以使用Vue指令if else指令來完成。我們為待辦元件的template最外層再新增一個div,同時也是為了控制樣式,如果!isEditing = true就代表當前不處於編輯狀態。同時在待辦列表下面新增一個編輯和刪除按鈕,為編輯按鈕繫結處理事件,將isEditing = true。像下面這樣:

<template>
   <div class="stack-small" v-if="!isEditing">
        <div class="custom-checkbox">
            <input type="checkbox" :id="id" :checked="isDone" class="checkbox" @change="$emit('todo-complete')"/>
            <label :for="id" class="checkbox-label"> {{label}}</label>
        </div>
        <div class="btn-group">
       <button type="button" class="btn"  @click="toggleToItemEditForm">
        編輯 <span class="visually-hidden">{{label}}</span>
      </button>
      <button type="button" class="btn btn__danger" @click="deleteToDo">
        刪除 <span class="visually-hidden">{{label}}</span>
      </button>
    </div>
</div>
<to-do-item-edit-form v-else :id="id" :label="label"></to-do-item-edit-form>
</template>

<script>
import ToDoItemEditForm from './ToDoItemEditForm.vue'
export default {
  name: 'ToDoItem',
  components: {
    ToDoItemEditForm
  },
  props: {
    label: {required: true, type: String},
    done: {default: false, type: Boolean},
    id: {required: true, type: String}
  },
  data () {
    return {
      isDone: this.done,
      isEditing: false
    }
  },
  methods: {
    deleteToDo () {
      this.$emit('item-deleted')
    },
    toggleToItemEditForm () {
      this.isEditing = true
    }
  }
}
</script>

<style scoped>
.custom-checkbox > .checkbox-label {
  font-family: Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-weight: 400;
  font-size: 16px;
  font-size: 1rem;
  line-height: 1.25;
  color: #0b0c0c;
  display: block;
  margin-bottom: 5px;
}
.custom-checkbox > .checkbox {
  font-family: Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-weight: 400;
  font-size: 16px;
  font-size: 1rem;
  line-height: 1.25;
  box-sizing: border-box;
  width: 100%;
  height: 40px;
  height: 2.5rem;
  margin-top: 0;
  padding: 5px;
  border: 2px solid #0b0c0c;
  border-radius: 0;
  appearance: none;
}
.custom-checkbox > input:focus {
  outline: 3px dashed #fd0;
  outline-offset: 0;
  box-shadow: inset 0 0 0 2px;
}
.custom-checkbox {
  font-family: Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  font-weight: 400;
  font-size: 1.6rem;
  line-height: 1.25;
  display: block;
  position: relative;
  min-height: 40px;
  margin-bottom: 10px;
  padding-left: 40px;
  clear: left;
}
.custom-checkbox > input[type="checkbox"] {
  -webkit-font-smoothing: antialiased;
  cursor: pointer;
  position: absolute;
  z-index: 1;
  top: -2px;
  left: -2px;
  width: 44px;
  height: 44px;
  margin: 0;
  opacity: 0;
}
.custom-checkbox > .checkbox-label {
  font-size: inherit;
  font-family: inherit;
  line-height: inherit;
  display: inline-block;
  margin-bottom: 0;
  padding: 8px 15px 5px;
  cursor: pointer;
  touch-action: manipulation;
}
.custom-checkbox > label::before {
  content: "";
  box-sizing: border-box;
  position: absolute;
  top: 0;
  left: 0;
  width: 40px;
  height: 40px;
  border: 2px solid currentcolor;
  background: transparent;
}
.custom-checkbox > input[type="checkbox"]:focus + label::before {
  border-width: 4px;
  outline: 3px dashed #228bec;
}
.custom-checkbox > label::after {
  box-sizing: content-box;
  content: "";
  position: absolute;
  top: 11px;
  left: 9px;
  width: 18px;
  height: 7px;
  transform: rotate(-45deg);
  border: solid;
  border-width: 0 0 5px 5px;
  border-top-color: transparent;
  opacity: 0;
  background: transparent;
}
.custom-checkbox > input[type="checkbox"]:checked + label::after {
  opacity: 1;
}
@media only screen and (min-width: 40rem) {
  label,
  input,
  .custom-checkbox {
    font-size: 19px;
    font-size: 1.9rem;
    line-height: 1.31579;
  }
}
</style>

現在當我們的頁面如下所示:

編輯

當點選了編輯之後:

點選編輯之後

但你會發現點選取消不能返回,點選儲存沒反應。原因在於我們的編輯元件發出的事件沒有被待辦元件所理會,當編輯待辦元件發出點選取消按鈕事件,待辦元件應當將編輯元件隱藏,也就是將isEditing置為false。當編輯待辦元件按鈕發出點選儲存按鈕事件,我們同樣應當將編輯元件隱藏,所以我們待辦元件中的template的編輯待辦模板標籤變成了下面這樣:

<to-do-item-edit-form v-else :id="id" :label="label" @item-edited="itemEdited" @edit-cancelled="editCancelled"></to-do-item-edit-form>

待辦元件的methods多了兩個方法itemEdited和editCancelled:

itemEdited (newLabel) {
  this.$emit('item-edited', newLabel)
  this.isEditing = false
},
editCancelled () {
  this.isEditing = false
}

現在我們點選取消就能回到待辦元件,但是點選儲存仍然沒有反應,原因在於我們在待辦元件裡面發出的事件沒有被App元件所處理,待辦列表的資料在App裡面。所以現在我們就需要轉到App元件裡面,處理這個事件。首先在待辦元件上宣告處理此事件的方法:

<to-do-item :label="item.label"  :done="item.done" :id="item.id" @todo-complete="updateToDoStatus(item.id)" @item-edited="editToDo(item.id,$event)" @item-deleted="deleteToDo(item.id)"></to-do-item>

$event是一個特殊的Vue變數,用於攜帶子元件傳遞過來的資料。現在我們需要在methods新增editToDo和deleteToDo方法:

editToDo (toDoId, newLabel) {
    const toDoEdit = this.ToDoItems.find((item) => item.id === toDoId)
    toDoEdit.label = newLabel
 },
deleteToDo (toDoId) {
      const deleteToDoIndex = this.ToDoItems.findIndex(item => item.id === toDoId)
      this.ToDoItems.splice(deleteToDoIndex, 1)
 }

一個小問題

到目前為止一切看起來都很好,但是如果用一下會發現還是會有一點小問題:

  • 嘗試選中待辦事項的核取方塊

  • 然後點選該待辦事項的的編輯按鈕

點選編輯按鈕

  • 然後點選取消

點選取消

選中狀態被丟失了,待辦事項的統計也出了問題,當你選中會發現統計資料跟你預期的相反,它變成了0:

變成了0

原因在於載入元件時,核取方塊的狀態取決於isDone,而isDone又取決於done,這個done又由外部傳入,所以當我們選中一個初始狀態為未選中的核取方塊然後點選編輯,又點選取消,就相當於待辦元件又重新被載入,丟失了選中的狀態。但是幸運的是解決這個問題也比較簡單,我們可以將isDone轉為一個計算屬性,計算屬性會保留改變。在Vue的官方文件是這麼介紹計算屬性的:

計算屬性是基於它們的響應式依賴進行快取的。只在相關響應式依賴發生改變時它們才會重新求值

這也就是我們將其轉換為計算屬性的時,點選選中按鈕,計算屬性會被計算一次,我們點選取消返回時,由於計算屬性的依賴並沒有發生更新,所以我們的選中狀態得以保留。你看在實踐中我們對Vue的一些理解更加深入了。最初我對計算屬性的理解是相比於在插值表示式中寫邏輯判斷,可維護性更高,就讓template裡面只負責顯示,另一個方面是計算屬性只有在相關響應式依賴發生改變才會重新求值,這對於一些大型頁面來說,如果我們將複雜的邏輯判斷寫在插值表示式裡面,每次渲染都要再執行一遍運算,這會相當損耗效能。現在我們可以藉助計算屬性來保留狀態。

首先我們將待辦元件的data中取消下面這一行:

isDone: this.done,

然後在待辦元件的計算屬性如下宣告:

computed: {
    isDone () {
      return this.done
    }
},

現在你再儲存並重新載入,就會發現問題已經解決。是不是很有成就感了呢!

自定義事件與原生事件

在這個例子中讓我們感到有點不清晰的,恐怕也就是自定義事件和原生事件了吧。下面這個流程圖會讓你對事件流轉更加清晰:

事件流轉圖

ref與焦點管理

我們幾乎完成了一個小型的應用,但目前仔細審視的話,它的體驗仍然有些美中不足。比如我們只用鍵盤來完成上面的操作。我們可以藉助tabs這個鍵盤上的按鈕,來完成編輯、儲存待辦。讓我們重新載入頁面,然後按tab鍵,你會發現待辦的輸入框上會有一個藍色框框,代表我們現在處於輸入框,向下面這樣:

foucs管理

這個藍色的框框我們姑且稱之為焦點(focus) , 再次按下tab鍵,這個焦點會被移動到點選新增按鈕上。再次按tab鍵,焦點會出現在第一個待辦的核取方塊上。接著按,它會停留在第一個待辦的編輯按鈕上。然後按下Enter鍵,然後編輯按鈕消失,出現的是我們的編輯待辦元件,我們的焦點也消失了。這樣的互動可能讓使用者的體驗沒有那麼良好。當你再次按下tab鍵,焦點出現在哪裡,這取決於你使用的瀏覽器。同樣的,如果你按table讓焦點再次出現,按下儲存或取消編輯,焦點會再次消失。

為了給使用者更好的體驗,我們將新增程式碼來控制焦點,以便在編輯表單出現的時候,讓焦點出現在編輯表單的輸入框上。當使用者在編輯表單中取消編輯或儲存編輯,讓焦點重新回到編輯按鈕。 為了做到這一點,我們都需要對Vue如何工作有更加深入一點的理解。

Virtual DOM(虛擬DOM) and refs

Vue和主流的前端框架一樣,選擇使用虛擬DOM來管理結點,這意味著Vue在記憶體中保留了應用程式所有的結點代表(原文為representation),這意味著任何更新都會先到達記憶體中的結點。然後再對頁面實際結點所需的更新進行批次同步。

直接讀寫真實DOM的結點相對於虛擬結點來說是有些昂貴的,虛擬結點會有更好的效能。然後這也意味著在框架裡面你不可以透過瀏覽器的原生APIs來在操縱HTML元素(向Document.getElementById),這會導致虛擬DOM和真實DOM同步出現問題(失去同步 原文為going out of sync)。

但是如果你確實是需要操縱真實DOM的結點(像設定焦點),你可以選擇使用Vue ref。對於自定義的元件,你可以選擇使用refs直接訪問子元件的內部結構,但是注意,要小心使用,這會讓你的程式碼看起來難以理解。

如果你想在元件中使用ref,你需要在想要訪問的元素上新增ref屬性,並未該屬性的值提供字串識別符號。注意在一個元件中ref必須是唯一的。

在待辦元件裡面新增ref

首先我們對ToDoItem.vue進行改造,在編輯按鈕上為它新增ref:

<button type="button" class="btn"  @click="toggleToItemEditForm" ref="editButton">
        編輯 <span class="visually-hidden">{{label}}</span>
</button>

然後我們就可以拿到這個結點了,讓我們在toggleToItemEditForm裡嘗試獲取一下ref:

toggleToItemEditForm () {
      console.log(this.$refs.editButton)
      this.isEditing = true
}

點選編輯按鈕你就會在控制檯發現輸出了ref所在按鈕的結點。

nextTick方法

當使用者儲存或取消他們的編輯時,我們希望焦點回到編輯按鈕上。所以我們要對ToDoItem元件中的itemEdited和editCancelled中的方法進行改造。我們建立一個不帶引數的方法,在這個方法中我們獲取上面的ref,ref目前處於編輯按鈕上,我們拿到這個按鈕然後設定焦點即可。

focusOnEditButton () {
      const editButtonRef = this.$refs.editButton
      editButtonRef.focus() 
}

然後在itemEdited和editCancelled呼叫即可:

itemEdited (newLabel) {
      this.$emit('item-edited', newLabel)
      this.isEditing = false
      this.focusOnEditButton();
},
editCancelled () {
      this.isEditing = false
      this.focusOnEditButton();
},

但是即使做了改造,你嘗試儲存/取消待辦也會發現,焦點沒有按我們預想的回到編輯按鈕上,同時在控制檯也會看到下面的報錯:

[Vue warn]: Error in v-on handler: "TypeError: editButtonRef is undefined"

found in

---> <ToDoItemEditForm> at src/components/ToDoItemEditForm.vue
       <ToDoItem> at src/components/ToDoItem.vue
         <App> at src/App.vue
           <Root> vue.esm.js:5105
TypeError: editButtonRef is undefined
    focusOnEditButton ToDoItem.vue:60
    editCancelled ToDoItem.vue:56
    VueJS 4
    onCancel ToDoItemEditForm.vue:43
    VueJS 38
    toggleToItemEditForm ToDoItem.vue:47
    VueJS 21

當我們點選編輯按鈕的時候,我們還能拿到這個ref,為什麼點選取消和儲存就拿不到了呢? 原因在於當我們點選編輯按鈕,此時將isEditing設定true,我們將不再渲染元件的編輯按鈕,這也就意味著ref引用不到按鈕,因此我們無法獲得編輯按鈕。

但是這也似乎有些說不通,在我們訪問ref之前,我們不是將isEditing 設定為false了嗎?那按鈕不就應該顯示了嗎? 或者說渲染了嗎?那為什麼我們取不到呢?這也就是虛擬DOM發揮作用的地方,Vue試圖最佳化批次更改,所以在DOM上的更新可能不會立馬更新,它會放在一個佇列裡面,因此當我們呼叫focusOnEditButton時,編輯按鈕尚未渲染。我們需要等到下一個DOM的更新週期之後,ref才能獲取到按鈕。

那有沒有什麼辦法能讓在DOM更新之後再呼叫focusOnEditButton方法呢,Vue提供了一個名為$nextTick的方法,該方法接收一個回撥函式,回撥函式會在DOM更新之後被呼叫,所以我們的focusOnEditButton就變成了下面這樣:

focusOnEditButton () {
      this.$nextTick(()=>{
        const editButtonRef = this.$refs.editButton
        editButtonRef.focus() 
      })
}

現在我們我們按編輯進入編輯表單,在編輯表單點選取消或返回會發現編輯按鈕上就出現了焦點。

Vue 的生命週期

Vue的生命週期是一個相當重要的概念,我們在前面的文章都回避了他,原因在於這個概念配合具體了例子,會讓理解更加清晰。現在我們還沒有完成的事情是,在我們點選編輯按鈕的時候,將焦點移動到表單的輸入框,但是編輯按鈕位於待辦元件,編輯待辦的輸入框位於編輯元件。所以我們不能在編輯按鈕的點選事件中設定焦點。我們可以藉助點選編輯按鈕時都會將ToDoItemEditForm元件重新掛載來解決這個問題。

那究竟該如何入手呢? Vue的元件會經歷一系列階段,我們稱之為生命週期。這個生命週期從元素被建立新增到VDOM開始,一直到它們被虛擬DOM中被移除結束。

在每個階段Vue都會有觸發的方法,這對於資料獲取之類的事情會很有用,比如你需要在元件渲染之前或屬性之後獲取資料。每個階段呼叫的方法如下,按觸發順序進行排列:

  1. beforeCreate: 在例項剛在被建立,且未初始化完成時呼叫。
  2. created: 在例項建立完成後呼叫。此時例項已完成初始化,但是還沒有掛載。
  3. beforeMount: 在掛載開始之前被呼叫:相關的 render 函式首次被呼叫。
  4. mounted: 在元件掛載之後呼叫。此時可以訪問例項上的屬性和方法。
  5. beforeUpdate: 在資料更新之前呼叫。
  6. updated: 在資料更新之後呼叫。此時可以訪問更新後的 DOM。
  7. beforeDestroy: 在例項銷燬之前呼叫。
  8. destroyed: 在例項銷燬之後呼叫

現在讓我們為ToDoItemEditForm的輸入框新增一個ref,如下所示:

<input :id="id" type="text" autocomplete="off" v-model.lazy.trim="newLabel"  ref="labelInput" />

接下來讓我們再匯出的script物件中新增一個屬性mounted,注意這個屬性和計算屬性平級,我們在mounted中獲取輸入框的ref。

mounted () {
    const labelInputRef = this.$refs.labelInput
    labelInputRef.focus()
}

注意我們這裡並沒有使用nextTick,原因在於在Vue的宣告週期裡面mounted被呼叫的時候,元件已經被掛載,我們可以訪問屬性和方法了。

刪除的焦點移動

目前還沒有考慮的一個問題是,刪除的時候焦點應該移動到哪裡,我想此時使用者應當關注的是統計資訊,也就是還有多少待辦。讓我們把視線轉移到App.vue中的統計資訊。我們還是為統計資訊新增上ref。

<h2 id="list-summary" ref="listSummary" tabindex="-1">{{listSummary}}</h2>

現在我們已經獲得了統計資訊的ref,我們就可以在刪除待辦的時候,將焦點移動到這上面:

deleteToDo (toDoId) {
  const deleteToDoIndex = this.ToDoItems.findIndex(item => item.id === toDoId)
  this.ToDoItems.splice(deleteToDoIndex, 1)
  this.$refs.listSummary.focus()
}

現在當你刪除一個待辦,焦點將會被移動調待辦的統計資訊上。

總結一下

經過這個例子我們將(一) (二) (三)的概念串聯了起來,在實踐中體會理論最為深刻。按照規劃來說,本身打算三篇介紹完Vue的相關理念,但是後面一邊學一邊發現,一些理念還是在實踐中體會比較深。感謝MDN Web Docs 提供的程式碼示例,非常詳細。

相關文章