揭密 Vue 的雙向繫結

邊城發表於2018-10-05

Vue 中需要輸入什麼內容的時候,自然會想到使用 <input v-model="xxx" /> 的方式來實現雙向繫結。下面是一個最簡單的示例

<div id="app">
    <h2>What's your name:</h2>
    <input v-model="name" />
    <div>Hello {{ name }}</div>
</div>
複製程式碼
new Vue({
    el: "#app",
    data: {
       	name: ""
    }
});
複製程式碼

JsFiddle 演示

jsfiddle.net/0okxhc6f/

在這個示例的輸入框中輸入的內容,會隨後呈現出來。這是 Vue 原生對 <input> 的良好支援,也是一個父元件和子元件之間進行雙向資料傳遞的典型示例。不過 v-model 是 Vue 2.2.0 才加入的一個新功能,在此之前,Vue 只支援單向資料流。

Vue 的單向資料流

Vue 的單向資料流和 React 相似,父元件可以通過設定子元件的屬性(Props)來向子元件傳遞資料,而父元件想獲得子元件的資料,得向子元件註冊事件,在子元件高興的時候觸發這個事件把資料傳遞出來。一句話總結起來就是,Props 向下傳遞資料,事件向上傳遞資料。

上面那個例子,如果不使用 v-model,它應該是這樣的

<input :value="name" @input="name = $event.target.value" />
複製程式碼

由於事件處理寫成了內聯模式,所以指令碼部分不需要修改。但是多數情況下,事件一般都會定義成一個方法,程式碼就會複雜得多

<input :value="name" @input="updateName" />
複製程式碼
new Vue({
    // ....
    methods: {
        updateName(e) {
            this.name = e.target.value;
        }
    }
})
複製程式碼

從上面的示例來看 v-model 節約了不少程式碼,最重要的是可以少定義一個事件處理函式。所以 v-model 實際乾的事件包括

  • 使用 v-bind(即 :)單向繫結一個屬性(示例::value="name"
  • 繫結 input 事件(即 @input)到一個預設實現的事件處理函式(示例:@input=updateName
  • 這個預設的事件處理函式會根據事件物件帶入的值來修改被繫結的資料(示例:this.name = e.target.value

自定義元件的 v-model

Vue 對原生元件進行了封裝,所以 <input> 在輸入的時候會觸發 input 事件。但是自定義元件應該怎麼呢?這裡不妨藉助 JsFiddle Vue 樣板的 Todo List 示例。

JsFiddle 的 Vue 樣板

點選 JsFilddle 的 Logo,在上面彈出皮膚中選擇 Vue 樣板即可

樣板程式碼包含 HTML 和 Vue(js) 兩個部分,程式碼如下:

<div id="app">
  <h2>Todos:</h2>
  <ol>
    <li v-for="todo in todos">
      <label>
        <input type="checkbox"
          v-on:change="toggle(todo)"
          v-bind:checked="todo.done">

        <del v-if="todo.done">
          {{ todo.text }}
        </del>
        <span v-else>
          {{ todo.text }}
        </span>
      </label>
    </li>
  </ol>
</div>
複製程式碼
new Vue({
  el: "#app",
  data: {
    todos: [
      { text: "Learn JavaScript", done: false },
      { text: "Learn Vue", done: false },
      { text: "Play around in JSFiddle", done: true },
      { text: "Build something awesome", done: true }
    ]
  },
  methods: {
  	toggle: function(todo){
    	todo.done = !todo.done
    }
  }
})
複製程式碼

定義 Todo 元件

JsFiddle 的 Vue 模板預設實現一個 Todo 列表的展示,資料是固定的,所有內容在一個模板中完成。我們首先要做事情是把單個 Todo 改成一個子元件。因為在 JsFiddle 中不能寫成多檔案的形式,所以元件使用 Vue.component() 在指令碼中定義,主要是把 <li> 內容中的那部分拎出來:

Vue.component("todo", {
    template: `
<label>
    <input type="checkbox" @change="toggle" :checked="isDone">
    <del v-if="isDone">
        {{ text }}
    </del>
    <span v-else>
        {{ text }}
    </span>
</label>
`,
    props: ["text", "done"],
    data() {
        return {
            isDone: this.done
        };
    },
    methods: {
        toggle() {
            this.isDone = !this.isDone;
        }
    }
});
複製程式碼

原來定義在 App 中的 toggle() 方法也稍作改動,定義在元件內了。toggle() 呼叫的時候會修改表示是否完成的 done 的值。但由於 done 是定義在 props 中的屬性,不能直接賦值,所以採用了官方推薦的第一種方法,定義一個資料 isDone,初始化為 this.done,並在元件內使用 isDone 來控制是否完成這一狀態。

相應的 App 部分的模板和程式碼精減了不少:

<div id="app">
    <h2>Todos:</h2>
    <ol>
        <li v-for="todo in todos">
            <todo :text="todo.text" :done="todo.done"></todo>
        </li>
    </ol>
</div>
複製程式碼
new Vue({
    el: "#app",
    data: {
        todos: [
            { text: "Learn JavaScript", done: false },
            { text: "Learn Vue", done: false },
            { text: "Play around in JSFiddle", done: true },
            { text: "Build something awesome", done: true }
        ]
    }
});
複製程式碼

JsFiddle 演示

jsfiddle.net/0okxhc6f/1/

不過到此為止,資料仍然是單向的。從效果上來看,點選核取方塊可以反饋出刪除線線效果,但這些動態變化都是在 todo 元件內部完成的,不存在資料繫結的問題。

為 Todo List 新增計數

為了讓 todo 元件內部的狀態變化能在 Todo List 中呈現出來,我們在 Todo List 中新增計數,展示已經完成的 Todo 數量。因為這個數量受 todo 元件內部狀態(資料)的影響,這就需要將 todo 內部資料變化反應到其父元件中,這才有 v-model 的用武之地。

這個數量我們在標題中以 n/m 的形式呈現,比如 2/4 表示一共 4 條 Todo,已經完成 2 條。這需要對 Todo List 的模板和程式碼部分進行修改,新增 countDonecount 兩個計算屬性:

<div id="app">
    <h2>Todos ({{ countDone }}/{{ count }}):</h2>
    <!-- ... -->
</div>
複製程式碼
new Vue({
    // ...
    computed: {
        count() {
            return this.todos.length;
        },
        countDone() {
            return this.todos.filter(todo => todo.done).length;
        }
    }
});
複製程式碼

現在計數呈現出來了,但是現在改變任務狀態並不會對這個計數產生影響。我們要讓子元件的變動對父元件的資料產生影響。v-model 待會兒再說,先用最常見的方法,事件:

  • 子元件 todotoggle() 中觸發 toggle 事件並將 isDone 作為事件引數
  • 父元件為子元件的 toggle 事件定義事件處理函式
Vue.component("todo", {
    //...
    methods: {
        toggle(e) {
            this.isDone = !this.isDone;
            this.$emit("toggle", this.isDone);
        }
    }
});
複製程式碼
<!-- #app 中其它程式碼略 -->
<todo :text="todo.text" :done="todo.done" @toggle="todo.done = $event"></todo>
複製程式碼

這裡為 @toggle 繫結的是一個表示式。因為這裡的 todo 是一個臨時變數,如果在 methods 中定義專門的事件處理函式很難將這個臨時變數繫結過去(當然定義普通方法通過呼叫的形式是可以實現的)。

事件處理函式,一般直接對應於要處理的事情,比如定義 onToggle(e),繫結為 @toggle="onToggle"。這種情況下不能傳入 todo 作為引數。

普通方法,可以定義成 toggle(todo, e),在事件定義中以函式呼叫表示式的形式呼叫:@toggle="toggle(todo, $event)"。它和todo.done = $event` 同屬表示式。

注意二者的區別,前者是繫結的處理函式(引用),後者是繫結的表示式(呼叫)

現在通過事件方式已經達到了預期效果

** Js Fiddle 演示 **

jsfiddle.net/0okxhc6f/2/

改造成 v-model

之前我們說了要用 v-model 實現的,現在來改造一下。注意實現 v-model 的幾個要素

  • 子元件通過 value 屬性(Prop)接受輸入
  • 子元件通過觸發 input 事件輸出,帶陣列引數
  • 父元件中用 v-model 繫結
Vue.component("todo", {
    // ...
    props: ["text", "value"],   // <-- 注意 done 改成了 value
    data() {
        return {
            isDone: this.value    // <-- 注意 this.done 改成了 this.value
        };
    },
    methods: {
        toggle(e) {
            this.isDone = !this.isDone;
            this.$emit("input", this.isDone);  // <-- 注意事件名稱變了
        }
    }
});
複製程式碼
<!-- #app 中其它程式碼略 -->
<todo :text="todo.text" v-model="todo.done"></todo>
複製程式碼

.sync 實現其它資料繫結

前面講到了 Vue 2.2.0 引入 v-model 特性。由於某些原因,它的輸入屬性是 value,但輸出事件叫 inputv-modelvalueinput 這三個名稱從字面上看不到半點關係。雖然這看起來有點奇葩,但這不是重點,重點是一個控制元件只能雙向繫結一個屬性嗎?

Vue 2.3.0 引入了 .sync 修飾語用於修飾 v-bind(即 :),使之成為雙向繫結。這同樣是語法糖,新增了 .sync 修飾的資料繫結會像 v-model 一樣自動註冊事件處理函式來對被繫結的資料進行賦值。這種方式同樣要求子元件觸發特定的事件。不過這個事件的名稱好歹和繫結屬性名有點關係,是在繫結屬性名前新增 update: 字首。

比如 <sub :some.sync="any" /> 將子元件的 some 屬性與父元件的 any 資料繫結起來,子元件中需要通過 $emit("update:some", value) 來觸發變更。

上面的示例中,使用 v-model 繫結始終感覺有點彆扭,因為 v-model 的字面意義是雙向繫結一個數值,而表示是否未完成的 done 其實是一個狀態,而不是一個數值。所以我們再次對其進行修改,仍然使用 done 這個屬性名稱(而不是 value),通過 .sync 來實現雙向繫結。

Vue.component("todo", {
    // ...
    props: ["text", "done"],   // <-- 恢復成 done
    data() {
        return {
            isDone: this.done    // <-- 恢復成 done
        };
    },
    methods: {
        toggle(e) {
            this.isDone = !this.isDone;
            this.$emit("update:done", this.isDone);  // <-- 事件名稱:update:done
        }
    }
});
複製程式碼
<!-- #app 中其它程式碼略 -->
<!-- 注意 v-model 變成了 :done.sync,別忘了冒號喲 -->
<todo :text="todo.text" :done.sync="todo.done"></todo>
複製程式碼

** Js Fiddle 演示 **

jsfiddle.net/0okxhc6f/3/

揭密 Vue 雙向繫結

通過上面的講述,我想大家應該已經明白了 Vue 的雙向繫結其實就是普通單向繫結和事件組合來完成的,只不過通過 v-model.sync 註冊了預設的處理函式來更新資料。Vue 原始碼中有這麼一段

// @file: src/compiler/parser/index.js

if (modifiers.sync) {
    addHandler(
        el,
        `update:${camelize(name)}`,
        genAssignmentCode(value, `$event`)
    )
}
複製程式碼

從這段程式碼可以看出來,.sync 雙向繫結的時候,編譯器會新增一個 update:${camelize(name)} 的事件處理函式來對資料進行賦值(genAssignmentCode 的字面意思是生成賦值的程式碼)。

展望

目前 Vue 的雙向繫結還需要通過觸發事件來實現資料回傳。這和很多所的期望的賦值回傳還是有一定的差距。造成這一差距的主要原因有兩個

  1. 需要通過事件回傳資料
  2. 屬性(prop)不可賦值

在現在的 Vue 版本中,可以通過定義計算屬性來實現簡化,比如

computed: {
    isDone: {
        get() {
            return this.done;
        },
        set(value) {
            this.$emit("update:done", value);
        }
    }
}
複製程式碼

說實在的,要多定義一個意義相同名稱不同的變數名也是挺費腦筋的。希望 Vue 在將來的版本中可以通過一定的技術手段減化這一過程,比如為屬性(Prop)宣告新增 sync 選項,只要宣告 sync: true 的都可以直接賦值並自動觸發 update:xxx 事件。

當然作為一個框架,在解決一個問題的時候,還要考慮對其它特性的影響,以及框架的擴充套件性等問題,所以最終雙向繫結會演進成什麼樣子,我們對 Vue 3.0 拭目以待。

相關文章