如何避免 Vue 的漏洞破坏单向数据流

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

导读

一直有人说,“React 适合中大型项目,Vue 比较适合中小型项目”,但是一直也没人说清楚为什么,所以笔者之前对这种言论是直接忽略的。最近在研究如何提升项目的可维护性,发现了 Vue 设计的一个漏洞会破坏单向数据流。如果以此为论据说 Vue 不适合大项目,勉强能够附会上。

澄清一点,笔者没有任何褒贬倾向,甚至感情上更倾向 Vue 多一点,所以大家客观看待这个问题就好,题目也只是想多博一些眼球而已。

正文

为什么要单向数据流

This is why state is often called local or encapsulated. It is not accessible to any component other than the one that owns and sets it.
这就是为什么称 state 为局部的或是封装的的原因。除了拥有并设置了它的组件,其他组件都无法访问。

请正确理解“访问”这个词,实际上换成“修改”更容易理解一些。毕竟 state 可以作为 props 传给子组件,子组件也可以使用它,但是不可以修改它。

这里又有一个疑问了,明明很多场景子组件都是可以修改父组件数据的,最典型的就是 input 类的表单组件。我们具体看下代码:

<Input :value="value" @update-value="v => { this.value = v; }" />
复制代码

请注意,Input 有能力修改父组件的 value,是因为父组件给它传了一个 updateValue 的 emit,它能做的只是“使用”这个方法而已,如果父组件不给它传类似的方法,它是无法对 value 做任何修改操作的。

这就保证了在阅读代码时你会对子组件的行为有所预期,更具体点说就是,如果我只给子组件传了 value 而没有传 updateValue 的话,子组件就不可能修改 value,debug 就可以忽略该子组件。这就是所谓的“确定性”。

为了加深印象,我们再看一个例子:

<Parent>
  <ChildA :value="value" />
  <ChildB :value="value" />
  <ChildC :value="value" />
  <ChildD :value="value" @update-value="handleUpdateValue" />
</Parent>
复制代码

多个子组件都使用了 value,如果有单向数据流的限制,我们可以确定,只有 ChildD 有能力修改 value。如果没有单向数据流的限制,我们要如何定位是哪个组件修改了 value 呢?恐怕只能进入每个子组件的代码中去查看了,如果是个比较复杂的项目,这种情况会“套娃般”的出现,这对于项目维护来说是灾难性的。

Vue 的漏洞

Vue 在文档中强调了单向数据流的重要性,但是却留了一个漏洞,并且没有给出解决办法。

提示
注意在 JavaScript 中对象和数组是通过引用传入的,所以对于一个数组或对象类型的 prop 来说,在子组件中改变变更这个对象或数组本身将会影响到父组件的状态。

来看一个例子:

// Parent
<script setup lang="ts">
import { ref } from "vue";
import ChildItem from "./ChildItem.vue";
const data = ref({
  root: {
    leaf: 0,
  },
});
</script>
<template>
  <!-- 只传入了数据,没有传修改数据的方法 -->
  <ChildItem :data="data" />
  <pre>{{ JSON.stringify(data, null, 2) }}</pre>
</template>


// Child
<script setup lang="ts">
import { defineProps } from "vue";
import { set } from "lodash-es";
const props = defineProps<{
  data: Record<"root", Record<"leaf", number>>;
}>();

// 子组件可以直接修改 props 的值
const handleChangeLvl2 = () => {
  // props.data.root.leaf = 2; // 虽然 eslint 会报错,但是仍然可以修改成功
  set(props, "data.root.leaf", 2); // 即规避了 eslint 的报错,也能修改成功
};
</script>
<template>
  <button @click="handleChangeLvl2">Change leaf value</button>
</template>
复制代码

如上例所示,可以“强行”打破单向数据流,这就是 Vue 中存在的漏洞。

如何避免漏洞

目前没有绝对硬性的办法来避免这个漏洞,只能通过工具尽量规避。比如:

  • 设置 eslint 规则,并要求全部开发者启用;
  • 进一步的,在 CI/CD 中进行 lint 检测,不通过不能合并代码;
  • CR 的时候注意

不管采取何种方式,都需要一定的成本,好在靠工具可以算是“一次投入,终身受益”的做法。即便如此,还是不能够彻底解决这个问题,除非 Vue 的更新机制做出改变,但就目前来看,这基本上是不可能的。

面对上例,正确的做法应该是显式的传入修改 dataemit,并且 clone 后进行修改,保证 immutable,如下:

// Parent 改为: <ChildItem v-model="data" />

<script setup lang="ts">
import { defineProps, defineEmits } from "vue";
import { cloneDeep } from "lodash-es";
type Value = Record<"root", Record<"leaf", number>>;
const props = defineProps<{
  modelValue: Value;
}>();
const emit = defineEmits<{
  (e: "update:modelValue", data: Value): void;
}>();

const handleChangeLvl2 = () => {
  // 先 clone,再修改
  const val = cloneDeep(props.modelValue);
  val.root.leaf++;
  // 赋值 clone 的值
  emit("update:modelValue", val);
};
</script>
<template>
  <button @click="handleChangeLvl2">Change leaf value</button>
</template>
复制代码

结语

一些人可能认为有点小题大做,觉得偶尔破坏一下单向数据流,带来了操作的便利,又有什么不行的呢?

的确,一切脱离实际情况的讨论,都是耍流氓。如果你的项目并不大,也就区区几个页面,然后大费周章的又要搞 CI/CD,又要普及规范标准,ROI 实在是太低了。但是对于中大型项目,保持绝对的单向数据流,是非常必要的。就像上文提到的,在复杂的项目中,一个小问题可能“套娃”一样的出现,然后带来灾难性的后果。

最后以一句名言来结尾:

“千丈之堤,以蝼蚁之穴溃;百尺之室,以突隙之烟焚。”——《韩非子·喻老》

猜你喜欢

转载自juejin.im/post/7019228126924242980