半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

Vue ref 語法糖與 Svelte

Vue ref 語法糖與 Svelte

前言

在 10/28 時尤雨溪提出了一個 RFC,是關於 ref 宣告的語法中,可以用 JavaScript 的 label statement 來進一步做簡化。 這個語法跟 Svelte 如出一徹,在這邊紀錄一下自己的想法。

先來看看範例當中的程式碼:

<script setup>
// 透過 label statement 語法宣告 ref 
ref: count = 1

function inc() {
  // 可以直接取用變數
  count++
}

// 或是直接使用 $ 當作前綴來拿 ref
console.log($count.value)
</script>

<template>
  <button @click="inc">{{ count }}</button>
</template>

在 Vue 3 當中透過 Composition API,可以透過 ref 將變數變成具有 reactive 的效果。宣告方式像這樣:(程式碼為文件範例)

const count = ref(0)
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

經過 ref 宣告之後會有 reactive 的效果,所以在 template 當中使用的話畫面也會即時做更新:(程式碼為文件範例)

<template>
  <div>{{ count }}</div>
</template>

<script>
  export default {
    setup() {
      return {
        count: ref(0)
      }
    }
  }
</script>

在這邊有一個地方要注意的是,每次宣告 ref 之後要存取值需要加上 .value,雖然不是什麼大問題,但還是引入了一些 overhead,在文件當中也有提到:

  • 使用者需要知道他現在使用的是一般的變數,還是被 ref 包起來的變數(不過這可以透過 naming convention 或是 TypeScript 解決)
  • 每次讀取值需要另外 .value

於是作者有了 Ref Sugar 這個提案:

<script setup>
// 透過 label statement 語法宣告 ref 
ref: count = 1

function inc() {
  // 可以直接取用變數
  count++
}

// 或是直接使用 $ 當作前綴來拿 ref
console.log($count.value)
</script>

<template>
  <button @click="inc">{{ count }}</button>
</template>

上面的程式碼經過轉換後會變成:

<script setup>
  const count = ref(1);
  function inc() {
    count.value++;
  }

  console.log(count.value)
</script>

<template>
  <button @click="inc">{{ count }}</button>
</template>

可以看到這個語法糖隱藏了 ref 本身的宣告,同時可以直接去存取變數不需要再用 .value 來讀取,進而減少了開發者的心智負擔。

目前社群大多持反對意見,大概有以下幾點:

  • 這不是一個合法的 JavaScript 語法
  • Vue 重新定義了 label statement 的語意
  • 沒有透過 let, const 來宣告變數
  • 需要經過一層編譯,等於多了一個 magic
  • ...

有興趣的話可以到 RFC 上面去看看,有蠻多討論的角度都挺有趣的。

作者本人也說,這樣的想法借鏡於 Svelte,我們現在來看看 Svelte 是怎麼使用這個語法的:

<script>
	let name = 'world';
	// 每次 name 有更新時重新執行 console.log(name)
	$: console.log(name);
  // 每次 name 有更新時賦值給 name2
	$: name2 = name.toUpperCase();
  // ???
	$: console.log('');
	
</script>

任何以 $ 當作標籤的程式碼,會被 Svelte 另外編譯成這個樣子:

...
let name2;
$$self.$$.update = () => {
  if ($$self.$$.dirty & /*name*/ 1) {
    $: console.log(name);
  }

  if ($$self.$$.dirty & /*name*/ 1) {
    $: name2 = name.toUpperCase();
  }
};

$: console.log("");

這邊的語法我們先不管,Svelte 會將標籤後宣告的程式碼在編譯後放到 update 這個函數當中,當元件有更新時就會執行這一段函數,同時 Svelte 會在裡頭檢查變數是否有更新再決定要不要執行程式碼。

同時也可以發現最下面的 console.log("")$: 的程式碼片段裡頭沒有用到元件當中的變數的話,並不會放到 update 函數當中,這是 Svelte 在編譯時做得優化之一。

如果有兩個變數以上時,Svelte 可以知道哪些變數有變化,並且執行對應的程式碼:

let name = 'world';
let count = 0;

// 只有 name 有變化時才會執行
$: console.log(name);
// 只有 count 有變化時才會執行
$: console.log(count);

詳細原理會在其他文章作介紹

在這邊可以知道 Svelte 會盡可能地追蹤依賴,如果轉換成 React 的語法,大概會像這個樣子:

const [name, ] = useState('world');
const [count, ] = useState(0);

useEffect(() => {
  console.log(name);
}, [name]);

useEffect(() => {
  console.log(count)
}, [count])

也就是說 Svelte 可以透過編譯的方式事先知道內部有哪些依賴,並且幫你做處理,這樣一來就不需要在 runtime 時顯式地去宣告依賴。當然這部分有好有壞,也包含一連串的取捨,我們以後再來細提。

從這裡可以發現,透過語法與編譯的幫助,可以大幅簡化程式碼,簡潔的程式碼可以降低開發人員的心智負擔。

也因為如此很容易被人當成是變魔術。為了讓討論更加集中,需要先釐清變魔術的定義是什麼,因此我在這邊將魔術定義為:

  • 程式碼行為不符合預期(例如改寫 label 的語意)
  • 程式碼與實際編譯後的程式碼經過太多步驟處理(label 的程式碼編譯後完全不一樣)

基於這兩者的討論就可以衍伸出各式各樣的辯論了:

  • JSX、template、SFC 經過許多步驟處理最後變成 JavaScript 程式碼,算不算是變魔術?為什麼開發者普遍接受這樣的語法?
  • v-if v-show v-for 等框架提供的樣板語法算不算是變魔術?
  • React 的 onChange 事件和原生瀏覽器的 onChange 不同,算不算重新定義了標準?(參考

所以單純從變魔術的角度來看,每個框架本身或多或少都存在著魔術,單就變魔術這個角度來看論點似乎有點薄弱。

不過單從 Vue Ref Sugar 來看這個語法糖能夠帶來的好處其實蠻有限,他作用的效果與 Svelte 不同,Svelte 的 $: 的語意是當內部的依賴有改變時重新執行程式碼,而 Vue 的 ref: 則是單純宣告一個 ref 變數而已,雖然語法相近但功能上完全不同。

另外雖然在 Svelte 當中,任何在元件裡 <script> 的變數宣告都會是 reactive,但是 Svelte 並沒有提供類似的機制在元件外部宣告。也就是說雖然 svelte 可以做到這樣:

// Component.svelte
<script>
  // reactive by default
  let state1 = 0;
  // reactive by default
  let state2 = 1;
  
	function doSomething() {
    let state3 = 0; // not reactive
  }
</script>

<span>{state1}</span>
<span>{state2}</span>

但是沒辦法將 state1state2 拆出來給外部:

// 假的,沒有這種 API
export const useStates = () => {
  const state1 = makeReactive(0);
  const state2 = makeReactive(1);
  
  return [state1, state2];
};
// Svelte 沒辦法這樣寫
<script>
  import { useStates } from './useStates';
	const [state1, state2] = useStates();
</script>

<span>{state1}</span>
<span>{state2}</span>

如果要達成類似的效果,就要使用 Svelte 當中的 store 來做宣告,不過在範圍上就有差異了。Store 本身是全域的,一旦被 subscribe 之後就會被其他元件共享,但是 composition API 本身可以作用在元件外部,單獨宣告成一個函數使用。

因為任何的變數都要放在 Svelte 元件中,所以一旦變數一多就很難做拆分,在維護上也會比較不容易一些,尤其是習慣不好的話變數到處亂宣告反而會變得不好管理。希望 Svelte 以後也有類似的機制可以在外部宣告 reactive 變數來簡化元件內的狀態。