乐趣区

关于前端:Vue3-的-Reactive-响应式到底是什么

​Vue 3 除了令人钦佩的性能改良,还带来了一些新性能。能够说,最重要的介绍是 Composition API。在本文的第一局部中,咱们将概括 Vue3 创立新 API 的动机: 即,更好的组织和重用代码。在第二局部中,咱们将重点探讨应用新 API 时较少探讨的方面,例如响应式个性。我将响应式个性其称为按需响应。

在介绍了相干的新个性之后,咱们将构建一个简略的电子表格应用程序来演示。最初,我将探讨这种按需响应的改良在事实场景中的用处。

Vue3 有什么新性能,为什么它很重要

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 等的形式并不适用于所有状况。
例如以下状况就存在例外:

  1. 解决具备大量性能的组件。例如,如果咱们想要降级动画代码,使其可能提早动画的开始,咱们就必须在代码编辑器中在组件的所有相干局部之间滚动 / 跳转。在文件上传组件的例子中,组件自身很小,它实现的性能也很少。因而,在这种状况下,在两个局部之间跳转并不是问题。当咱们解决大型组件时,产生碎片代码的问题就变得很突出。
  2. 代码重用。咱们常常须要在多个组件中提供响应数据、计算属性、办法等的特定组合。传统的这种形式不利于代码的组合。

尽管,Vue 2(以及向后兼容的 Vue 3)为大多数代码组织和重用问题提供了解决方案: mixin

Vue3 中 Mixins 的优缺点

Mixin 容许在独自的代码单元中提取组件的性能。每个性能都放在一个独自的 mixin 中,每个组件都能够应用一个或多个 mixin。在 mixin 中定义的代码能够在组件中应用,就像它们在组件自身中定义一样。mixin 有点像面向对象语言中的类。与类一样,mixin 能够在其余代码中继承。

然而,应用 mixins 了解起来比拟艰难,因为与类不同,mixin 的设计不须要思考封装。Mixin 能够是涣散绑定的代码片段的汇合。在同一组件中一次应用多个 mixin 可能会导致组件难以了解和应用。

大多数面向对象的语言(例如 C# 和 Java)不激励甚至不容许多重继承,只管面向对象的编程范式具备解决这种复杂性的工具。

在 Vue 中应用 mixin 时可能呈现的一个更理论的问题是 名称抵触,当应用两个或多个 mixins 申明通用名称时会产生这种问题。这里须要留神的是,如果 Vue 解决名称抵触的默认策略在给定状况下并不现实,则能够由开发人员调整该策略。然而这就引入了更多的复杂性。

另一个问题是 mixins 不提供相似于类构造函数的货色。这是一个问题,因为咱们常常须要十分类似但不完全相同的性能呈现在不同的组件中。在一些简略的状况下,能够应用 mixin 工厂函数来躲避这种状况。

因而,mixin 并不是代码组织和重用的现实解决方案,而且我的项目越大,它们的问题就越重大。Vue 3 引入了一种新办法来解决无关代码组织和重用的相干问题。

Composition API:Vue 3 代码的组织和重用

Composition API 容许咱们齐全解耦组件的各个局部。每一段代码:变量、computed 属性、watch 等,都能够独立定义。

例如,咱们当初能够编写(在咱们的 JavaScript 代码中的任何地位),而不是让一个对象蕴含一个数据局部,该局部蕴含一个键 animation_state 和 一个 “playing” 值:

const animation_state = ref('playing');

成果简直和在某些组件的数据局部申明这个变量一样。惟一的本质区别是咱们须要使在组件内部定义的 ref。咱们能够通过将其模块导入到定义组件的地位并从组件的 setup 局部返回 ref 来做到这一点。咱们当初将跳过这个过程,只关注新的 API。Vue 3 中的响应式是不须要组件的,它实际上是一个独立的零碎。

咱们能够在咱们将此变量导入到的任何范畴内应用变量 animation_state。结构完一个 ref 后,咱们应用 ref.value 获取并设置它的理论值,例如:

animation_state.value = 'paused';
console.log(animation_state.value);

咱们须要 “.value” 后缀,因为赋值运算符会将“paused”(非响应式)调配给变量 animation_stateJavaScript 中的响应式(无论是在 Vue 2 中通过 defineProperty 实现,还是在 Vue 3 中基于 Proxy 实现时)都须要一个对象。

在那里,咱们有一个组件作为任何响应式数据的成员(component.data_member)的前缀。除非并且直到 JavaScript 语言规范引入了重载赋值运算符的能力,否则响应式表达式将须要一个对象和一个键(例如下面的 animation_statevalue)呈现在咱们心愿的任何赋值操作的左侧。

在模板中,咱们能够省略 .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

// 如果咱们没有将 `upload_params` 设为常量,上面的代码会编译,但咱们会在赋值后失去响应性。因而,明确地使响应式变量 const 是一个好主见。upload_params = {
  target_directory: 'static',
  visibility: 'public',
};

应用 refreactive 解耦响应式并不是 Vue 3 的全新个性。它在 Vue 2.6 中就曾经局部引入了,其中这种解耦的响应式数据实例被称为“可察看对象”。在大多数状况下,能够用响应式替换 Vue.observable。区别之一是间接拜访和扭转传递给 Vue.observable 的对象是响应式的,而新的 API 返回一个代理对象,因而扭转原始对象不会产生响应式成果。

Vue 3 的全新之处在于,除了响应式数据之外,组件的其余响应式局部当初也能够独立定义。例如,计算属性能够以预期的形式实现:

const x = ref(5);
const x_squared = computed(() => x.value * x.value);
console.log(x_squared.value); // outputs 25

同样,能够实现各种类型的 watch、生命周期办法和依赖注入。为简洁起见,咱们不会在这里介绍这些内容。

假如咱们应用规范 SFC 办法进行 Vue 开发。咱们甚至可能应用传统的 API,data、computed 属性等。

咱们如何将 Composition API 的大量响应式局部与 SFC 集成?

Vue 3 为此引入了另一个局部:setup。该局部能够被认为是一种新的生命周期办法(它在任何其余钩子之前执行 – 特地是在 create 之前)。

上面是一个将传统办法与 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 是一个援用,在模板代码或组件的传统局部(例如计算)中援用时,它也不须要 .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 

这不仅将 A1 中的值 5 更改为 6。变量 A1 当初具备齐全不同的标识:解析为数字 6 的计算属性。然而,变量 A2 依然对变量 A1 的旧标识的更改做出响应。所以,A2 不应该间接援用 A1,而应该援用一些在上下文中总是可用的非凡对象,并且会通知咱们此时 A1 是什么。

换句话说,在拜访 A1 之前,咱们须要一个间接级别,相似于指针。

Javascript 中没有指针作为一等实体,但很容易模仿一个。如果咱们心愿有一个 pointer 指向一个 value,咱们能够创立一个对象 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

咱们的电子表格实现将有一些二维数组的键援用的单元格。这个数组能够提供咱们须要的间接级别。因而,在咱们的例子中,咱们不须要任何额定的指针模仿。咱们甚至能够领有一个不辨别原始值和计算值的数组。所有都能够是计算值:

const cells = reactive([computed(() => 5),
  computed(() => cells[0].value * cells[0].value)
]);

cells[0] = computed(() => 6);
console.log(cells[1].value); // outputs 36

然而,咱们真的想辨别原始值和计算值,因为咱们心愿可能将原始值绑定到 HTML 的 input 元素。此外,如果咱们有一个独自的原始值数组,咱们就不用更改计算属性的定义,它们将依据原始数据自动更新。

创立电子表格

让咱们从一些根本定义开始,这些定义在很大水平上是不言自明的。

const rows = ref(30), cols = ref(26);

/* 如果一个字符串编码一个数字,则返回该数字,否则返回一个字符串 */
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));

/* 一个有用的调试指标:单元(从新)计算产生了多少次?*/
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(/(^|[^A-Z])([A-Z])([0-9]+)/g, cell_replacer);
};

应用 transpile 函数,咱们能够从用单元格援用的 JavaScript 小“扩大”编写的表达式中获取纯 JavaScript 表达式。

下一步是为每个单元生成计算属性。这个过程将在每个细胞的生命周期中产生一次。咱们能够创立一个返回所需计算属性的工厂:

const computed_cell_generator = (i, j) => {const computed_cell = computed(() => {
        // 咱们不心愿 Vue 认为 computed_cell 的值取决于 `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 {
            // Function 的构造函数接管函数体作为字符串
            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}

上面,咱们展现了残缺的组件代码:

<template>
  <div>
    <div style="margin: 1ex;">Calculations: {{calculations}}</div>
    <table class="table" border="0">
      <tr class="row">
        <td id="empty_first_cell"></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 年,表现力的晋升显然没有受到高度追捧。

电子表格只是作为一个实在的例子,有点小众。这套零碎在什么状况下会派上用场?集体感觉,按需响应最显著的用例可能是简单应用程序的性能晋升。

在解决大量数据的前端应用程序中,应用考虑不周的响应式的开销可能会对性能产生负面影响。假如咱们有一个业务仪表板应用程序,能够生成公司业务流动的交互式报告。用户能够抉择工夫范畴并在报告中增加或删除性能指标。某些指标可能显示取决于其余指标的值。

当用户更改界面中的输出参数时,会更新单个计算属性,例如 report_data。这个计算属性的计算是依据一个硬编码的打算进行的:首先,计算所有独立的性能指标,而后是那些只依赖于这些独立指标的指标,等等。

更好的实现将解耦报告的各个局部并独立计算它们。这样做有一些益处:

  • 开发人员不用对执行打算进行硬编码,这既繁琐又容易出错。Vue 的响应式零碎会自动检测依赖关系。
  • 依据所波及的数据量,咱们可能会取得显着的性能晋升,因为咱们只更新逻辑上依赖于批改后的输出参数的报告数据。
退出移动版