无视图的 Vue - 无渲染组件简介

2025-06-07

无视图的 Vue - 无渲染组件简介

随着组件规模的扩大,维护难度也随之增加。有时,如何将臃肿的组件拆分成更小的组件并不明显。代码会变得更加嘈杂,难以理解。

在这篇文章中,我将介绍“无渲染组件”的概念,它可能会帮助您改进组件。

我的奇妙网站

我们将深入探究“我的神奇网站”来源。(如果您不想被剧透,请先不要深入 PR。)

Groovy 页脚

看到页面底部那个漂亮的页脚了吗?让我们看看这个页脚的源代码。

src/components/Footer.vue

<template>
  <footer :style="footerStyle">
    <div class="text" :style="textStyle">Made with ❤ by Jason Yu &copy; 2019</div>
    <label class="insane-mode-label">
      <input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
    </label>
  </footer>
</template>

<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';

const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;

export default {
  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
    this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
    this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
  },
  beforeDestroy() {
    window.clearInterval(this.footerIntervalId);
    window.clearInterval(this.textIntervalId);
  },
  data: () => ({
    footerStyle: null,
    textStyle: null,
    insaneMode: false,
  }),
  computed: {
    insaneFactor() {
      return this.insaneMode ? 3 : 1;
    },
    footerIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
    textIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
  },
  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
  methods: {
    randomFooterStyle() {
      const { insaneFactor } = this;
      this.footerStyle = {
        borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
        background: randomColor(),
        transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
      };
    },
    randomTextStyle() {
      const { insaneFactor } = this;
      this.textStyle = {
        transform: `rotate(${randomNumber(
          -3 * insaneFactor,
          3 * insaneFactor,
        )}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
        color: randomColor(),
        transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
      };
    },
  },
};
</script>

<style scoped>
footer {
  margin-top: 1rem;
  padding: 3rem 0;
  transition-property: border-radius, background;
  text-align: center;
}
footer .text {
  transition-property: color, transform;
}
.insane-mode-label {
  display: block;
  margin-top: 2rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

注意, 中超过一半的代码<script>都是用来处理window.setInterval和 的window.clearInterval。我们该如何简化这个组件呢?将页脚文本和背景分别移到各自的组件中是没有意义的,因为它们在语义上属于页脚,而不是独立的!

<间隔>

让我们创建一个名为的组件<Interval>,它将处理与我们相关的所有window.setInterval事务window.clearInterval

src/组件/renderless/Interval.js:

export default {
  render: () => null,
};
Enter fullscreen mode Exit fullscreen mode

首先,正如本文标题所示,该render函数应该不渲染任何内容。因此我们 return null

道具

接下来,应该接受什么样的 props <Interval>?显然我们希望能够控制delay每个间隔之间的距离。

src/组件/renderless/Interval.js:

export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  render: () => null,
}
Enter fullscreen mode Exit fullscreen mode

已安装

<Interval>安装 时,我们预计它会开始间隔,并会在 处撕开间隔beforeDestroyed

src/组件/renderless/Interval.js:

export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  mounted () {
    this.id = window.setInterval(() => /* ... */, this.delay);
  },
  beforeDestroy () {
    window.clearInterval(this.id);
  },
  render: () => null,
}
Enter fullscreen mode Exit fullscreen mode

我们应该做什么/* ... */

setInterval它接受两个参数:一个回调和一个延迟。那么我们应该接受它callback作为 prop 吗?这主意很棒,而且效果很好。但我认为更“Vue 式”的做法是触发事件!

src/组件/renderless/Interval.js:

export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  mounted () {
    this.id = window.setInterval(() => this.$emit('tick'), this.delay);
  },
  beforeDestroy () {
    window.clearInterval(this.id);
  },
  render: () => null,
}
Enter fullscreen mode Exit fullscreen mode

完毕!

尽管它很简单,但它赋予了我们间隔的力量,而无需管理间隔 ID 和间隔的设置/拆除!

重构 Footer.vue!

让我们在 Footer.vue 中分别处理和钩子中的setIntervalclearIntervalmountedbeforeDestroy

// ...
  mounted() {
    // ...
    this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
    this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
  },
  beforeDestroy() {
    window.clearInterval(this.footerIntervalId);
    window.clearInterval(this.textIntervalId);
  },
// ...
Enter fullscreen mode Exit fullscreen mode

上面的代码现在可以替换为:

   <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
   <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
Enter fullscreen mode Exit fullscreen mode

生成的 Footer.vue 将会像这样:

<template>
  <footer :style="footerStyle">
    <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
    <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
    <div class="text" :style="textStyle">Made with ❤ by Jason Yu &copy; 2019</div>
    <label class="insane-mode-label">
      <input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
    </label>
  </footer>
</template>

<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';
import Interval from './renderless/Interval';

const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;

export default {
  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
  },
  data: () => ({
    footerStyle: null,
    textStyle: null,
    insaneMode: false,
  }),
  computed: {
    insaneFactor() {
      return this.insaneMode ? 3 : 1;
    },
    footerIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
    textIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
  },
  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
  methods: {
    randomFooterStyle() {
      const { insaneFactor } = this;
      this.footerStyle = {
        borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
        background: randomColor(),
        transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
      };
    },
    randomTextStyle() {
      const { insaneFactor } = this;
      this.textStyle = {
        transform: `rotate(${randomNumber(
          -3 * insaneFactor,
          3 * insaneFactor,
        )}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
        color: randomColor(),
        transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
      };
    },
  },
};
</script>

<style scoped>
footer {
  margin-top: 1rem;
  padding: 3rem 0;
  transition-property: border-radius, background;
  text-align: center;
}
footer .text {
  transition-property: color, transform;
}
.insane-mode-label {
  display: block;
  margin-top: 2rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

注意到组件现在看起来漂亮多了?没有像footerIntervalIdor这样荒谬的名字了textIntervalId,也不用担心忘记删除间隔了!

疯狂模式

疯狂模式由 Footer.vue 中的观察者提供支持:

<template>
   <!-- ... -->
   <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
   <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
   <!-- ... -->
</template>

<script>
// ...
  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
// ...
</script>
Enter fullscreen mode Exit fullscreen mode

我们显然想删除这个观察者并将逻辑移到里面<Interval>

当触发疯狂模式时, 会<Interval>收到一个新的delayprop,因为this.footerIntervalMsthis.textIntervalMs发生了变化。然而,<Interval>尚未被编程来对 的变化做出反应delay。我们可以添加一个观察器,delay它会拆除现有的间隔并设置一个新的。

Interval.js

export default {
  props: {
    delay: {
      type: Number,
      required: true,
    },
  },
  mounted () {
    this.id = window.setInterval(() => this.$emit('tick'), this.delay);
  },
  beforeDestroy () {
    window.clearInterval(this.id);
  },
  watch: {
    delay () {
      window.clearInterval(this.id);
      this.id = window.setInterval(() => this.$emit('tick'), this.delay);
    },
  },
  render: () => null,
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以删除 Footer.vue 中的观察者:

  watch: {
    insaneMode() {
      window.clearInterval(this.footerIntervalId);
      window.clearInterval(this.textIntervalId);
      this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
      this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
    },
  },
Enter fullscreen mode Exit fullscreen mode

最终的 Footer.vue 如下所示:

<template>
  <footer :style="footerStyle">
    <Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
    <Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
    <div class="text" :style="textStyle">Made with ❤ by Jason Yu &copy; 2019</div>
    <label class="insane-mode-label">
      <input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
    </label>
  </footer>
</template>

<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';
import Interval from './renderless/Interval';

const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;

export default {
  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
  },
  data: () => ({
    footerStyle: null,
    textStyle: null,
    insaneMode: false,
  }),
  computed: {
    insaneFactor() {
      return this.insaneMode ? 3 : 1;
    },
    footerIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
    textIntervalMs() {
      return FOOTER_INTERVAL_MS / this.insaneFactor;
    },
  },
  methods: {
    randomFooterStyle() {
      const { insaneFactor } = this;
      this.footerStyle = {
        borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
        background: randomColor(),
        transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
      };
    },
    randomTextStyle() {
      const { insaneFactor } = this;
      this.textStyle = {
        transform: `rotate(${randomNumber(
          -3 * insaneFactor,
          3 * insaneFactor,
        )}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
        color: randomColor(),
        transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
      };
    },
  },
};
</script>

<style scoped>
footer {
  margin-top: 1rem;
  padding: 3rem 0;
  transition-property: border-radius, background;
  text-align: center;
}
footer .text {
  transition-property: color, transform;
}
.insane-mode-label {
  display: block;
  margin-top: 2rem;
}
</style>
Enter fullscreen mode Exit fullscreen mode

向你挑战!

希望你觉得这篇文章有趣。如果你想了解更多关于不同类型的无渲染组件的知识,请观看我之前演讲的视频,其中包含更多实时编码示例。

Footer.vue 中的钩子里还有两行代码mounted。你能想办法扩展一下<Interval>,把整个钩子都去掉mounted吗?点击此处查看PR获取灵感。

  mounted() {
    this.randomFooterStyle();
    this.randomTextStyle();
  },
Enter fullscreen mode Exit fullscreen mode

为什么?

我们在Attest使用 Vue构建了非常酷的产品。我们发现这种模式在很多方面都很有优势,例如可维护性、正确性、可测试性等等。如果你想加入这个才华横溢的团队,就立即申请吧

PS 我们喜欢基于功能的 RFC。

文章来源:https://dev.to/ycmjason/vue-without-view-an-introduction-to-renderless-components-23ld
PREV
向我解释一下 XKCD
NEXT
关于 Vue 3 Composition API 的思考 - `reactive()` 被认为有害