iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Modern Web

一個活了六年的 Vue 2 後台,升級到 Vue 3 的那兩年系列 第 7

Day 7|一個檔名拼錯六年的 mixin,和 `this` 消失的意義

  • 分享至 

  • xImage
  •  

模組二|選型、語言遷移與地基(Day 6–11)

舊專案裡有一個 14 行的檔案,我第一次打開它的時候看了三分鐘,然後決定把它寫成一整篇。

它同時示範了三個問題,而這三個問題正好解釋了「為什麼 Vue 3 要把 this 拿掉」。

先鋪一點背景。從 Vue 2 搬到 Vue 3,表面上是換寫法:data() 變成 ref()methods 變成一般函式、this.xxx 變成 xxx.value。網路上一堆對照表,看起來像查字典就能做完。

但實際搬過之後我的感覺是:真正改變的不是語法,是「一個元件需要什麼」這件事被記錄在哪裡

先講 Options API 的 this 是什麼

Vue 2 的元件長這樣:

export default {
  data() {
    return { count: 0 }
  },
  methods: {
    add() {
      this.count++        // this 拿得到 data
      this.formatNumber() // this 也拿得到別的 method
    },
  },
}

這裡的 this 是一個集散地。Vue 在背後把很多東西掛上去:

  • 你的 data
  • 你的 props
  • 你的 methodscomputed
  • mixins 注入的東西
  • 外掛掛上去的東西(Day 4 講的那些 Vue.prototype

好處是很方便,全部 this. 就有了。

代價是:當你在一個元件裡看到 this.someThing,你沒辦法只看這個檔案就知道它從哪來。

Day 4 講的全域魔法之所以會長成那樣,根源就在這裡:這個 API 設計讓「往 this 上丟東西」變成阻力最小的路徑。

一個 14 行的檔案,三個問題

理論講完,來看真的東西。

舊專案裡有一個 mixin 檔案,總共 14 行。我把它完整貼出來(變數名沒改,這就是原文):

export default {
    methods: {
        init_form_base_with_module(name, module) {
            this[name] = null
            this[name] = this.deep_clone(module)
        },
        abort_dialog_form() {
            // this.close_dialog_base(name)
        },
        submit_dialog_form() {
            // this.close_dialog_base(name)
        },
    },
}

14 行,三個問題。

問題一:檔名拼錯了

這個檔案叫 formminxin.js

正確拼法是 mi-x-in,這裡打成了 mi-nx-in。

它已經這樣存在六年了。沒有人改,因為改了要動 import 路徑,而你不確定還有誰在引用它。一個純粹的打字錯誤,因為沒有工具能保證安全改名,就變成了永久的。

問題二:兩個方法是空的

我用整個身體去拉這條繩,鈴晃得很兇,卻連一點聲音都沒有

abort_dialog_formsubmit_dialog_form 這兩個方法,函式主體整個被註解掉了。

它們現在是兩個什麼都不做的空函式。

但它們還在。而且因為 mixin 的特性,任何用了這個 mixin 的元件,都「擁有」這兩個什麼都不做的方法。如果有元件在呼叫 this.submit_dialog_form(),它會安靜地什麼都不發生,不報錯,只是沒反應。

問題三:最嚴重的——this.deep_clone 從哪來?

看第一個方法:

init_form_base_with_module(name, module) {
    this[name] = null
    this[name] = this.deep_clone(module)   // ← deep_clone 是誰?
}

deep_clone 不在這個檔案裡。這個檔案也沒有 import 任何東西。

我搜了一下,答案是:它在另一個 mixin 檔案裡(commontoolmixin.js 的第 49 行)。

也就是說,這個 mixin 依賴另一個 mixin,但這個依賴關係沒有寫在任何地方。它能跑,純粹是因為有個檔案剛好把兩個一起匯出:

import commontoolmixin from '@/mixins/module/commontoolmixin'
import formminxin from '@/mixins/module/formminxin.js'

export default [commontoolmixin, formminxin]   // 剛好一起

如果有人只引入其中一個,它就會在使用者按下按鈕的那一刻爆掉,錯誤訊息是 this.deep_clone is not a function

而且,編譯不會報錯、ESLint 不會報錯、IDE 不會標紅。這個地雷會一路走到正式環境。

mixins 的三個致命傷

那 14 行剛好把 mixin 的三個問題全示範了一遍:

一、來源不明。 元件裡出現一個沒定義的方法,你要翻遍所有 mixin 才找得到。

二、命名衝突會無聲覆蓋。 兩個 mixin 都有同名方法時,後面的贏,沒有任何警告。順帶一提,舊專案裡有兩個檔名都叫 numberMixin.js 的檔案(一個 84 行、一個 200 行),分別放在兩代統計模組底下。它們目前沒有被同一個元件引入,所以還沒出事,但這種安全是靠運氣的。

三、不敢刪。 就像那個拼錯的檔名。你不知道誰在用,所以只能留著。

composable 差在哪裡

整袋我沒扛,我只夾了兩件掛上腰帶,而且每一件下面都吊著名字

Vue 3 的做法是把邏輯抽成一般函式(習慣上叫 composable,命名以 use 開頭):

// useFormBase.js
import { deepClone } from '@/utils/clone'   // ← 依賴寫出來了

export function useFormBase() {
  function initFormBase(module) {
    return deepClone(module)
  }
  return { initFormBase }
}

元件裡這樣用:

const { initFormBase } = useFormBase()

差別不在語法,在這一行:

const { initFormBase } = useFormBase()

這一行本身就是文件。 它明白地說了:這個元件用了 useFormBase,而且只拿了 initFormBase 這個東西。

三個致命傷同時被解掉:

  • 來源明確 → 有 import,IDE 點一下就跳過去
  • 命名衝突 → 兩個 composable 回傳同名的東西,你在解構那一行就得處理,編譯器會逼你面對
  • 敢刪 → 沒有人 import 它,IDE 直接告訴你可以刪

這件事有個名字:組合優於繼承

我後來才知道,這個轉變有個現成的說法叫 組合優於繼承(Composition over Inheritance)。

用一個場景理解:

繼承是你從爸爸那裡繼承了一整棟房子。房子很棒,但裡面附贈了一個你根本不需要的閣樓、一個會漏水的地下室,而且你沒辦法只繼承客廳。

組合是你去買樂高。要什麼拼什麼,每一塊你都知道自己為什麼拿它。組起來可能比較費工,但沒有一塊是你不知道為什麼在那裡的。

mixin 是「繼承」那一邊:你混入它,就得接受它帶來的全部東西,包括那兩個空函式和一個隱形依賴。

composable 是「樂高」那一邊:你只拿你解構出來的那幾個。

為什麼我們沒有用自動轉換工具

社群有一些工具號稱可以把 Options API 自動轉成 Composition API。我們沒有用。

原因很簡單:自動轉出來的東西,是「用新語法寫的舊程式碼」。

工具能做的是把 this.count 換成 count.value、把 methods 攤平成函式。但它不會幫你解決今天講的任何一個問題:

  • 那個隱形的 deep_clone 依賴,轉完之後還是隱形的(工具不知道它從哪來,多半會直接放棄或標記錯誤)
  • 那兩個空函式,會被完整地轉成新語法的空函式
  • 檔名還是拼錯的

換句話說:你花了力氣,得到一份長得比較新、但一樣難改的程式碼。

而 Composition API 的價值,九成來自「你被迫把依賴寫出來」這件事。如果你用工具跳過了這個過程,那你只是換了語法。

所以我們的做法是:一個一個重寫,而不是轉換。 慢很多,但每個檔案在重寫的過程中,都會有人被迫回答「這個東西到底從哪來」。

順帶一提,我們也沒有走「先升級到 Vue 2 的最後一版、裝上相容套件、再慢慢轉」這條中間路線。舊專案的版本停在 Vue 2.5,連 Vue 2 自己的最終版本都沒跟上,中間要補的東西也不少,與其分兩段走,不如一次到位。

<script setup> 一律用

還有一個小決定,但影響很大:新專案的元件一律用 <script setup> 這種寫法。

理由只有一個:它讓「這個元件用到什麼」等於檔案最上方的 import 清單。

你打開一個檔案,往上捲到頂,就知道它依賴哪些東西。不需要搜尋、不需要翻 mixin、不需要問人。

這聽起來是小事,但對照今天那個 this.deep_clone(「答案就在檔案最上面」和「答案在另一個你不知道存在的檔案裡」),是完全不同的維護體驗。

那 330 次 this.$refs 怎麼辦?

Day 2 量到舊專案有 330 次「父元件直接伸手進子元件呼叫方法」。

搬到 Vue 3 之後,這種寫法還是能用(語法變成 ref() 加上 defineExpose),而且有了型別,甚至更安全一點。

但我們處理的方式是重寫每一頁的時候問一句:為什麼需要這個 ref?不是逐句翻譯它。

答案通常是三種:

原本想做的事 更好的做法
呼叫子元件的「重新查詢」 把查詢條件變成 props,子元件自己 watch
呼叫子元件的「清空表單」 把表單狀態拉到父層,或用 key 強制重建
拿子元件的「目前選取值」 v-modelemit 往上送

三種的共同點是:把「我知道你內部有什麼方法」換成「我們約定好要傳什麼資料」。

這正是 Day 2 那個借醬油的故事:從「自己開門進廚房翻櫃子」,改回「按門鈴說我要什麼」。

代價

這個決定不是沒有成本,有三個很實在的:

一、慢很多。 一個一個重寫,比跑工具慢一個量級。這也是為什麼核心重構要花六個月(Day 5)。

二、<script setup> 加上 Composition API,程式碼不一定變短。 很多時候還變長了,因為原本靠 this 隱式拿到的東西,現在每一個都要寫出來。這是我們用「多打幾個字」換「看得見依賴」的交易,我覺得划算,但你要知道自己在買什麼。

三、寫法自由帶來新的不一致。 Options API 至少強迫大家把東西放在固定位置;Composition API 你想怎麼排都可以。如果沒有團隊約定,一個檔案裡的 ref 可能散落各處。 這件事我們後來是靠專案守則和 review 補的,不是靠框架。

帶走什麼

  1. Composition API 的價值不是寫得比較短,是讓依賴關係從隱性變顯性。 如果你搬過去還是一坨 this,那只是換了語法。
  2. 不要用自動轉換工具做這種遷移。 你會失去這次遷移最大的收穫:那個「被迫回答依賴從哪來」的過程。
  3. mixin 最危險的不是它做了什麼,是它偷偷假設了什麼。 那個 this.deep_clone 能跑六年,純粹是因為運氣好。
  4. 看到 this.$refs 的時候,先問「為什麼需要它」,而不是「Vue 3 要怎麼寫」。 多數時候答案是不需要。

明天 Day 8 是 Vue 2 → Vue 3 的下半場。我會從一個發現講起:舊專案的狀態管理註冊了 12 個模組,其中六個打開來只有兩行(一個空物件)。


上一篇
Day 6|官方要 monorepo,我們把 93% 的程式碼留在根目錄
下一篇
Day 8|Vuex 裡有六個從沒打開過的空模組
系列文
一個活了六年的 Vue 2 後台,升級到 Vue 3 的那兩年12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言