Первоначально опубликовано на deno.com/blog.

Одним из первых комиксов XKCD, ставших вирусными, был этот, #303:

Сегодня версия веб-разработчика будет «зданием моего сайта», и они будут играть на мечах в виртуальной реальности.

В наши дни создание сайтов требует времени. Создание большого сайта Next.js 11 займет несколько минут. Это потерянное время в цикле разработки. Инструменты сборки, такие как Vite или Turbopack, подчеркивают их способность снизить это число.

Но более глубокий вопрос не был рассмотрен:

Зачем вообще нужен шаг сборки?

Как строительство стало нормой

В более простые времена вы добавляли несколько тегов <script src="my_jquery_script.js"></script> в свой index.html, и все было на высоте.

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

Если бы мы оставили его там, все было бы хорошо. Но в какой-то момент кто-то задал опасный вопрос:

Что, если бы я мог писать JS на стороне сервера, но в браузере?

Серверный JavaScript Node несовместим с браузерным JavaScript, потому что каждая реализация удовлетворяет две совершенно разные системы:

  • Node построен вокруг файловой системы. Сервер использует HTTP-управляемый ввод-вывод, но все его внутренние функции связаны с поиском нужных файлов в файловой системе.
  • JavaScript был создан для браузеров, в которых сценарии и ресурсы импортируются асинхронно через URL-адреса.

Другие ключевые проблемы, которые привели к необходимости этапа сборки, включают:

  1. В браузере не было «менеджера пакетов», в то время как npm быстро становился де-факто менеджером пакетов для Node и JavaScript в целом. Frontend-разработчикам нужен был простой способ управления зависимостями JavaScript в браузере.
  2. Модули npm и метод их импорта (CommonJS) не поддерживаются в браузере.
  3. Браузерный JavaScript продолжает развиваться (с 2009 года в него добавлены Promises, async/awaits, awaits верхнего уровня, модули ES и классы), в то время как Node JavaScript отстает на несколько циклов.
  4. На сервере используются разные разновидности JavaScript. CoffeeScript привнес в язык стили, подобные Pythonic и Ruby, JSX позволил писать HTML-разметку, а Typescript обеспечил безопасность типов. Но все это нужно перевести в обычный JavaScript для браузера.
  5. Node имеет модульную структуру, поэтому код из разных модулей npm необходимо объединять и минимизировать, чтобы уменьшить объем кода, отправляемого клиенту.
  6. Некоторые функции, используемые в исходном коде, могут быть недоступны для старых браузеров, поэтому необходимо добавить полифиллы, чтобы восполнить пробел.
  7. Фреймворки и препроцессоры CSS (такие как LESS и SASS), которые были созданы для улучшения опыта написания и поддержки сложных кодовых баз CSS, должны быть преобразованы в ванильный, анализируемый браузером CSS.
  8. Отображение динамических данных с помощью HTML (а-ля генераторы статических сайтов) обычно требует отдельного шага, прежде чем HTML будет развернут у хостинг-провайдера.

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

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

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

Рост инструментов сборки JavaScript

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

В 2011 году Browserify запустила сборку Node/npm для браузера. Затем появился Gulp (2013) и другие инструменты сборки, средства запуска задач и т. д., чтобы управлять множеством задач сборки, необходимых для того, чтобы разработчики могли продолжать писать Node, но для браузера. На сцену вышло все больше и больше инструментов для сборки.

Вот неполный список инструментов сборки с течением времени:

К 2020-м годам инструменты сборки стали отдельной категорией библиотеки/фреймворка JS. Многие из этих инструментов также имеют собственную экосистему плагинов и загрузчиков, позволяющую разработчикам использовать свои любимые технологии.

Например, Webpack предоставляет множество загрузчиков для SASS, Babel, SVG и Bootstrap среди многих других. Это позволяет разработчикам выбирать свой собственный стек сборки: они могут использовать webpack в качестве сборщика модулей, babel в качестве транспилятора TS с загрузчиком postcss для Tailwind.

Шаги сборки неизбежны в современной веб-разработке. Но прежде чем мы сможем спросить, нужны ли вообще инструменты сборки, давайте сначала спросим:

Что именно должно произойти, чтобы серверный JavaScript запустился в браузере?

Четырехэтапный процесс сборки Next.js

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

npx create-next-app --example blog-starter blog-starter-app

Ничего не меняя, запустим:

npm run build

Это запустит 4-этапный процесс запуска вашего проекта Next.js в браузере:

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

Компиляция

Когда вы создаете веб-приложение, ваше основное внимание уделяется производительности и опыту. Таким образом, вы будете использовать фреймворк наподобие Next.js, что означает, что вы, вероятно, также используете React, модули ESM, JSX, async/await, TypeScript и т. д. Но этот код необходимо преобразовать в обычный JavaScript для браузера, который происходит на этапе компиляции:

  • Сначала разберите код и превратите его в абстрактное представление, называемое Абстрактное синтаксическое дерево.
  • Затем преобразуйте этот AST в представление, поддерживаемое целевым языком.
  • Наконец, сгенерируйте новый код из этого нового представления AST.

Если вы хотите узнать больше о внутреннем устройстве компиляторов, Супер крошечный компилятор — отличное руководство по их работе.

Первым шагом Next.js является компиляция всего вашего кода в простой JavaScript. Давайте возьмем функцию Post в [slug].tsx в качестве примера:

export default function Post({ post, morePosts, preview }: Props) {
  const router = useRouter()
  if (!router.isFallback && !post?.slug) {
    return <ErrorPage statusCode={404} />
  }
  return (
    <Layout preview={preview}>
      <Container>
        <Header />
        {router.isFallback ? (
          <PostTitle>Loading…</PostTitle>
        ) : (
          <>
            <article className="mb-32">
              <Head>
                <title>
                  {post.title} | Next.js Blog Example with {CMS_NAME}
                </title>
                <meta property="og:image" content={post.ogImage.url} />
              </Head>
              <PostHeader
                title={post.title}
                coverImage={post.coverImage}
                date={post.date}
                author={post.author}
              />
              <PostBody content={post.content} />
            </article>
          </>
        )}
      </Container>
    </Layout>
  )
}

Компилятор проанализирует этот код, преобразует его в AST, преобразует этот AST в правильную функциональную форму для JS браузера и сгенерирует новый код. А вот скомпилированный код этой функции, который отправляется в браузер:

function y(e) {
  let { post: t, morePosts: n, preview: l } = e,
    c = (0, r.useRouter)();
  return c.isFallback || (null == t ? void 0 : t.slug)
    ? (0, s.jsx)(v.Z, {
      preview: l,
      children: (0, s.jsxs)(a.Z, {
        children: [
          (0, s.jsx)(h, {}),
          c.isFallback
            ? (0, s.jsx)(j, {
              children: "Loading…",
            })
            : (0, s.jsx)(s.Fragment, {
              children: (0, s.jsxs)("article", {
                className: "mb-32",
                children: [
                  (0, s.jsxs)(N(), {
                    children: [
                      (0, s.jsxs)("title", {
                        children: [
                          t.title,
                          " | Next.js Blog Example with ",
                          w.yf,
                        ],
                      }),
                      (0, s.jsx)("meta", {
                        property: "og:image",
                        content: t.ogImage.url,
                      }),
                    ],
                  }),
                  (0, s.jsx)(p, {
                    title: t.title,
                    coverImage: t.coverImage,
                    date: t.date,
                    author: t.author,
                  }),
                  (0, s.jsx)(x, {
                    content: t.content,
                  }),
                ],
              }),
            }),
        ],
      }),
    })
    : (0, s.jsx)(i(), {
      statusCode: 404,
    });
}

Минификация

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

Вышеупомянутая версия также является «улучшенной» версией. Вот как это выглядит на самом деле:

function y(e) {
  let { post: t, morePosts: n, preview: l } = e, c = (0, r.useRouter)();
  return c.isFallback || (null == t ? void 0 : t.slug)
    ? (0, s.jsx)(v.Z, {
      preview: l,
      children: (0, s.jsxs)(a.Z, {
        children: [
          (0, s.jsx)(h, {}),
          c.isFallback
            ? (0, s.jsx)(j, { children: "Loading…" })
            : (0, s.jsx)(s.Fragment, {
              children: (0, s.jsxs)("article", {
                className: "mb-32",
                children: [
                  (0, s.jsxs)(N(), {
                    children: [
                      (0, s.jsxs)("title", {
                        children: [
                          t.title,
                          " | Next.js Blog Example with ",
                          w.yf,
                        ],
                      }),
                      (0, s.jsx)("meta", {
                        property: "og:image",
                        content: t.ogImage.url,
                      }),
                    ],
                  }),
                  (0, s.jsx)(p, {
                    title: t.title,
                    coverImage: t.coverImage,
                    date: t.date,
                    author: t.author,
                  }),
                  (0, s.jsx)(x, { content: t.content }),
                ],
              }),
            }),
        ],
      }),
    })
    : (0, s.jsx)(i(), { statusCode: 404 });
}

Комплектация

Весь вышеприведенный код содержится в файле с именем (для этой сборки) [slug]-af0d50a2e56018ac.js. Когда этот код преттифицирован, файл имеет длину 447 строк. Сравните это с гораздо меньшими 56 строками кода из исходных [slug].tsx, которые мы редактировали.

Почему мы отправили файл в 10 раз больше?

Из-за другой важной части процесса сборки: комплектования.

Несмотря на то, что [slug].tsx состоит всего из 56 строк, он зависит от многих зависимостей и компонентов, которые, в свою очередь, зависят от большего количества зависимостей и компонентов. Все эти модули должны быть загружены для правильной работы [slug].tsx.

Давайте воспользуемся круизером зависимостей, чтобы визуализировать это. Во-первых, мы просто посмотрим на компоненты:

npx depcruise --exclude "^node_modules" --output-type dot pages | dot -T svg > dependencygraph.svg

Вот график зависимости:

Не плохо. Но у каждого из них есть зависимости модуля узла. Давайте удалим этот --exclude "^node_modules", чтобы посмотреть все в этом проекте:

npx depcruise --output-type dot pages | dot -T svg > dependencygraph.svg

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

(Кто знал, что столько всего ушло на date-fns?)

Сборщики требуют создания графа зависимостей для точки входа в код (обычно index.js), затем работают в обратном порядке, ища все, от чего зависит index.js, затем все, от чего зависит index.js, и так далее. Затем он объединяет все это в один выходной файл, который можно отправить в браузер.

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

Разделение кода

Или нет, если у вас есть разделение кода.

Без разделения кода один связанный JS-файл будет отправлен клиенту при первом посещении сайта пользователем, независимо от того, нужен ли весь этот JavaScript целиком. При разделении кода, шаге оптимизации производительности, JavaScript разбивается по точкам входа (например, по страницам или компонентам пользовательского интерфейса) или по динамическому импорту (поэтому в любой момент времени должна быть отправлена ​​только небольшая часть JavaScript). Разделение кода помогает лениво загружать то, что в данный момент необходимо пользователю, загружая только то, что необходимо, и избегая кода, который может никогда не использоваться. С React вы можете получить уменьшение размера основного пакета до 30% при использовании разделения кода.

В нашем примере [slug]-af0d50a2e56018ac.js — это код, необходимый для загрузки определенной страницы сообщения в блоге, и он не включает код для домашней страницы или любого другого компонента на сайте.

Вы можете начать понимать, почему в экосистеме так много систем сборки и инструментов: это дерьмо сложное. Мы даже не рассмотрели все параметры, необходимые для настройки и компиляции CSS. Учебники Webpack на YouTube длятся буквально часами. Длительное время сборки — обычное разочарование, настолько сильное, что основной темой недавнего обновления Next.js 13 было ускорение сборки.

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

А если другой подход?

Без строительства с Deno и Fresh

Я нахожу Deno похожим: если вы изучаете серверный JavaScript с помощью Deno, вы можете случайно изучить веб-платформу. Это передаваемые знания.

— Джим Нильсен, Deno is Webby (pt. 2)

Шаги сборки прежде всего связаны с простой проблемой — JavaScript Node отличается от JavaScript браузера. Но что, если бы мы могли с самого начала написать совместимый с браузером JavaScript, использующий такие веб-API, как fetch, и собственный импорт ESM?

Это Дено. Deno использует подход, в котором веб-JS значительно улучшился за последние годы и теперь является чрезвычайно мощным языком сценариев. Мы все должны использовать его.

Вот как вы можете сделать то же самое, что и выше, создать блог, но с помощью Deno и Fresh.

Fresh — это веб-фреймворк, построенный на Deno, в котором нет этапа сборки — ни связывания, ни транспиляции — и это задумано. Когда на сервер поступает запрос, Fresh рендерит каждую страницу на лету и отправляет только HTML (если не задействован остров, то также будет отправлено только необходимое количество JavaScript).

Сборка «точно в срок» по сравнению с пакетированием

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

С Fresh, когда пользователь нажимает на страницу сообщения, загружается /routes/[slug].tsx. Эта страница импортирует следующие модули:

import { Handlers, PageProps } from "$fresh/server.ts";
import { Head } from "$fresh/runtime.ts";
import { getPost, Post } from "@/utils/posts.ts";
import { CSS, render } from "$gfm";

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

import { Handlers, PageProps } from "https://deno.land/x/[email protected]/server.ts";
import { Head } from "https://deno.land/x/[email protected]/runtime.ts";
import { getPost, Post } from "../utils/posts.ts";
import { CSS, render } from "https://deno.land/x/[email protected]/mod.ts";

Мы импортируем getPost и Post из собственного модуля posts.ts. В этих компонентах мы импортируем модули с других URL:

import { extract } from "https://deno.land/[email protected]/encoding/front_matter.ts";
import { join } from "https://deno.land/[email protected]/path/posix.ts";

В любой точке графика зависимостей мы просто вызываем код с других URL-адресов. Как черепахи, это URL-адреса до самого низа.

Своевременная транспиляция

Fresh также не требует каких-либо отдельных шагов транспиляции, так как все происходит точно в срок по запросу:

  • Заставьте TypeScript и TSX работать в браузере: среда выполнения Deno транспилирует TypeScript и TSX «из коробки» и «точно в срок» по запросу.
  • Рендеринг на стороне сервера: передача динамических данных через шаблон для генерации HTML также происходит по запросу.
  • Написание TypeScript на стороне клиента через острова: TypeScript на стороне клиента транслируется в JavaScript по запросу, что необходимо, поскольку браузеры не понимают TypeScript.

А чтобы сделать ваше приложение Fresh более производительным, весь клиентский JavaScript/TypeScript кэшируется после первого запроса для быстрого последующего извлечения.

Лучше код, быстрее

Пока разработчики не пишут необработанный HTML, JS и CSS и нуждаются в оптимизации ресурсов для производительности конечного пользователя, неизбежно будет какой-то этап «сборки». Является ли этот шаг отдельным шагом, который занимает несколько минут и выполняется в CI/CD, или он выполняется точно в момент поступления запроса, зависит от выбранной вами среды или стека.

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

Вы также можете быстрее развертывать. Поскольку нет этапа сборки, особенно при использовании облака изоляции v8 Deno Deploy, ваше развертывание в глобальном масштабе занимает секунды, поскольку оно просто загружает несколько килобайт JavaScript.

Вы также пишете лучший код с лучшим опытом разработчика. Вместо того, чтобы изучать Node или специфичные API-интерфейсы поставщиков, пытаясь связать Node, ESM и совместимый с браузером JavaScript через сеть сборщиков, вы можете написать веб-стандартный JavaScript, изучая API-интерфейсы, которые вы можете повторно использовать с любыми облачными примитивами.

Пропустите этап сборки и попробуйте сделать что-нибудь с помощью Fresh и Deno Deploy.