Найти тему
Обо всем

Как построить A/B-тесты с нулевым CLS с помощью Next.js и Vercel Edge Config

A/B-тестирование и эксперименты помогут вам создать культуру роста. Вместо того, чтобы гадать, какой опыт лучше всего подойдет вашим пользователям, вы можете строить, итерировать и адаптировать, опираясь на данные, чтобы создать максимально эффективный пользовательский интерфейс.

В этой статье вы узнаете, как мы создали высокопроизводительный механизм экспериментов для vercel.com с использованием Next.js и Vercel Edge Config, позволяющий нашим разработчикам создавать эксперименты, которые загружаются мгновенно с нулевым кумулятивным сдвигом макета (CLS) и отличным опытом разработчиков.

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

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

При рендеринге на стороне клиента (CSR) ваши эксперименты оценивают, какую версию вашего приложения увидит пользователь после загрузки страницы. Это приводит к плохому UX, поскольку вашим пользователям придется ждать загрузчика, пока эксперимент будет оценен и в конечном итоге отображен, создавая смещение макета.
Рендеринг на стороне сервера (SSR) может замедлить время отклика страницы, поскольку эксперименты оцениваются по требованию. Пользователям приходится ждать экспериментов по аналогичному графику, как и CSR - но смотреть на пустую страницу, пока не будет выполнена вся работа по созданию и обслуживанию страницы.
Эти стратегии приводят к плохому пользовательскому опыту и дают вам плохие данные о ваших экспериментах. Поскольку вы ухудшаете время загрузки, вы негативно влияете на то, что должно было стать вашей контрольной группой.

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

Определение механизма экспериментов


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

-Нулевое воздействие на конечных пользователей
-Автоматическое сохранение пользователей в их "экспериментальном ведре" с помощью нашего поставщика флагов возможностей
-Отправка событий в наше хранилище данных, когда пользователи дают согласие.
-Отличный опыт разработчика "из коробки".
Используя правильные инструменты, мы знали, что можем улучшить как работу наших внутренних разработчиков, так и работу конечных пользователей.

Использование Edge Config для повышения скорости чтения
Vercel Edge Config - это хранилище данных JSON, которое позволяет считывать динамические данные как можно ближе к пользователям. Edge Config считывает данные в течение 15 мс при P99 или даже 0 мс в некоторых сценариях.

import { NextResponse } from 'next/server'
import { get } from '@vercel/edge-config'

export const config = { matcher: '/welcome' }

export async function middleware() {
const greeting = await get('greeting')
return NextResponse.json(greeting)
}

Мы используем Edge Config Integration от Statsig для автоматического заполнения Edge Config, подключенного к нашему проекту, правилами оценки эксперимента. После этого мы можем легко получать результаты наших экспериментов в нашем коде.

import { EdgeConfigDataAdapter } from 'statsig-node-vercel'
import statsig from 'statsig-node'

async function initializeStatsig() {
const dataAdapter = new EdgeConfigDataAdapter(
process.env.STATSIG_EDGE_CONFIG_ITEM_KEY,
process.env.STATSIG_EDGE_CONFIG,
)

await statsig.initialize(process.env.STATSIG_SERVER_KEY, { dataAdapter })
}

async function getExperiment(userId, experimentName) {
await initializeStatsig()
return statsig.getExperiment({ userId }, experimentName)
}

Использование Next.js для высокопроизводительного и гибкого опыта

В качестве высокоуровневого обзора мы собираемся:

-Создадим безопасность типов для нашего кода
-Предварительно отрендерить наши эксперименты на сервере
-Предоставлять правильные варианты страниц нашим пользователям, используя их cookies
-Автоматически перехватывать события с помощью React Context

Создание экспериментов в коде

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

export const EXPERIMENTS = {
pricing_redesign: {
params: {
enabled: [false, true],
bgGradientFactor: [1, 42]
},
paths: ['/pricing']
},
skip_button: {
params: {
skip: [false, true]
},
// A client-side experiment won't need path values
paths: []
}
} as const

Варианты экспериментов перед рендерингом

Динамические маршруты позволяют пользователям Next.js создавать шаблоны страниц, которые изменяют то, что они отображают, используя параметры в маршруте. Для наших целей мы закодируем варианты эксперимента в имени пути страницы. Мы предоставим эти данные через мощные API Next.js для получения данных: getStaticProps и getStaticPaths.

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

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

Пути, не сгенерированные во время сборки, возвращаются к шаблонам Next.js "Incremental Static Generation" или "Incremental Static Regeneration" в зависимости от того, указали ли вы интервал повторной проверки. Решение о том, сколько страниц вы хотите предварительно отрендерить, является компромиссом между временем сборки и влиянием задержки начального рендеринга для первого посетителя инкрементально статически отрендерированной страницы.

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

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

// Your encoding implementation
import { encodeVariations, decodeVariations } from './encoders'

export function experimentGetStaticPaths(
path,
maxGeneratedPaths = 100
) {
return (context) => {
const paths = encodeVariations(path, maxGeneratedPaths)

return {
paths,
fallback: 'blocking',
}
}
}


export function experimentGetStaticProps(pageGetStaticProps) {
return async (context) => {
const { props: pageProps, revalidate } = await pageGetStaticProps(context)
const encodedRoute = context.params?.experiments

// Read from URL or use default values
const experiments = decodeVariations(encodedRoute) ?? EXPERIMENT_DEFAULTS

return {
props: {
...pageProps,
experiments
},
revalidate
}
}

Теперь, когда мы собираемся провести эксперимент, мы можем написать:

export const getStaticPaths = experimentGetStaticPaths("/pricing")

export const getStaticProps = experimentGetStaticProps(async () => {
const { prices } = await fetchPricingMetadata()

return {
props: {
prices
}
}
})

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

Для того чтобы наши пользователи попадали на правильные варианты страниц, мы используем перезапись в Next.js Middleware.

Если пользователь уже посещал сайт vercel.com, перезапись выполняется на основе существующего cookie пользователя.
Если у пользователя нет cookie, Middleware считывает данные из Edge Config и кодирует значения, определяя маршрут для вариации. Благодаря скорости Edge Config мы знаем, что у нашего пользователя не будет задержки при загрузке страницы.
Когда вы используете перезапись в Middleware, ваш пользователь все еще видит /pricing в строке браузера, даже если вы предоставили /pricing/0p0v0, статически предварительно отрендеренную версию страницы с экспериментальными значениями.

import { NextResponse } from 'next/server'

async function getExperimentsForRequest(req) {
const cookie = getExperimentsCookie(req)
const experiments = cookie
? parseExperiments(cookie)
: readExperimentsFromEdgeConfigAndUpdateCookie(req)

return experiments
}

export async function middleware(req) {
const experiments = await getExperimentsForRequest(req)
const path = getPathForExperiment(experiments, req)

return NextResponse.rewrite(new URL(path, req.url))
}

export const config = {
matcher: '/pricing'
}

Значение cookie - это параметры эксперимента, назначенные с помощью интеграции Statsig Edge Config, которую мы настроили ранее. Наши пользователи попадают в "ведра" на основе значений, которые им присваивает интеграция Statsig, разумно управляя тем, какие пользователи получают те или иные эксперименты.

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


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

function Pricing({ prices }) {
const { enabled, bgGradientFactor } = useExperiment("pricing_redesign")

return (
<div>
<h1>Pricing</h1>
{
enabled ?
<GradientPricingTable factor={bgGradientFactor} prices={prices} />
: <PricingTable prices={prices} />
}
</div>
)
}

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

Оценка успеха

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

export function trackExperiment(experimentName) {
analytics(EXPERIMENT_VIEWED, getTrackingMetadataForExperiment(experimentName))
}

const Context = createContext()

export function ExperimentContext({
experiments,
path,
children
}) {
useEffect(() => {
for (const experimentName of getExperimentsForPath(path)) {
trackExperiment(experimentName);
}
}, [])

return (
<Context.Provider experiments={experiments}>
{children}
</Context.Provider>
)
}

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

Обработка экспериментов на стороне клиента

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

В этих случаях мы используем наш React hook на стороне клиента, поскольку нам не нужно беспокоиться о первоначальной загрузке.

components/PricingModal.ts

function PricingModal() {
// Fully-typed `skip` from EXPERIMENTS constant under the hood
const { skip } = useClientSideExperiment("skip_button")

return (
<Modal>
<Modal.Title>Invite Teammates</Modal.Title>
<Modal.Description>Add members to your team.</Modal.Description>
<Modal.Button>Ok</Modal.Button>
{skip ? <Modal.Button>Skip</Modal.Button> : null}
</Modal>
)
}

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

Обеспечение сохранения свежести экспериментов

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

Поскольку мы используем Edge Function в сочетании с Edge Config, мы можем быть уверены, что это фоновое обновление выполняется молниеносно, избегая холодного старта и используя мгновенное чтение Edge Config.

Эффективные эксперименты, каждый раз

С помощью Next.js и Edge Config мы создали механизм экспериментов, в котором:

Наши пользователи не будут испытывать Cumulate Layout Shift в результате эксперимента.
Мы можем проводить множество экспериментов на уровне страниц и компонентов на каждой странице и между страницами.
Мы можем быстрее и безопаснее отправлять эксперименты в Vercel и проводить итерации.
Мы можем собирать ценные знания о том, что лучше всего работает для наших пользователей, не жертвуя производительностью страницы.
За последние несколько месяцев мы поставили множество различных экспериментов с нулевым CLS на vercel.com, и нам не терпится продолжить улучшать ваш опыт, получая новые данные.

Криво переведено с https://vercel.com/blog/zero-cls-experiments-nextjs-edge-config

Если интересно подобное, пишите, продолжу эксперименты с таким контентом