iT邦幫忙

2022 iThome 鐵人賽

DAY 19
2
Modern Web

真的好想離開 Vue 3 新手村 feat. CompositionAPI系列 第 19

Day 19: 你可能不知道的 v-model - 為何多選綁定陣列不能用 reactive()?

  • 分享至 

  • xImage
  •  

前言

在處理響應式多選表單時,使用 v-model 綁定陣列類型的資料,用 ref() 沒問題,但用 reactive() 卻行不通QQ~為什麼~~~

就是下面這個範例:

當初在響應式篇章,明明說 object 和 array 可以用 reactive() 來做響應的?
我就很開心的把 ref() 改成 reative() 了!
/images/emoticon/emoticon04.gif 結果大踩坑,Vue 不會報錯或警告,但資料就是沒辦法響應。

這裡為懶得打開 Vue 開發環境的人做了個線上 DEMO 範例

總之,我和讀書會夥伴就踏上了找尋真相的路途~!/images/emoticon/emoticon07.gif


在進入原始碼之前,想拋出個問題讓大家想想,這也會是等等研究原碼的重點。

『你有想過 v-model 是透過「改動原陣列」還是「創造新陣列」的方式來更新值嗎?』

v-model 是 Vue 提供的雙向綁定指令,在每次觸發事件時,會「更新」綁定的變數。
為什麼要強調「更新」?這跟 Javascript 的型別有關。

  • 綁定基本型別
    當綁定的變數為基本型別,就沒有什麼好討論的,基本型別本身就是 immutable,必須直接將新值賦值給綁定變數。
const text = ref("")
<input v-model="text">

<input
  :value="text"
  @input="event => text = event.target.value">
  • 綁定陣列 []
    那如果今天對複選欄位做雙向綁定,綁定的值是陣列 [] 呢?

在進入原始碼之前,要不要先猜猜看,v-model 是透過「改動原陣列」還是「創造新陣列」的方式來更新值?


解讀 vModelCheckbox 原始碼

會針對 vModelCheckbox -- v-model 綁定 checkbox 的部份做分析,完整原始碼連結

先看一下使用情境:

const checkedNames = ref([])
<div>Checked names: {{ checkedNames }}</div>

<input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
<label for="jack">Jack</label>

<input type="checkbox" id="john" value="John" v-model="checkedNames">
<label for="john">John</label>

<input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
<label for="mike">Mike</label>

vModelCheckbox 原始碼

export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
  // #4096 array checkboxes need to be deep traversed
  deep: true,
  created(el, _, vnode) {
    el._assign = getModelAssigner(vnode) 
    //調用 `assign` 時會把新的 modelValue 傳進去
    //這個 method 比較複雜,可以先簡單想成更新 modelValue 的值
    
    addEventListener(el, 'change', () => {
      const modelValue = (el as any)._modelValue  //v-model 綁定的值稱為 modelValue
      const elementValue = getValue(el)  //監聽元素 checkbox 所綁定的 value 值
      
      const checked = el.checked //checkbox 是否被打勾
      const assign = el._assign
      
      //如果綁定的 modelValue 是 Array
      if (isArray(modelValue)) {
        const index = looseIndexOf(modelValue, elementValue)
        //找 checkbox 的 value 是否在 modelValue 陣列內*
        //*底下會補充為何不直接用原生的 indexOf
          
        const found = index !== -1 
        //在 modelvalue 陣列內是否有找到對應的 value
        
        //元素被打勾 && modelValue 內沒有 => 表示新打勾的
        if (checked && !found) { 
          assign(modelValue.concat(elementValue)) 
        
        //元素沒有被打勾 && modelValue 內有 => 表示取消打勾
        } else if (!checked && found) {
          const filtered = [...modelValue]
          filtered.splice(index, 1)
          assign(filtered)
        }

      //如果綁定的 modelValue 是 Set,以下不討論
      } else if (isSet(modelValue)) {
          //略,今天不討論
      } else {
        //綁定的 modelValue 不是 Array 或 Set => 單選 checkbox
        assign(getCheckboxValue(el, checked))
      }
    })
  },
  mounted: setChecked,
  beforeUpdate(el, binding, vnode) {
    el._assign = getModelAssigner(vnode)
    setChecked(el, binding, vnode)
  }
}

補充說明:

  • looseIndex
    為什麼不用原生的indexOf,是因為 Vue 支援 checkbox value 綁定非基本型別(如:物件),而原生 Javascript 方法只能判斷物件的參照是否相同,不能判斷物件內的值是否相同,所以要調用 Vue 自己定義的 looseIndexOf 函式,來比較物件項目的內部值是否相同。

今日重點:v-model 如何「更新」陣列

前面有提到,要特別研究:v-model 如何處理「陣列型別 modelValue 的更新」
主要相關的程式碼是這段:

//元素被打勾 && modelValue 內沒有 => 表示新打勾的
if (checked && !found) { 
  assign(modelValue.concat(elementValue)) 

//元素沒有被打勾 && modelValue 內有 => 表示取消打勾
} else if (!checked && found) {
  const filtered = [...modelValue]
  filtered.splice(index, 1)
  assign(filtered)
}

這裡調用的方法相信大家都不陌生,不管是新增或刪除,v-model 都不會直接更動原本的陣列(modelValue),而是創造一個指向不同參照的新陣列。

  • 新增:
    • Array.prototype.concat() 會合併並回傳新陣列,不會影響原陣列
  • 刪除:
    • [...modelValue] 透過解構賦值展開,可以拿到一個新陣列
    • 再透過 Array.prototype.splice() 直接修改新陣列

也就是說,v-model 是透過重新賦值一個新陣列,來更新 modelValue 的值

「更新」陣列和 ref&reactive 的關係

大家還記得 refreactive 的特性嗎?

長話短說~
reactive() 是利用 Proxy 來實作,所以 reactive 物件不能被重新賦值,這也是 v-model 多選綁定 reactive([]) 會失效的原因。

註1: RefImpl.value 值指向物件依然可以被重新賦值
註2: 還不清楚 refreactive 的特性的人可以參考之前的文章,這裡就不再贅述。
Day 10: 從原生 JS 理解 Vue 3 響應式基礎 - reactive & ref (上)
Day 11: 從原生 JS 理解 Vue 3 響應式基礎 - reactive & ref (下)

為什麼不支援 reactive

不能用 reactive() 做 v-model 陣列的響應,這難道不是個 bug 嗎?

不是

我們在 vue.js 的 github 上找到一串相關 issue。
vue.js 一度採納 issue 提案,讓 v-model 多選陣列能支援 reactive() 響應,但後來發現,這項變動的負面成本比預期的還大,所以幾乎是一天內又改回來了。

透過 reactive()響應的資料不可被重新賦值,也就是說,如果 v-model 要支援 reactive 陣列,必須直接在 v-model 中改動(mutate) 綁定的陣列

其中一個最大的影響就是,v-model + computed 的搭配用法會失效,modelValue 值的更新會繞過 computed 的 setter!
為什麼?
這樣操作會直接改動原本的陣列,而且陣列參照沒有改變,不會無法觸發 computed 的 setter。

v-model + computed 的搭配使用法參考如下:
現在的 Vue 是透過重新賦值來更新陣列,所以下面這個用法是沒問題的!

<input v-model="inputValue">
const inputValue = computed({
  get: () => props.value,
  set: newValue => emit('update:value', newValue)
})
})

如果要在現行 Vue 版本下模擬相似的情境,我覺得可以參考下面的程式碼,線上 DEMO 連結

const arr = reactive([]);

const computedArr = computed({
  get() {
    window.alert(`getting`)
    return arr
  },
  set(newValue) {
    window.alert(`getting`)
    //其實也不需要了,因為陣列直接被改動了
    arr = newValue 
  },
})
<button @click="computedArr.push("Jack")">push</button>
<p>arr:{{ arr }}</p>
<p>computedArr:{{ computedArr }}</p>

我是用點擊觸發 computedArr.push("Jack") 模擬 v-model 直接操作 reactive 陣列的部份,每次 push,原始的 arr 會直接被改動,而且每次改動陣列都無法觸發 computedArr 的 setter。

參考 issue: Checkbox v-model array mutation#2700

小結

  1. v-model 透過重新賦值來更新 modelValue(當型別為基本型別或陣列)
  2. reactive() 物件不能重新賦值,陣列型別的 modelValue 只能透過 ref() 來做響應
  3. 不支援 reactive() 並不是 bug,是為了避免在 v-model 中改動原陣列,會導致非預期情況出現,負面效益大於正面效益

最後

以上是我和讀書會夥伴們研究的結果。
我自己覺得有點難解釋,如果有什麼說明不清楚的地方,歡迎留言告訴我~
有什麼不同的想法,或發現有解讀不恰當的地方,也歡迎留言一起討論!
(但我可能要鐵人賽後才會出現回覆QQ)/images/emoticon/emoticon02.gif
最後,感謝耐心看到最後的你~

參考資料

補充資料

對於 v-model 和原生比較,可以參考下列文章:
Day27 前端蛇行撞牆記 - 表單輸入 input 原生及 v-model 比較 / Vue 3 (上)
Day28 前端蛇行撞牆記 - input 原生及 v-model 比較 / Vue 3 (下)


上一篇
Day 18: v-slot 到底用在哪?從應用學 v-slot 語法
下一篇
Day 20: 在發 API 之前 - 先學 axios 基礎與封裝管理 API
系列文
真的好想離開 Vue 3 新手村 feat. CompositionAPI31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言