Nuxt Adaptive SSR Solution: SEO и минимизация верхней части страницы

Vue.js
Nuxt Adaptive SSR Solution: SEO и минимизация верхней части страницы

В настоящее время проект использует Nuxt SSR для завершения рендеринга на стороне сервера.Чтобы соответствовать требованиям SEO, содержимое экрана без складывания также запрашивается и отправляется непосредственно с сервера, что приводит к более длительному времени отображения первой страницы (без складывания). запросы ресурсов и рендеринг компонентов потребуют дополнительных затрат). Для большого числа пользователей небольшое количество требований к доступу для поисковых роботов повлияет на доступ обычных пользователей, что приведет к большому противоречию между SEO и улучшением взаимодействия с пользователем.

Чтобы решить эту проблему, мы разрабатываем и практикуем адаптивную схему SSR для удовлетворения потребностей обоих сценариев. Сегодня я поделюсь техническими деталями, дизайнерскими идеями и практическим опытом некоторых сопутствующих подзадач, возникающих при реализации этой схемы.Добро пожаловать в общение друг с другом.

поделиться планом

  • Источник проблемы и предыстория
  • идеи решения проблем
  • Введение в адаптивную схему SSR
  • До и после оптимизации данных с помощью адаптивного SSR

Источник проблемы и предыстория

20190808160403.png

Диаграмма жизненного цикла Nuxt

20190808160623.png

Для большого числа пользователей небольшое количество требований к доступу для поисковых роботов повлияет на доступ обычных пользователей, что приведет к большому противоречию между SEO и улучшением взаимодействия с пользователем.

Чтобы решить эту проблему, мы надеемся различать разные сценарии для разных прямых выходов, все сценарии SEO выполняются прямо, другие сценарии выходят только прямо из свернутого первого экрана, а некритические запросы асинхронно извлекаются из внешнего интерфейса.

Решения

Планируется управлять загрузкой данных единым образом, а загрузкой данных управляет специальный плагин, который будет выборочно подгружать данные по условиям, а лениво загружать часть данных.

  • Судя по тому, что это ситуация SEO, этап выборки выполняет всю логику загрузки данных.
  • В сценариях, не связанных с SEO, на этапе выборки выполняется только минимальная логика загрузки данных.После того, как первый экран страницы открыт, другая часть данных каким-то образом загружается лениво.

Оптимизированная блок-схема загрузки страницы обзора фильма проекта

20190808162208.png

Введение в адаптивную схему SSR

Gitlab CI Pipeline

20190808160912.png

Саморазвитый варный трубопровод

Опираясь на концепцию и процесс непрерывной интеграции Gitlab CI, запрос данных разбит на несколько этапов (этап), каждый этап выполняет разные асинхронные задачи (задание), и все этапы формируют конвейер запроса данных (конвейер).

Предустановленная стадия

  • seoFetch: сбор заданий для нужд SEO-рендеринга, общее требование состоит в том, что требуются все запросы данных и как можно больше контента для рендеринга на стороне сервера.
  • minFetch: минимальный набор заданий, необходимых для рендеринга первого экрана.
  • Смонтировано: после загрузки первого экрана коллекция заданий выполняется асинхронно в фазе мыши.

Каждая страница управляется экземпляром Nuxt Fetch Pipeline. Nuxt Fetch Pipeline необходимо настроить соответствующее задание и этап, а затем он будет адаптивно определять тип запроса и целенаправленно обрабатывать асинхронное извлечение данных:

  • Если это сценарий SEO, будет выполняться только сбор заданий на этапе seoFetch.
  • Если к нему обращается реальный пользователь, сбор заданий на этапе minFetch будет сначала выполнен на сервере, а затем немедленно вернется.Клиент может видеть содержимое первого экрана и скелетного экрана.После первого экрана загружена, она будет выполняться асинхронно на смонтированной стадии. Сбор заданий смонтированной стадии и других заданий с более низким приоритетом будет выполняться только тогда, когда простаивает стадия ожидания.

Пример использования Nuxt Fetch Pipeline

import NuxtFetchPipeline, {
  pipelineMixin,
  adaptiveFetch,
} from '@/utils/nuxt-fetch-pipeline';
import pipelineConfig from './index.pipeline.config';

const nuxtFetchPipeline = new NuxtFetchPipeline(pipelineConfig);

export default {
  mixins: [pipelineMixin(nuxtFetchPipeline)],

  fetch(context) {
    return adaptiveFetch(nuxtFetchPipeline, context);
  },
};

export default {
  stages: {
    // 面向SEO渲染需要的 job 集合,一般要求是全部
    seoFetch: {
      type: 'parallel',
      jobs: [
        'task1'
      ]
    },
    // 首屏渲染需要的最小的 job 集合
    minFetch: {
      type: 'parallel',
      jobs: [
      ]
    },
    // 首屏加载完之后,在 mounted 阶段异步执行的 job 集合
    mounted: {
      type: 'parallel',
      jobs: [
      ]
    },
    // 空闲时刻才执行的 job 集合
    idle: {
      type: 'serial',
      jobs: [
      ]
    }
  },
  pipelines: {
    // 任务1
    task1: {
      task: ({ store, params, query, error, redirect, app, route }) =&gt {
        return store.dispatch('action', {})
      }
    }
  }
}

В качестве параллельной обработки также будет запускаться каждое задание, ожидающее выполнения всех заданий. Задание завершено Этап Этап поддерживает тип конфигурации параллельный и последовательно-параллельный после завершения этого этапа. задание, до и после завершения задания, задание начало возвращаться, после завершения последнего задания, чтобы завершить этот этап

Вложение заданий

Некоторые многократно используемые задания можно определить как настраиваемые этапы, а затем ссылаться на них на других этапах следующим образом, чтобы снизить затраты на кодирование.

{
  seoFetch: {
    type: 'serial',
    jobs:
    [
      'getVideo',
      { jobType: 'stage', name: 'postGetVideo' }
    ]
  },
  postGetVideo: {
    type: 'parallel',
    jobs: [
      'anyjob',
      'anyjob2'
    ]
  }
}

Контекст выполнения работы

Чтобы облегчить кодирование и снизить стоимость изменений, каждый контекст выполнения задания аналогичен выборке Nuxt, но доступ к некоторым состояниям осуществляется через параметр контекста.Поскольку на этапе выборки нет экземпляра компонента, для сохранения единства экземпляров можно получить через это

В настоящее время поддерживается контекст nuxt

  • app
  • route
  • store
  • params
  • query
  • error
  • redirect

Stage
seoFetch
minFetch Лучший параллель
mounted Данные для вторичного ключевого контента, такого как боковые панели, вторые экраны и т. д. Учитывать параллелизм в соответствии с приоритетом
idle Данные о наименее важном содержимом, например о нижней части страницы, где вкладки скрыты. Попробуйте сделать это в партиях, не влияя на взаимодействие пользователя

Использование SVG для создания пошаговой практики скелетного экрана

Поскольку сервер извлекает только ключевые данные, на некоторых страницах нет данных, поэтому для улучшения взаимодействия необходим скелетный экран.

20190808163542.png

20190808163628.png

Использование и принцип загрузки содержимого Vue

пример

<script>
  import VueContentLoading from 'vue-content-loading';

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

<template>
  <vue-content-loading :width="300" :height="100">
    <circle cx="30" cy="30" r="30" />
    <rect x="75" y="13" rx="4" ry="4" width="100" height="15" />
    <rect x="75" y="37" rx="4" ry="4" width="50" height="10" />
  </vue-content-loading>
</template>

Vue Content Загрузка основного кода

<template>
  <svg :viewBox="viewbox" :style="svg" preserveAspectRatio="xMidYMid meet">
    <rect
      :style="rect.style"
      :clip-path="rect.clipPath"
      x="0"
      y="0"
      :width="width"
      :height="height"
    />

    <defs>
      <clipPath :id="clipPathId">
        <slot>
          <rect x="0" y="0" rx="5" ry="5" width="70" height="70" />
          <rect x="80" y="17" rx="4" ry="4" width="300" height="13" />
          <rect x="80" y="40" rx="3" ry="3" width="250" height="10" />
          <rect x="0" y="80" rx="3" ry="3" width="350" height="10" />
          <rect x="0" y="100" rx="3" ry="3" width="400" height="10" />
          <rect x="0" y="120" rx="3" ry="3" width="360" height="10" />
        </slot>
      </clipPath>

      <linearGradient :id="gradientId">
        <stop offset="0%" :stop-color="primary">
          <animate
            attributeName="offset"
            values="-2; 1"
            :dur="formatedSpeed"
            repeatCount="indefinite"
          />
        </stop>

        <stop offset="50%" :stop-color="secondary">
          <animate
            attributeName="offset"
            values="-1.5; 1.5"
            :dur="formatedSpeed"
            repeatCount="indefinite"
          />
        </stop>

        <stop offset="100%" :stop-color="primary">
          <animate
            attributeName="offset"
            values="-1; 2"
            :dur="formatedSpeed"
            repeatCount="indefinite"
          />
        </stop>
      </linearGradient>
    </defs>
  </svg>
</template>

<script>
  const validateColor = color =>
    /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(color);
  export default {
    name: 'VueContentLoading',
    props: {
      rtl: {
        default: false,
        type: Boolean,
      },
      speed: {
        default: 2,
        type: Number,
      },
      width: {
        default: 400,
        type: Number,
      },
      height: {
        default: 130,
        type: Number,
      },
      primary: {
        type: String,
        default: '#f0f0f0',
        validator: validateColor,
      },
      secondary: {
        type: String,
        default: '#e0e0e0',
        validator: validateColor,
      },
    },
    computed: {
      viewbox() {
        return `0 0 ${this.width} ${this.height}`;
      },
      formatedSpeed() {
        return `${this.speed}s`;
      },
      gradientId() {
        return `gradient-${this.uid}`;
      },
      clipPathId() {
        return `clipPath-${this.uid}`;
      },
      svg() {
        if (this.rtl) {
          return {
            transform: 'rotateY(180deg)',
          };
        }
      },
      rect() {
        return {
          style: {
            fill: 'url(#' + this.gradientId + ')',
          },
          clipPath: 'url(#' + this.clipPathId + ')',
        };
      },
    },
    data: () => ({
      uid: null,
    }),
    created() {
      this.uid = this._uid;
    },
  };
</script>

Заморозка SVG-анимации

После использования загрузки содержимого Vue в качестве каркасного экрана я обнаружил, что анимация будет зависать при загрузке и выполнении js, в то время как анимация CSS в большинстве случаев может выполняться вне основного потока, что позволяет избежать зависания.

CSS animations are the better choice. But how? The key is that as long as the properties we want to animate do not trigger reflow/repaint (read CSS triggers for more information), we can move those sampling operations out of the main thread. The most common property is the CSS transform. If an element is promoted as a layer, animating transform properties can be done in the GPU, meaning better performance/efficiency, especially on mobile. Find out more details in OffMainThreadCompositing. developer.Mozilla.org/en-US/docs/…

Адрес тестовой демонстрации

Это bin.com/myNOx библиотека/…

Похоже, что браузеры не оптимизируют анимацию SVG в этом отношении, и, наконец, мы изменили реализацию загрузки контента Vue, чтобы использовать анимацию CSS для достижения эффекта мерцающей загрузки.

<template>
  <div :style="style">
    <svg :viewBox="viewbox" preserveAspectRatio="xMidYMid meet">
      <defs :key="uid">
        <clipPath :id="clipPathId" :key="clipPathId">
          <slot>
            <rect x="0" y="0" rx="5" ry="5" width="70" height="70" />
            <rect x="80" y="17" rx="4" ry="4" width="300" height="13" />
            <rect x="80" y="40" rx="3" ry="3" width="250" height="10" />
            <rect x="0" y="80" rx="3" ry="3" width="350" height="10" />
            <rect x="0" y="100" rx="3" ry="3" width="400" height="10" />
            <rect x="0" y="120" rx="3" ry="3" width="360" height="10" />
          </slot>
        </clipPath>
      </defs>
    </svg>
  </div>
</template>

<script>
  const validateColor = color =>
    /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(color);

  export default {
    name: 'VueContentLoading',
    props: {
      rtl: {
        default: false,
        type: Boolean,
      },
      speed: {
        default: 2,
        type: Number,
      },
      width: {
        default: 400,
        type: Number,
      },
      height: {
        default: 130,
        type: Number,
      },
      primary: {
        type: String,
        default: '#F0F0F0',
        validator: validateColor,
      },
      secondary: {
        type: String,
        default: '#E0E0E0',
        validator: validateColor,
      },
      uid: {
        type: String,
        required: true,
      },
    },
    computed: {
      viewbox() {
        return `0 0 ${this.width} ${this.height}`;
      },
      formatedSpeed() {
        return `${this.speed}s`;
      },
      clipPathId() {
        return `clipPath-${this.uid || this._uid}`;
      },
      style() {
        return {
          width: `${this.width}px`,
          height: `${this.height}px`,
          backgroundSize: '200%',
          backgroundImage: `linear-gradient(-90deg, ${this.primary} 0, ${this.secondary} 20%, ${this.primary} 50%,  ${this.secondary} 75%,  ${this.primary})`,
          clipPath: 'url(#' + this.clipPathId + ')',
          animation: `backgroundAnimation ${this.formatedSpeed} infinite linear`,
          transform: this.rtl ? 'rotateY(180deg)' : 'none',
        };
      },
    },
  };
</script>

<style lang="scss">
  @keyframes backgroundAnimation {
    0% {
      background-position-x: 100%;
    }

    50% {
      background-position-x: 0;
    }

    100% {
      background-position-x: -100%;
    }
  }
</style>

Этап гидратации на стороне клиента Vue SSR на практике в яме

один пример

<template>
  <div :id="id"> text: {{ id }}</div>
</template>
<script>
  export default {
    data () {
       return {
         id: Math.random()
       }
    }
  }
</script>

Каков результат гидратации на стороне клиента?

  • A. id — это случайное число на стороне клиента, текст — это случайное число на стороне клиента.
  • B. id — это случайное число на стороне клиента, текст — это случайное число на стороне сервера.
  • C. id — это случайное число на стороне сервера, текст — это случайное число на стороне клиента.
  • D. id — это случайное число на стороне сервера, текст — это случайное число на стороне сервера

Зачем задавать этот вопрос?

Внутренняя загрузка контента Vue зависит от this._uid как svg defs в id clippath, но this._uid не одинаков на клиенте и сервере, приведенный выше пример на самом деле почти похож на случайное число.

Результат клиентской боковой гидратации является C

То есть id не изменился, что вызвало в нашей сцене такое явление, как экран-скелет мерцал и исчезал.

Почему это происходит?

Весь процесс от инициализации Vue до окончательного рендеринга

20190808172826.png

источник:США ТБ Huang Yi.GitHub.IO/v UE-Anarias…

Так называемая активация на стороне клиента относится к процессу, когда Vue принимает статический HTML, отправленный сервером на стороне браузера, и превращает его в динамический DOM, управляемый Vue.

В entry-client.js монтируем (монтируем) приложение следующей строкой:

// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app');

Поскольку сервер уже отобразил HTML, нам, очевидно, не нужно его выбрасывать и заново создавать все элементы DOM. Вместо этого нам нужно «активировать» эти статические HTML, а затем сделать их динамическими (способными реагировать на последующие изменения данных).

Если вы проверите рендеринг сервера вывода, то заметите, что добавляете специальный атрибут к корневому элементу приложения:

<div id="app" data-server-rendered="true"></div>

// 强制使用应用程序的激活模式
app.$mount('#app', true);

list of modules that can skip create hook during hydration because they are already rendered on the client or has no need

Создание уникального UUID на основе компонента

  • реквизит и слоты преобразуются в строки
  • Хэш алгоритм

Слишком тяжелый, сдавайся

Окончательное решение

Просто позвольте пользователю передать идентификатор самостоятельно

<vue-content-loading
  uid="circlesMediaSkeleton"
  v-bind="$attrs"
  :width="186"
  :height="height"
>
  <template v-for="i in rows">
    <rect
      :key="i + '_r'"
      x="4"
      :y="getYPos(i, 4)"
      rx="2"
      ry="2"
      width="24"
      height="24"
    />
    <rect
      :key="i + '_r'"
      x="36"
      :y="getYPos(i, 6)"
      rx="3"
      ry="3"
      width="200"
      height="18"
    />
  </template>
</vue-content-loading>

Эффект оптимизации

  • Сокращение времени извлечения данных за счет сокращения задач извлечения данных на этапе выборки
  • В то же время количество компонентов и накладные расходы на рендеринг на стороне сервера сокращаются, а время до первого байта сокращается.
  • Меньший размер экрана также сокращает время, необходимое для загрузки экрана выше сгиба.

В совокупности первый байт, первый экран будут опережать время, интерактивное время будет опережать

локальные данные

Типы время ответа службы Домашний размер не Gzip
Перед модификацией главной страницы 0.88s 561 KB
Домашняя страница (минимум запросов на выборку) 0.58s 217 KB

Когда локальный тестовый сервер выполняет рендеринг домашнего ключа и поэтому запрашивает только запросы интерфейса сервера, время отклика службына 0,30 с короче,на 34% нижеРазмер основного HTML-текста,

онлайн данные

file

Среднее время просмотра первого экрана главной страницы уменьшено с 2-3 с до примерно 1,1 с, а скорость загрузки увеличена на 100%+.

Суммировать

В этой статье рассказывается, как решить проблему противоречия между SEO и улучшением пользовательского опыта, объясняется, как мы можем извлечь уроки из концепции пайплайна Gitlab CI с учетом сворачивания и минимизации побочного рендеринга SEO в сервисе, делиться техническими подробности адаптивного SSR, практические идеи дизайна, а также некоторые связанные с этим подпроблемы, возникающие при реализации программы во время ступенчатого карьера, мы надеемся вдохновить и помочь.


обо мне

binggg(Booker Zhao) @腾讯

- 先后就职于迅雷、腾讯等,个人开源项目有 mrn.js 等
- 创办了迅雷内部组件仓库 XNPM ,参与几个迅雷前端开源项目的开发
- 热衷于优化和提效,是一个奉行“懒惰使人进步”的懒人工程师

Социальные данные

Публичный аккаунт WeChatbinggg_net, добро пожаловать, чтобы следовать