在Vue 3中实现懒惰的水化,从头开始

2,222 阅读10分钟

随着网络应用的复杂性和规模不断增加,性能问题也随之而来。开发人员通常通过采用服务器端渲染(SSR)来解决这个问题,以卸载客户端的一些渲染过程。

然而,即使HTML渲染发生在服务器上,网站的性能仍然会受到影响。虽然HTML是以快速和有利于SEO的方式交付的,但水化的过程--使应用程序在客户端互动--可能是昂贵的。反过来,对于具有复杂的、深度嵌套的HTML的应用程序来说,交互时间(TTI)和估计输入延迟(EIL)等指标会急剧下降。

现在,你可以用代码分割等技术来解决这个问题,或者立即加载应用程序的重要部分,同时延迟代码的交付,并将其他组件水化。这可能会提高你的指标,但仍然会在用户从未看到或互动的组件上浪费加载时间。

这就是懒惰的水合作用的地方。让我们看看这是什么,它如何工作,以及如何在Vue 3中实现它。

局部水化与懒惰水化

要了解水合的变体以及它们是如何工作的,你首先需要熟悉部分水合的情况。

顾名思义,在部分水合中,你只对你的应用程序的某些部分进行水合。这在实现所谓的"岛屿架构 "时很有用在这种情况下,不同的应用程序部分被视为独立的实体。这使得应用程序的每个部分都独立于其他部分,这使得它们可以分别进行水合。

让我们想一想,部分水合和岛屿架构将如何适用于一个网站,如博客。你可以对工具栏和评论区等互动部分进行水化,但让其他部分如内容本身完全静态化。这种方法提高了网站的性能和用户体验,而且没有资源浪费在静态内容上,使互动部分的水化速度更快。

懒惰的水化是建立在部分水化的概念之上,并将它们更进一步。对于任何已经包含SSR、基本水合和async组件的框架来说,这个概念在实现上是相似的。

你不再只能够决定网络应用的哪些部分应该被水化,你还可以决定什么时候应该发生。例如,你可以只在空闲时、在视口中、或在响应其他各种触发器(如Promise resolving或用户互动)时对组件进行水化。

这将资源节约和性能优化提升到另一个层次。你不再需要对用户永远不会看到或与之互动的组件进行水化,这使得TTI几乎是即时的

Vue的懒惰水化

Vue 2有一个伟大的、相当流行的库,名为vue-lazy-hydration。它提供了一个无渲染的LazyHydrate 组件和一堆手动函数包装器,如hydrateWhenVisible ,用于包装你想懒惰水化的组件。它还允许你在不同的条件下进行补水,比如说。

  • 当浏览器处于空闲状态时(使用requestIdleCallback )。
  • 当组件在视口内时(使用IntersectionObserver )。
  • 在用户互动时(click,mouseover, 等)。
  • 通过手动触发(Promise, 布尔开关,等等)
  • never (对于静态的、仅有SSR的组件)。

遗憾的是,在发表文章时,这个以及其他任何著名的懒惰水化库都不支持Vue 3。尽管如此,Vue-lazy-hydration对Vue 3的支持正在开发中,似乎有计划在Nuxt 3出来后发布

这使得我们要么继续使用Vue 2进行懒惰补水,要么实现我们自己的机制,这就是我们在这篇文章中要做的。

在Vue 3中实现懒惰的水合作用

像Vue这样的UI框架,有内置的SSR和水合支持,实现懒惰水合是相当容易的。

你需要一个包装器或无渲染组件,在服务器上自动渲染你的组件,同时在客户端使用条件渲染来延迟水化,直到满足某些条件。

我决定在react-lazy-hydration的基础上实现Vue 3的懒惰水化。它的代码比vue-lazy-hydration的简单,而且出乎意料地更容易翻译,React Hooks与Vue Composition API转换得很好。

组件声明和道具

我们从一个基本的Vue 3组件开始,包含额外的TypeScript和一个isBrowser 实用函数,用于检查浏览器的globals是否可用。

<script lang="ts">
import { defineComponent, onMounted, PropType, ref, watch } from "vue";

type VoidFunction = () => void;

const isBrowser = () => {
  return typeof window === "object";
};

export default defineComponent({
  props: {},
  setup() {},
});
</script>
<template></template>

我们的lazy hydration包装器将包括与前面提到的库所提供的类似的功能。为此,我们将不得不接受一套相当广泛的配置道具。

// ...
export default defineComponent({
  props: {
    ssrOnly: Boolean,
    whenIdle: Boolean,
    whenVisible: [Boolean, Object] as PropType<
      boolean | IntersectionObserverInit
    >,
    didHydrate: Function as PropType<() => void>,
    promise: Object as PropType<Promise<any>>,
    on: [Array, String] as PropType<
      (keyof HTMLElementEventMap)[] | keyof HTMLElementEventMap
    >,
  },
  // ...
});
// ...

有了上述道具,我们将支持仅有SSR的静态组件,以及在浏览器空闲时、组件可见时或给定的Promise 解决后进行水化。

除此之外,on 将支持在用户交互时进行水化,而didHydrate 将允许在组件水化后进行回调。

设置功能

setup ,我们首先初始化一些必要的值。

// ...
export default defineComponent({
  // ...
  setup() {
    const noOptions =
      !props.ssrOnly &&
      !props.whenIdle &&
      !props.whenVisible &&
      !props.on?.length &&
      !props.promise;
    const wrapper = ref<Element | null>(null);
    const hydrated = ref(noOptions || !isBrowser());
    const hydrate = () => {
      hydrated.value = true;
    };
  },
});
// ...

我们将使用一个wrapper 模板引用来访问包装元素,以及一个hydrated 引用来保存反应布尔值,该值决定了当前的水合状态。

注意我们是如何初始化hydrated ref的。当没有设置任何选项时,默认情况下组件将立即进行水化。否则,在通过SSR时,水化将在客户端被延迟。

hydrate 只是一个单向的辅助函数,用于设置hydratedtrue

补水回调注册

接下来,我们开始创建逻辑,有一个onMounted 回调和一个watch 效果。

// ...
onMounted(() => {
  if (wrapper.value && !wrapper.value.hasChildNodes()) {
    hydrate();
  }
});
watch(
  hydrated,
  (hydrate) => {
    if (hydrate && props.didHydrate) props.didHydrate();
  },
  { immediate: true }
);
// ...

onMounted 回调中,我们检查该元素是否有任何孩子。如果没有,我们可以立即进行水化。

watch 效果处理didHydrate 回调。注意immediate 选项--它对水合不延迟时很重要,在SSR期间和没有提供选项时都是如此。

设置主要的watch 效果

现在,我们进入主要的watch 效果,它将处理所有的选项并适当地设置hydrated ref。

// ...
watch(
  [() => props, wrapper, hydrated],
  (
    [{ on, promise, ssrOnly, whenIdle, whenVisible }, wrapper, hydrated],
    _,
    onInvalidate
  ) => {
    if (ssrOnly || hydrated) {
      return;
    }

    const cleanupFns: VoidFunction[] = [];
    const cleanup = () => {
      cleanupFns.forEach((fn) => {
        fn();
      });
    };

    if (promise) {
      promise.then(hydrate, hydrate);
    }
  },
  { immediate: true }
);
// ...

该效果将触发道具的变化,以及wrapperhydrate refs中的变化。

首先,我们检查该组件是否只在服务器端呈现,或者是否已经被水化。我们这样做是因为,在这两种情况下,都不需要进一步评估效果,所以我们可以从函数中return

如果这个过程继续下去,我们将初始化清理函数,以便在效果无效时,处理Promise-based lazy hydration。

基于可见性的水化

接下来,仍然在效果内部,我们处理基于可见性的水合。如果支持IntersectionObserver ,我们初始化它,传递默认或提供的选项。否则,我们立即进行水合。

// ...
if (whenVisible) {
  if (wrapper && typeof IntersectionObserver !== "undefined") {
    const observerOptions =
      typeof whenVisible === "object"
        ? whenVisible
        : {
            rootMargin: "250px",
          };

    const io = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting || entry.intersectionRatio > 0) {
          hydrate();
        }
      });
    }, observerOptions);

    io.observe(wrapper);

    cleanupFns.push(() => {
      io.disconnect();
    });
  } else {
    return hydrate();
  }
}
// ...

注意清理回调,将IntersectionObserver 实例与wrapper 元素断开连接。

基于浏览器空闲的水化

我们对基于浏览器空闲的水合采用类似的结构,这次是用requestIdleCallbackcancelIdleCallback

if (whenIdle) {
  if (typeof window.requestIdleCallback !== "undefined") {
    const idleCallbackId = window.requestIdleCallback(hydrate, {
      timeout: 500,
    });
    cleanupFns.push(() => {
      window.cancelIdleCallback(idleCallbackId);
    });
  } else {
    const id = setTimeout(hydrate, 2000);
    cleanupFns.push(() => {
      clearTimeout(id);
    });
  }
}

requestIdleCallback 的跨浏览器兼容性低于80%,尤其是iOS和macOS上的Safari不支持,所以我们必须用setTimeout 实现回退,延迟水合并将其推送到异步队列中。

如果你使用TypeScript,你应该注意,目前,你不会在默认库中找到requestIdleCallback 。为了正确打字,你需要安装@types/requestidlecallback

基于用户交互的水化

最后,我们处理基于用户事件的水合。在这里,事情相对简单,因为我们只是循环处理事件并相应地设置事件监听器。

if (on) {
  const events = ([] as Array<keyof HTMLElementEventMap>).concat(on);

  events.forEach((event) => {
    wrapper?.addEventListener(event, hydrate, {
      once: true,
      passive: true,
    });
    cleanupFns.push(() => {
      wrapper?.removeEventListener(event, hydrate, {});
    });
  });
}

onInvalidate(cleanup);

之后,记得调用onInvalidate 来注册清理函数,效果就准备好了!

完成模板的制作

为了完成这个组件,从setup 函数中返回模板中需要的引用。

// ...
export default defineComponent({
  // ...
  setup() {
    // ...
    return {
      wrapper,
      hydrated,
    };
  },
});
// ...

然后,在模板中,渲染包装<div> ,分配参数,并有条件地渲染懒惰水化的组件。

<template>
  <div ref="wrapper" :style="{ display: 'contents' }" v-if="hydrated">
    <slot></slot>
  </div>
  <div ref="wrapper" v-else></div>
</template>

使用我们的懒惰水合组件

我们的懒惰水合组件已经准备好了,现在是时候测试它了

构建我们的Vue 3 SSR应用程序的脚手架

首先,你需要建立一个可以使用SSR或静态网站生成器(SSG)的环境。从技术上讲,任何预渲染的HTML和启用了水化功能的Vue 3都可以使用,但你的里程可能会有所不同。

由于Nuxt.jsGridsome都还没有与Vue 3兼容,你最好的选择是使用Vite-plugin-ssr之类的东西。这样的解决方案将允许你利用Vite提供的伟大的开发经验,同时实现SSR,而不会有太多麻烦。

你可以用下面的命令搭建一个新的vit-plugin-ssr应用程序。

npm init vite-plugin-ssr@latest

然后,用上面的指南或GitHub的Gist来设置懒惰的水化组件。

有了这些,去任何一个可用的页面,在<LazyHydrate> ,然后玩一个交互式的组件!使用不同的选项,看看什么时候组件会出现。

<template>
  <h1>Welcome</h1>
  This page is:
  <ul>
    <li>Rendered to HTML.</li>
    <li>
      Interactive. <LazyHydrate when-visible><Counter /></LazyHydrate>
    </li>
  </ul>
</template>
<script lang="ts">
import Counter from "./_components/Counter.vue";
import LazyHydrate from "./_components/LazyHydrate.vue";

export default {
  components:{
    Counter,
    LazyHydrate,
  }
};
</script>

使用不同的选项,查看组件的互动性,查看它何时被didHydrate 回调水化,以及更多!

将懒惰的水化与异步组件相结合

为了进一步改善你的应用程序的TTI指标和加载时间,你可以将懒惰的水化与异步组件结合起来。这将把你的应用程序分割成更小的块,准备按需加载。有了这个,你的懒惰水化组件将只在水化发生时加载。

import { defineAsyncComponent } from "vue";
import LazyHydrate from "./_components/LazyHydrate.vue";

export default {
  components: {
    Counter: defineAsyncComponent({
      loader: () => import("./_components/Counter.vue"),
    }),
    LazyHydrate,
  },
};

请记住,你必须小心使用这种方法,因为动态获取组件可能会给用户带来明显的延迟。在这种情况下,你必须有选择地推迟哪些组件,并需要实现后备内容,比如在获取和解析代码时的加载器。

然而,即使考虑到所有这些,懒惰的异步组件仍然有很大的潜力,可以大幅提高大型复杂应用程序的性能,特别是那些严重依赖交互式图表或隐藏对话框等元素的应用程序。

底线

所以,你已经拥有了它--在Vue 3中解释和实现的懒惰的水合作用!通过本帖实现的组件,你可以优化你的SSR/SSG应用,提高其性能、响应速度和用户体验。

关于<LazyHydrate> 组件的完整代码,请查看GitHub Gist。请随意使用它进行实验。如果你有任何改进的想法,请在GitHub上告诉我。

请务必关注vue-lazy-hydration上的更新。下一个版本据说会利用新的Vue 3-Node APIs,因此可能会比本帖的简单实现更有性能或提供更多的功能。

The postAchieving lazy hydration in Vue 3 from scratchappeared first onLogRocket Blog.