KapDev

Google PageSpeed Insights: как повысить показатели у Next.js проекта

В статье рассказываем про то как можно быстро разработать современный сервис

В этой статье на примере нашего сайта покажем как нужно оптимизировать Next.js приложения для достижения высоких показателей в Google PageSpeed Insights. Мы смогли добиться 80+ очков производительности на мобильной версии и 100 на десктопе для нашей главной страницы. 

Screen Shot 2024-08-18 at 03.50.26.pngScreen Shot 2024-08-18 at 05.15.25.png

1. Используйте как можно меньше клиентских компонентов

 У нас в команде принцип: как можно меньше нагружать front-end. Если работу можно выполнить на back-end, то лучше это сделать там. По крайней мере это касается рендеринга. 

В современном мире веб разработки есть большая проблема - раздутый (bloated) front-end. После того как Next.js добавил app router, у нас появилась возможность взаимодействия с сайтами быстрее и приятнее, были добавлены Server и Client Components.

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

Если вам нужно клиентское взаимодействие, выделите интерактивные элементы в отдельный компонент 

В качестве примере возьмём блок с приветственным шортсом на нашем сайте. Ради этого даже откроем часть исходного кода. 

// IntroShort

import Box from '@mui/material/Box';
import Image from 'next/image';
import Overlay from './Overlay'; // Это Client Component

import styles from "./Eyes.module.scss";

import shortsImage from '@/assets/images/intro-short.png';

export default function Eyes() {
  return (
    <Box
      className={styles.eyes}
      position="relative"
      overflow="hidden"
      p="5px"
    >
      <Image
        src={shortsImage}
        fill
        priority
        placeholder="blur"
        style={{
          objectFit: 'cover',
          objectPosition: 'center',
          filter: 'blur(1px)'
        }}
        alt="Превью видео о компании"
      />

      <Overlay />
    </Box >
  );
}
'use client';

import {
  useRef,
  useState,
  useLayoutEffect
} from 'react';
import dynamic from 'next/dynamic';
import CircularProgress from '@mui/material/CircularProgress';
import Fade from '@mui/material/Fade';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Dialog from '@mui/material/Dialog';

import shortsVideo from '@/assets/videos/intro-shorts.mp4';

const Stories = dynamic(() => import('@/domains/StoriesSSR'), { ssr: false });

const stories = [
  {
    url: shortsVideo,
    type: 'video'
  }
];

export default function Overlay(props) {
  const videoRef = useRef(null);

  const [loaded, setLoaded] = useState(false);

  const [show, setShow] = useState(false);

  const [hovered, setHovered] = useState(false);

  const video = videoRef.current;

  useLayoutEffect(() => {
    if (video) {
      if (hovered) {
        video.play();
      } else {
        video.pause();

        video.currentTime = 0;
      }
    }
  }, [hovered, Boolean(video)]);

  return (
    <>
      <Dialog
        open={show}
        onClose={setShow.bind(this, false)}
        keepMounted={false}
        TransitionProps={{
          mountOnEnter: true,
          unmountOnExit: true
        }}
      >
        {
          show &&
          <Stories
            loop
            stories={stories}
          />
        }
      </Dialog>

      <Box
        top={0}
        left={0}
        position="absolute"
        width="calc(100%)"
        height="calc(100%)"
        display="flex"
        alignItems="center"
        justifyContent="center"
        sx={{
          backgroundColor: 'rgba(0, 0, 0, .5)',
          cursor: 'pointer',
        }}
        onClick={setShow.bind(this, !show)}
        onMouseOver={setHovered.bind(this, true)}
        onMouseLeave={setHovered.bind(this, false)}
      >
        <Box
          position="relative"
          width="100%"
          height="100%"
        >
          <Fade
            timeout={500}
            in={hovered}
            mountOnEnter
          >
            <Box
              position="absolute"
              top={0}
              left={0}
              width="100%"
              height="100%"
              display="flex"
              alignItems="center"
              justifyContent="center"
            >
              {
                !loaded &&
                <CircularProgress />
              }

              <Box
                component="video"
                width="auto"
                height="100%"
                controls={false}
                preload="auto"
                autoPlay
                onLoadedData={setLoaded.bind(this, true)}
                muted
                ref={videoRef}
                loop
                playsInline
                display={loaded ? 'unset' : 'none'}
              >
                <source src={shortsVideo} type="video/mp4" />
              </Box>
            </Box>
          </Fade>

          <Fade
            timeout={500}
            in={!hovered}
            appear={false}
          >
            <Box
              position="absolute"
              top={0}
              left={0}
              width="100%"
              height="100%"
              display="flex"
              alignItems="center"
              justifyContent="center"
            >
              <Typography
                color="primary.contrastText"
                fontWeight="bold"
                textAlign="center"
              >
                Про
                <br /> компанию
              </Typography>
            </Box>
          </Fade>
        </Box>
      </Box>
    </>
  );
}

В данном примере мы, конечно, сэкономили не очень много, у нас есть более удачные примеры, также можно было бы добавить children в Overlay и, например, выводить Typography из родителя, но в демонстрационных целях этого вполне достаточно. 

2. Применяйте lazy loading

В React можно динамически подгружать необходимые компоненты, то есть JavaScript код будет подгружаться с сервера только тогда, когда "ленивые" компоненты будут вызваны. 

В Next.js это реализовано через функцию "dynamic", которая под капотом работает со стандартной React.lazy и Suspense. В коде выше уже показано как мы динамически импортируем компонент сторисов: 

// Сюда ещё можно добавить компонент для состояния загрузки через "loading() => ...", но НЛО у нас его украл 
const Stories = dynamic(() => import('@/domains/StoriesSSR'), { ssr: false });

 И ниже у нас есть следующая конструкция: 

	<Dialog
        open={show}
        onClose={setShow.bind(this, false)}
        keepMounted={false}
        TransitionProps={{
          mountOnEnter: true,
          unmountOnExit: true
        }}
      >
        {
          show &&
          <Stories
            loop
            stories={stories}
          />
        }
      </Dialog>

Обратите внимание, что для того, чтобы это работало как нужно, мы не просто визуально скрываем компонент "Stories" при помощи "display: none;" или "opacity: 0;" , а именно не вызываем его в принципе. Иначе компонент бы подгружался сразу после первого рендера. В нашем случае он подгружается только после открытия диалога.

P.S. для тех, кто тоже пользуется Material UI: по сути TransitionProps должно быть достаточно, но подстраховки от изменения API и багов стороннего ПО много не бывает, поэтому ещё добавили "show &&" :)

Также не забывайте использовать lazy loading для функций в асинхронных контекстах через import:

"use client";

import Bracket from "public/images/redesign/bracket.svg";

import styles from "./Results.module.scss";

export default function Results() {

  function handleRef(el) {
    if (!el) return;

    Promise.all([
      import('gsap'),
      import('gsap/TextPlugin')
    ]).then(items => {
      const [g, t] = items;

      const gsap = g.gsap;

      const TextPlugin = t.TextPlugin;

      gsap.registerPlugin(TextPlugin);

      gsap.to(el, {
        duration: 4,
        text: "Формируем совместную команду.",
        repeat: -1,
        yoyo: true,
      });
    }).catch(console.error);
  }

  return (
    <div className={styles.results}>
      <span ref={handleRef}></span>
      <Bracket className={styles.resultsImg} />
    </div>
  );
}

3. Используйте минимум зависимостей в проекте

Посмотрите, вероятно, вы установили целый npm пакет из 100 различных функций ради одной? Вероятно, вам будет проще написать свою реализацию этой фичи и установить часть зависимости как, например, мы сделали с lodash:

{"lodash.merge": "^4.6.2"}
import merge from 'lodash.merge';
... 
const components = merge({}, defaultComponents, props.components ?? {});

4. Приоритезируйте загрузку изображений

Если какое-то из ваших изображений было определено как "Largest Contentful Paint", то передайте ему prop priority. Так вы отключите "ленивую" загрузку изображения и сможете улучшить LCP в Google PageSpeed Insights.

Важно: не нужно ставить priority={true} на все ихображения. Это может значительно ухудшить производительность. Если ваши изображения спрятаны внутри других компонентов, то используйте "Props Drilling" и передайте priority={true} в тех местах, где это нужно.

// Вызов компонента кейса в page.jsx на странице портфолио

<Case priority image={cover} stories={stories} name={name} {...attributes} />
// Вызов того же самого компонента в списке кейсов на главной странице

<Case
	image={cover}
	name={name}
	title={slug}
	{...attributes}
/>

Заключение

Это были все Next.js/React специфичные фишки для оптимизации ваших сервисов. Мы не стали включать банальные фишки для любых систем, о которых есть много материалов в интернете, а сфокусировались на наших профильных технологиях - React/Next.js.

Несмотря на то, что важно обращать внимание на показатели Google PageSpeed Insights, не стоит их переоценивать. Низкая оценка от сервиса Гугла ещё не означает, что сайт медленный или имеет плохой юзабилити.

Иногда бывает, что вы уже выжали максимум из своего стэка и упираетесь в текущие технологические ограничения. Например, мы так и не смогли в должной мере побороть "Minimize main thread work" и "Remove unused JavaScript".  Но мы не бросили эти попытки и сейчас изучаем возможность миграции на Preact, что в теории может решить эту проблему.

Как бы мы не старались минимизировать клиентскую часть проекта,  мы всё равно не смогли достичь зелёной отметки по этим показателям. Несмотря на это,  субъективно сайт открывает крайне быстро с любого устройства и использовать его приятно.

Резюме

Как улучшить показатели Google PageSpeed Insights у Next.js проекта?

  • Используйте как можно меньше клиентских компонентов
  • Применяйте lazy loading компонентов и функций
  • Удалите ненужные зависимости
  • Приоритезируйте изображения