Vue 3 中的按需響應式
已發表: 2022-03-11除了令人欽佩的性能改進之外,最近發布的 Vue 3 還帶來了幾個新特性。 可以說最重要的介紹是Composition API 。 在本文的第一部分,我們回顧了新 API 的標準動機:更好的代碼組織和重用。 在第二部分中,我們將專注於使用新 API 的討論較少的方面,例如實現在 Vue 2 的反應性系統中無法表達的基於反應性的功能。
我們將其稱為按需反應性。 在介紹了相關的新特性之後,我們將構建一個簡單的電子表格應用程序來展示 Vue 響應式系統的新表現力。 最後,我們將討論這種改進的按需響應性在現實世界中的用途。
Vue 3 的新特性及其重要性
Vue 3 是對 Vue 2 的一次重大改寫,引入了大量改進,同時幾乎完全保留了與舊 API 的向後兼容性。
Vue 3 中最重要的新特性之一是Composition API 。 首次公開討論時,它的引入引發了很多爭議。 如果您還不熟悉新 API,我們將首先描述其背後的動機。
通常的代碼組織單元是一個 JavaScript 對象,它的鍵代表一個組件的各種可能類型。 因此,該對象可能有一個部分用於響應數據( data
),另一個部分用於計算屬性( computed
),另一個部分用於組件方法( methods
)等。
在這種範式下,一個組件可以具有多個不相關或鬆散相關的功能,其內部工作分佈在上述組件部分之間。 例如,我們可能有一個用於上傳文件的組件,它實現了兩個本質上獨立的功能:文件管理和控制上傳狀態動畫的系統。
<script>
部分可能包含如下內容:
export default { data () { return { animation_state: 'playing', animation_duration: 10, upload_filenames: [], upload_params: { target_directory: 'media', visibility: 'private', } } }, computed: { long_animation () { return this.animation_duration > 5; }, upload_requested () { return this.upload_filenames.length > 0; }, }, ... }
這種傳統的代碼組織方法有很多好處,主要是開發人員不必擔心在哪裡編寫新代碼。 如果我們要添加一個反應變量,我們將其插入data
部分。 如果我們正在尋找一個現有的變量,我們知道它必須在data
部分。
這種將功能實現拆分為部分( data
、 computed
等)的傳統方法並不適用於所有情況。
經常引用以下例外情況:
- 處理具有大量功能的組件。 例如,如果我們想升級我們的動畫代碼以延遲動畫的開始,我們將不得不在代碼編輯器中組件的所有相關部分之間滾動/跳轉。 對於我們的文件上傳組件,組件本身很小,它實現的功能數量也很少。 因此,在這種情況下,在各個部分之間跳轉並不是真正的問題。 當我們處理大型組件時,這個代碼碎片問題變得很重要。
- 傳統方法缺乏的另一種情況是代碼重用。 通常,我們需要在多個組件中提供反應數據、計算屬性、方法等的特定組合。
Vue 2(以及向後兼容的 Vue 3)為大多數代碼組織和重用問題提供了解決方案: mixins 。
Vue 3 中 Mixins 的優缺點
Mixin 允許在單獨的代碼單元中提取組件的功能。 每個功能都放在一個單獨的 mixin 中,每個組件都可以使用一個或多個 mixin。 在 mixin 中定義的片段可以在組件中使用,就像它們在組件本身中定義一樣。 mixin 有點像面向對象語言中的類,因為它們收集與給定功能相關的代碼。 與類一樣,mixin 可以在其他代碼單元中繼承(使用)。
但是,使用 mixins 進行推理比較困難,因為與類不同,mixin 的設計不需要考慮封裝。 Mixin 可以是鬆散綁定的代碼片段的集合,沒有明確定義的外部世界接口。 在同一組件中一次使用多個 mixin 可能會導致組件難以理解和使用。
大多數面向對象的語言(例如,C# 和 Java)不鼓勵甚至不允許多重繼承,儘管面向對象的編程範例具有處理這種複雜性的工具。 (某些語言確實允許多重繼承,例如 C++,但組合仍然比繼承更受歡迎。)
在 Vue 中使用 mixin 時可能出現的一個更實際的問題是名稱衝突,當使用兩個或多個 mixins 聲明通用名稱時會發生這種問題。 這裡需要注意的是,如果 Vue 處理名稱衝突的默認策略在給定情況下並不理想,則可以由開發人員調整該策略。這是以引入更多複雜性為代價的。
另一個問題是 mixins 不提供類似於類構造函數的東西。 這是一個問題,因為我們經常需要非常相似但不完全相同的功能出現在不同的組件中。 在一些簡單的情況下,可以使用 mixin 工廠來規避這種情況。
因此,mixin 並不是代碼組織和重用的理想解決方案,而且項目越大,它們的問題就越嚴重。 Vue 3 引入了一種新方法來解決有關代碼組織和重用的相同問題。
組合 API:Vue 3 對代碼組織和重用的回答
組合 API 允許我們(但不要求我們)完全解耦組件的各個部分。 每一段代碼——變量、計算屬性、手錶等——都可以獨立定義。
例如,我們現在可以編寫(在我們的 JavaScript 代碼中的任何位置),而不是讓一個對象包含一個data
部分,該部分包含一個鍵animation_state
和(默認)值“正在播放”:
const animation_state = ref('playing');
效果幾乎和在某些組件的data
部分聲明這個變量一樣。 唯一的本質區別是我們需要使在組件外部定義的ref
在我們打算使用它的組件中可用。 我們通過將其模塊導入到定義組件的位置並從組件的setup
部分返回ref
來做到這一點。 我們現在將跳過這個過程,只關注新的 API 片刻。 Vue 3 中的反應性不需要組件; 它實際上是一個獨立的系統。
我們可以在我們將此變量導入到的任何範圍內使用變量animation_state
。 在構造一個ref
之後,我們使用ref.value
獲取並設置它的實際值,例如:
animation_state.value = 'paused'; console.log(animation_state.value);
我們需要“.value”後綴,因為賦值運算符會將(非反應性)值“暫停”分配給變量animation_state
。 JavaScript 中的響應性(無論是在 Vue 2 中通過defineProperty
實現,還是在 Vue 3 中基於Proxy
實現時)都需要一個對象,我們可以響應地使用其鍵。
請注意,在 Vue 2 中也是如此; 在那裡,我們有一個組件作為任何反應數據成員( component.data_member
)的前綴。 除非 JavaScript 語言標準引入了重載賦值運算符的能力,否則反應式表達式將需要一個對象和一個鍵(例如,上面的animation_state
狀態和value
)出現在我們希望的任何賦值操作的左側保持反應性。
在模板中,我們可以省略.value
,因為 Vue 必須預處理模板代碼並且可以自動檢測引用:
<animation :state='animation_state' />
理論上,Vue 編譯器也可以以類似的方式預處理單個文件組件 (SFC) 的<script>
部分,在需要的地方插入.value
。 但是,根據我們是否使用 SFC,對refs
的使用會有所不同,所以也許這樣的特性甚至是不可取的。
有時,我們有一個我們從不打算用完全不同的實例替換的實體(例如,一個 Javascript 對像或數組)。 相反,我們可能只對修改其關鍵字段感興趣。 在這種情況下有一個簡寫:使用reactive
而不是ref
可以讓我們省去.value
:
const upload_params = reactive({ target_directory: 'media', visibility: 'private', }); upload_params.visibility = 'public'; // no `.value` needed here // if we did not make `upload_params` constant, the following code would compile but we would lose reactivity after the assignment; it is thus a good idea to make reactive variables ```const``` explicitly: upload_params = { target_directory: 'static', visibility: 'public', };
使用ref
和reactive
解耦反應性並不是 Vue 3 的全新特性。它在 Vue 2.6 中部分引入,其中這種解耦的反應性數據實例被稱為“可觀察對象”。 在大多數情況下,可以將Vue.observable
替換為reactive
。 區別之一是直接訪問和改變傳遞給Vue.observable
的對像是反應式的,而新的 API 返回一個代理對象,因此改變原始對像不會產生反應式效果。

Vue 3 的全新之處在於,除了響應式數據之外,組件的其他響應式部分現在也可以獨立定義。 計算屬性以預期的方式實現:
const x = ref(5); const x_squared = computed(() => x.value * x.value); console.log(x_squared.value); // outputs 25
同樣,可以實現各種類型的手錶、生命週期方法和依賴注入。 為簡潔起見,我們不會在這裡介紹這些內容。
假設我們使用標準 SFC 方法進行 Vue 開發。 我們甚至可能使用傳統的 API,數據、計算屬性等具有單獨的部分。我們如何將 Composition API 的少量反應性與 SFC 集成? Vue 3 為此引入了另一個部分: setup
。 新部分可以被認為是一個新的生命週期方法(它在任何其他鉤子之前執行 - 特別是在created
之前)。
下面是一個將傳統方法與 Composition API 相集成的完整組件示例:
<template> <input v-model="x" /> <div>Squared: {{ x_squared }}, negative: {{ x_negative }}</div> </template> <script> import { ref, computed } from 'vue'; export default { name: "Demo", computed: { x_negative() { return -this.x; } }, setup() { const x = ref(0); const x_squared = computed(() => x.value * x.value); return {x, x_squared}; } } </script>
從這個例子中得到的東西:
- 所有 Composition API 代碼現在都在
setup
中。 您可能希望為每個功能創建一個單獨的文件,將該文件導入 SFC,並從setup
中返回所需的反應位(以使它們可用於組件的其餘部分)。 - 您可以在同一個文件中混合使用新方法和傳統方法。 請注意,
x
,即使它是一個引用,在模板代碼或組件的傳統部分(如computed
)中引用時也不需要.value
。 - 最後但同樣重要的是,請注意我們的模板中有兩個根 DOM 節點; 擁有多個根節點的能力是 Vue 3 的另一個新特性。
反應性在 Vue 3 中更具表現力
在本文的第一部分,我們談到了 Composition API 的標準動機,即改進代碼組織和重用。 確實,新 API 的主要賣點不是它的強大功能,而是它帶來的組織便利性:能夠更清晰地組織代碼。 看起來就是這樣——Composition API 實現了一種實現組件的方式,避免了現有解決方案(例如 mixins)的限制。
但是,新的 API 還有更多內容。 組合 API 實際上不僅支持更好的組織,而且支持更強大的響應式系統。 關鍵因素是能夠動態地向應用程序添加反應性。 以前,必須在加載組件之前定義所有數據、所有計算屬性等。 為什麼在後期添加響應式對象會很有用? 在剩下的部分中,我們看一個更複雜的例子:電子表格。
在 Vue 2 中創建電子表格
Microsoft Excel、LibreOffice Calc 和 Google Sheets 等電子表格工具都有某種反應系統。 這些工具向用戶展示了一個表格,其中列按 A–Z、AA–ZZ、AAA–ZZZ 等索引,行按數字索引。
每個單元格可能包含一個普通值或一個公式。 具有公式的單元格本質上是一個計算屬性,它可能取決於值或其他計算屬性。 使用標準電子表格(與 Vue 中的反應系統不同),這些計算屬性甚至可以依賴於它們自己! 這種自引用在某些通過迭代逼近獲得期望值的場景中很有用。
一旦單元格的內容髮生變化,所有依賴於該單元格的單元格都會觸發更新。 如果發生進一步的變化,可能會安排進一步的更新。
如果我們要使用 Vue 構建電子表格應用程序,自然會問我們是否可以使用 Vue 自己的反應系統,並使 Vue 成為電子表格應用程序的引擎。 對於每個單元格,我們可以記住它的原始可編輯值以及相應的計算值。 如果計算值是純值,則計算值將反映原始值,否則,計算值是寫入的表達式(公式)的結果,而不是純值。
使用 Vue 2,實現電子表格的一種方法是讓raw_values
是一個二維字符串數組,而computed_values
是一個(計算的)二維單元格值數組。
如果在加載適當的 Vue 組件之前單元格的數量很小並且是固定的,那麼我們可以在組件定義中為表格的每個單元格設置一個原始值和一個計算值。 除了這種實現會導緻美學上的怪異之外,在編譯時具有固定數量單元格的表格可能不算作電子表格。
二維數組computed_values
也存在問題。 計算屬性始終是一個函數,在這種情況下,其評估取決於自身(計算單元格的值通常需要已經計算一些其他值)。 即使 Vue 允許自引用計算屬性,更新單個單元格也會導致重新計算所有單元格(無論是否存在依賴關係)。 這將是非常低效的。 因此,我們最終可能會使用反應性來檢測 Vue 2 中原始數據的變化,但其他所有反應性方面的事情都必須從頭開始實現。
在 Vue 3 中對計算值進行建模
使用 Vue 3,我們可以為每個單元格引入一個新的計算屬性。 如果表增長,則引入新的計算屬性。
假設我們有單元格A1
和A2
,我們希望A2
顯示A1
的正方形,其值為數字 5。這種情況的草圖:
let A1 = computed(() => 5); let A2 = computed(() => A1.value * A1.value); console.log(A2.value); // outputs 25
假設我們在這個簡單的場景中停留片刻。 這裡有一個問題; 如果我們希望更改A1
使其包含數字 6 怎麼辦? 假設我們這樣寫:

A1 = computed(() => 6); console.log(A2.value); // outputs 25 if we already ran the code above
這不僅將A1
中的值 5 更改為 6 。 變量A1
現在具有完全不同的標識:計算屬性解析為數字 6。但是,變量A2
仍然對變量A1
的舊標識的更改做出反應。 所以, A2
不應該直接引用A1
,而是引用一些在上下文中始終可用的特殊對象,並且會告訴我們此刻A1
是什麼。 換句話說,在訪問A1
之前,我們需要一個間接級別,類似於指針。 Javascript 中沒有指針作為一等實體,但很容易模擬一個。 如果我們希望有一個pointer
value
的指針,我們可以創建一個 object pointer = {points_to: value}
。 重定向指針相當於分配給pointer.points_to
,而取消引用(訪問指向的值)相當於檢索pointer.points_to
的值。 在我們的例子中,我們進行如下操作:
let A1 = reactive({points_to: computed(() => 5)}); let A2 = reactive({points_to: computed(() => A1.points_to * A1.points_to)}); console.log(A2.points_to); // outputs 25
現在我們可以用 6 代替 5。
A1.points_to = computed(() => 6); console.log(A2.points_to); // outputs 36
在 Vue 的 Discord 服務器上,用戶redblobgames提出了另一種有趣的方法:不使用計算值,而是使用包裝常規函數的引用。 這樣,人們可以類似地交換函數而不改變引用本身的身份。
我們的電子表格實現將有一些二維數組的鍵引用的單元格。 這個數組可以提供我們需要的間接級別。 因此,在我們的例子中,我們不需要任何額外的指針模擬。 我們甚至可以擁有一個不區分原始值和計算值的數組。 一切都可以是計算值:
const cells = reactive([ computed(() => 5), computed(() => cells[0].value * cells[0].value) ]); cells[0] = computed(() => 6); console.log(cells[1].value); // outputs 36
然而,我們真的想區分原始值和計算值,因為我們希望能夠將原始值綁定到 HTML 輸入元素。 此外,如果我們有一個單獨的原始值數組,我們就不必更改計算屬性的定義; 它們將根據原始數據自動更新。
實施電子表格
讓我們從一些基本定義開始,這些定義在很大程度上是不言自明的。
const rows = ref(30), cols = ref(26); /* if a string codes a number, return the number, else return a string */ const as_number = raw_cell => /^[0-9]+(\.[0-9]+)?$/.test(raw_cell) ? Number.parseFloat(raw_cell) : raw_cell; const make_table = (val = '', _rows = rows.value, _cols = cols.value) => Array(_rows).fill(null).map(() => Array(_cols).fill(val)); const raw_values = reactive(make_table('', rows.value, cols.value)); const computed_values = reactive(make_table(undefined, rows.value, cols.value)); /* a useful metric for debugging: how many times did cell (re)computations occur? */ const calculations = ref(0);
該計劃是對每個computed_values[row][column]
進行如下計算。 如果raw_values[row][column]
不以=
開頭,則返回raw_values[row][column]
。 否則,解析公式,將其編譯為 JavaScript,評估編譯後的代碼並返回值。 為了簡短起見,我們將在解析公式上作弊,我們不會在這裡做一些明顯的優化,比如編譯緩存。
我們將假設用戶可以輸入任何有效的 JavaScript 表達式作為公式。 我們可以將用戶表達式中出現的單元格名稱的引用替換為對實際單元格值(計算)的引用,例如 A1、B5 等。 下面的函數完成了這項工作,假設類似於單元格名稱的字符串確實總是標識單元格(並且不是某些不相關的 JavaScript 表達式的一部分)。 為簡單起見,我們假設列索引由單個字母組成。
const letters = Array(26).fill(0) .map((_, i) => String.fromCharCode("A".charCodeAt(0) + i)); const transpile = str => { let cell_replacer = (match, prepend, col, row) => { col = letters.indexOf(col); row = Number.parseInt(row) - 1; return prepend + ` computed_values[${row}][${col}].value `; }; return str.replace(/(^|[^AZ])([AZ])([0-9]+)/g, cell_replacer); };
使用transpile
函數,我們可以從用單元格引用的 JavaScript 小“擴展”編寫的表達式中獲取純 JavaScript 表達式。
下一步是為每個單元生成計算屬性。 這個過程將在每個細胞的生命週期中發生一次。 我們可以創建一個返回所需計算屬性的工廠:
const computed_cell_generator = (i, j) => { const computed_cell = computed(() => { // we don't want Vue to think that the value of a computed_cell depends on the value of `calculations` nextTick(() => ++calculations.value); let raw_cell = raw_values[i][j].trim(); if (!raw_cell || raw_cell[0] != '=') return as_number(raw_cell); let user_code = raw_cell.substring(1); let code = transpile(user_code); try { // the constructor of a Function receives the body of a function as a string let fn = new Function(['computed_values'], `return ${code};`); return fn(computed_values); } catch (e) { return "ERROR"; } }); return computed_cell; }; for (let i = 0; i < rows.value; ++i) for (let j = 0; j < cols.value; ++j) computed_values[i][j] = computed_cell_generator(i, j);
如果我們將上面的所有代碼都放在setup
方法中,我們需要返回{raw_values, computed_values, rows, cols, letters, calculations}
。
下面,我們展示了完整的組件,以及一個基本的用戶界面。
該代碼在 GitHub 上可用,您也可以查看現場演示。
<template> <div> <div>Calculations: {{ calculations }}</div> <table class="table" border="0"> <tr class="row"> <td></td> <td class="column" v-for="(_, j) in cols" :key="'header' + j" > {{ letters[j] }} </td> </tr> <tr class="row" v-for="(_, i) in rows" :key="i" > <td class="column"> {{ i + 1 }} </td> <td class="column" v-for="(__, j) in cols" :key="i + '-' + j" :class="{ column_selected: active(i, j), column_inactive: !active(i, j), }" @click="activate(i, j)" > <div v-if="active(i, j)"> <input :ref="'input' + i + '-' + j" v-model="raw_values[i][j]" @keydown.enter.prevent="ui_enter()" @keydown.esc="ui_esc()" /> </div> <div v-else v-html="computed_value_formatter(computed_values[i][j].value)"/> </td> </tr> </table> </div> </template> <script> import {ref, reactive, computed, watchEffect, toRefs, nextTick, onUpdated} from "vue"; export default { name: 'App', components: {}, data() { return { ui_editing_i: null, ui_editing_j: null, } }, methods: { get_dom_input(i, j) { return this.$refs['input' + i + '-' + j]; }, activate(i, j) { this.ui_editing_i = i; this.ui_editing_j = j; nextTick(() => this.get_dom_input(i, j).focus()); }, active(i, j) { return this.ui_editing_i === i && this.ui_editing_j === j; }, unselect() { this.ui_editing_i = null; this.ui_editing_j = null; }, computed_value_formatter(str) { if (str === undefined || str === null) return 'none'; return str; }, ui_enter() { if (this.ui_editing_i < this.rows - 1) this.activate(this.ui_editing_i + 1, this.ui_editing_j); else this.unselect(); }, ui_esc() { this.unselect(); }, }, setup() { /*** All the code we wrote above goes here. ***/ return {raw_values, computed_values, rows, cols, letters, calculations}; }, } </script> <style> .table { margin-left: auto; margin-right: auto; margin-top: 1ex; border-collapse: collapse; } .column { box-sizing: border-box; border: 1px lightgray solid; } .column:first-child { background: #f6f6f6; min-width: 3em; } .column:not(:first-child) { min-width: 4em; } .row:first-child { background: #f6f6f6; } #empty_first_cell { background: white; } .column_selected { border: 2px cornflowerblue solid !important; padding: 0px; } .column_selected input, .column_selected input:active, .column_selected input:focus { outline: none; border: none; } </style>
實際使用情況如何?
我們看到了 Vue 3 的解耦反應系統如何不僅使代碼更簡潔,而且基於 Vue 的新反應機制允許更複雜的反應系統。 自 Vue 推出以來已經過去了大約 7 年,表現力的提升顯然沒有受到高度追捧。
電子表格示例直接演示了 Vue 現在的功能,您還可以查看現場演示。
但作為一個真實的例子,它有點小眾。 新系統在什麼情況下會派上用場? 按需響應最明顯的用例可能是複雜應用程序的性能提升。

在處理大量數據的前端應用程序中,使用考慮不周的反應性的開銷可能會對性能產生負面影響。 假設我們有一個業務儀表板應用程序,它生成公司業務活動的交互式報告。 用戶可以選擇時間範圍並在報告中添加或刪除性能指標。 某些指標可能顯示取決於其他指標的值。
實現報告生成的一種方法是通過整體結構。 當用戶更改界面中的輸入參數時,會更新單個計算屬性,例如report_data
。 這個計算屬性的計算是根據一個硬編碼的計劃進行的:首先,計算所有獨立的性能指標,然後是那些只依賴於這些獨立指標的指標,等等。
更好的實現將解耦報告的各個部分並獨立計算它們。 這樣做有一些好處:
- 開發人員不必對執行計劃進行硬編碼,這既繁瑣又容易出錯。 Vue 的反應系統會自動檢測依賴關係。
- 根據所涉及的數據量,我們可能會獲得顯著的性能提升,因為我們只更新邏輯上依賴於修改後的輸入參數的報告數據。
如果在加載 Vue 組件之前知道所有可能成為最終報告一部分的性能指標,那麼即使使用 Vue 2,我們也可以實現建議的解耦。否則,如果後端是唯一的事實來源(即通常是數據驅動的應用程序的情況),或者如果有外部數據提供者,我們可以為報告的每一部分生成按需計算屬性。
感謝 Vue 3,這不僅是可能的,而且很容易做到。