Как написать веб приложение на javascript

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

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

image

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

Обзор

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

▍Архитектура приложения

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

  • Архитектура App Shell.
  • Паттерн PRPL (Push, Render, Pre-cache, Lazy loading).

▍Система сборки проекта

В нашем проекте понадобится качественная, настроенная в соответствии с нашими нуждами, система сборки. Здесь мы будем использовать Webpack, предъявляя к системе сборки проекта следующие требования:

  • Поддержка ES6 и возможностей динамического импорта ресурсов.
  • Поддержка SASS и CSS.
  • Раздельная настройка режимов разработки и реальной работы приложения.
  • Возможность автоматической настройки сервис-воркеров.

▍Современные возможности JavaScript

Мы будем пользоваться минимальным набором современных возможностей JavaScript, позволяющим нам разработать то, что нам нужно. Вот о каких возможностях идёт речь:

  • Модули.
  • Разные способы создания объектов (объектные литералы, классы).
  • Динамический импорт ресурсов.
  • Стрелочные функции.
  • Шаблонные литералы.

Теперь, получив общее представление о том, что нам понадобится, мы готовы к тому, чтобы приступить к разработке нашего проекта.

Архитектура приложения

Появление прогрессивных веб-приложений (Progressive Web Application, PWA) способствовало и приходу в веб-разработку новых архитектурных решений. Это позволило веб-приложениям быстрее загружаться и выводиться на экран. Комбинация архитектуры App Shell и паттерна PRPL может привести к тому, что веб-приложение будет быстрым и отзывчивым, похожим на обычное приложение.

▍Что такое App Shell и PRPL?

App Shell — это архитектурный паттерн, применяемый для разработки PWA, при использовании которого в браузер пользователя, при загрузке сайта, отправляют минимальный объём критически важных для работы сайта ресурсов. В состав этих материалов обычно входят все ресурсы, необходимые для первого вывода приложения на экран. Подобные ресурсы можно и кэшировать с использованием сервис-воркера.

Аббревиатура PRPL расшифровывается следующим образом:

  • Push — отправка клиенту критически важных ресурсов для исходного маршрута (в частности, с использованием HTTP/2).
  • Render — вывод исходного маршрута.
  • Pre-cache — заблаговременное кэширование оставшихся маршрутов или ресурсов.
  • Lazy load — «ленивая» загрузка частей приложения по мере того, как в них возникает необходимость (в частности — по запросу пользователя).

▍Реализация App Shell и PRPL в коде

Паттерны App Shepp и PRPL используются совместно. Это позволяет реализовывать передовые подходы к разработке веб-проектов. Вот как выглядит паттерн App Shell в коде:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <!-- Critical Styles -->
    <!-- Начало фрагмента №1 -->
    <style>
        html {
            box-sizing: border-box;
        }

        *,
        *:after,
        *:before {
            box-sizing: inherit;
        }

        body {
            margin: 0;
            padding: 0;
            font: 18px 'Oxygen', Helvetica;
            background: #ececec;
        }

        header {
            height: 60px;
            background: #512DA8;
            color: #fff;
            display: flex;
            align-items: center;
            padding: 0 40px;
            box-shadow: 1px 2px 6px 0px #777;
        }

        h1 {
            margin: 0;
        }

        .banner {
            text-decoration: none;
            color: #fff;
            cursor: pointer;
        }

        main {
            display: flex;
            justify-content: center;
            height: calc(100vh - 140px);
            padding: 20px 40px;
            overflow-y: auto;
        }

        button {
            background: #512DA8;
            border: 2px solid #512DA8;
            cursor: pointer;
            box-shadow: 1px 1px 3px 0px #777;
            color: #fff;
            padding: 10px 15px;
            border-radius: 20px;
        }

        .button {
            display: flex;
            justify-content: center;
        }

        button:hover {
            box-shadow: none;
        }

        footer {
            height: 40px;
            background: #2d3850;
            color: #fff;
            display: flex;
            align-items: center;
            padding: 40px;
        }
    </style>
    <!-- Конец фрагмента №1 -->

    <title>Vanilla Todos PWA</title>
</head>

<body>

    <body>
        <!-- Main Application Section -->
        <!-- Начало фрагмента №2 -->
        <header>
            <h3><font color="#3AC1EF">▍<a class="banner"> Vanilla Todos PWA </a></font></h3>
        </header>
        <main id="app"></main>
        <footer>
            <span>© 2019 Anurag Majumdar - Vanilla Todos SPA</span>
        </footer>
        <!-- Конец фрагмента №2 -->
      
        <!-- Critical Scripts -->
        <!-- Начало фрагмента №3 -->
        <script async src="<%= htmlWebpackPlugin.files.chunks.main.entry %>"></script>
        <!-- Конец фрагмента №3 -->

        <noscript>
            This site uses JavaScript. Please enable JavaScript in your browser.

        </noscript>
    </body>
</body>

</html>

Изучив этот код, можно понять, что шаблон App Shell предусматривает создание «оболочки» приложения, которая представляет собой его «скелет», содержащий минимум разметки. Разберём этот код (здесь и далее фрагменты кода, на которые мы будем ссылаться при разборе, отмечены комментариями, наподобие <!-- Начало фрагмента №1 -->).

  • Фрагмент №1. Важнейшие стили встроены в разметку, а не представлены в виде отдельных файлов. Сделано это для того чтобы CSS-код был бы обработан непосредственно при загрузке HTML-страницы.
  • Фрагмент №2. Здесь представлена «оболочка» приложения. Этими областями позже будет управлять JavaScript-код. Особенно это относится к тому, что будет находиться в теге main с идентификатором app (<main id="app"></main>).
  • Фрагмент №3. Тут в дело вступают скрипты. Атрибут async позволяет не блокировать парсер во время загрузки скриптов.

Представленный выше «скелет» приложения реализует шаги Push и Render паттерна PRPL. Это происходит при разборе HTML-кода браузером для формирования визуального представления страницы. При этом браузер быстро находит критически важные для вывода страницы ресурсы. Кроме того, здесь представлены скрипты (фрагмент №3), ответственные за вывод исходного маршрута путём манипуляций с DOM (на шаге Render).

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

Ниже показан код сервис-воркера, кэширующего «скелет» и все статические ресурсы приложения.

var staticAssetsCacheName = 'todo-assets-v3';
var dynamicCacheName = 'todo-dynamic-v3';

// Начало фрагмента №1
self.addEventListener('install', function (event) {
    self.skipWaiting();
    event.waitUntil(
      caches.open(staticAssetsCacheName).then(function (cache) {
        cache.addAll([
            '/',
            "chunks/todo.d41d8cd98f00b204e980.js","index.html","main.d41d8cd98f00b204e980.js"
        ]
        );
      }).catch((error) => {
        console.log('Error caching static assets:', error);
      })
    );
  });
// Конец фрагмента №1

// Начало фрагмента №2
  self.addEventListener('activate', function (event) {
    if (self.clients && clients.claim) {
      clients.claim();
    }
    event.waitUntil(
      caches.keys().then(function (cacheNames) {
        return Promise.all(
          cacheNames.filter(function (cacheName) {
            return (cacheName.startsWith('todo-')) && cacheName !== staticAssetsCacheName;
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          })
        ).catch((error) => {
            console.log('Some error occurred while removing existing cache:', error);
        });
      }).catch((error) => {
        console.log('Some error occurred while removing existing cache:', error);
    }));
  });
// Конец фрагмента №2

// Начало фрагмента №3
  self.addEventListener('fetch', (event) => {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request)
          .then((fetchResponse) => {
              return cacheDynamicRequestData(dynamicCacheName, event.request.url, fetchResponse.clone());
          }).catch((error) => {
            console.log(error);
          });
      }).catch((error) => {
        console.log(error);
      })
    );
  });
// Конец фрагмента №3

  function cacheDynamicRequestData(dynamicCacheName, url, fetchResponse) {
    return caches.open(dynamicCacheName)
      .then((cache) => {
        cache.put(url, fetchResponse.clone());
        return fetchResponse;
      }).catch((error) => {
        console.log(error);
      });
  }

Разберём этот код.

  • Фрагмент №1. Обработка события install сервис-воркера помогает кэшировать статические ресурсы. Здесь можно поместить в кэш ресурсы «скелета» приложения (CSS, JavaScript, изображения, и так далее) для первого маршрута (в соответствии с наполнением «скелета»). Кроме того, можно загрузить и другие ресурсы приложения, сделав так, чтобы оно могло бы работать без подключения к интернету. Кэширование ресурсов, помимо кэширования «скелета», соответствует шагу Pre-cache паттерна PRPL.
  • Фрагмент №2. При обработке события activate выполняется очистка неиспользуемых кэшей.
  • Фрагмент №3. В этих строках кода выполняется загрузка ресурсов из кэша в том случае, если они там есть. В противном случае выполняются сетевые запросы. Кроме того, если сделан сетевой запрос на получение ресурса, это означает, что этот ресурс ещё не кэширован. Такой ресурс помещают в новый отдельный кэш. Этот сценарий помогает кэшировать динамические данные приложения.

К настоящему моменту мы обсудили большую часть архитектурных решений, которые будут использоваться в нашем приложении. Единственное, о чём мы пока не говорили — это шаг Lazy loading паттерна PRPL. К нему мы вернёмся позже, а пока займёмся системой сборки проекта.

Система сборки проекта

Одной только хорошей архитектуры, без достойной системы сборки проекта, недостаточно для создания качественного приложения. Тут нам и пригодится Webpack. Существуют и другие средства для сборки проектов (бандлеры), например — Parcel и Rollup. Но то, что мы будем реализовывать на базе Webpack, можно сделать и с использованием других средств.

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

Непросто настраивать инструменты наподобие Webpack с нуля. В таких случаях полезно иметь под рукой какое-нибудь хорошее справочное руководство. Таким руководством, с использованием которого написана соответствующая часть данного материала, стала эта статья. Если у вас возникнут какие-то сложности с Webpack — обращайтесь к ней. Теперь давайте вспомним и реализуем те требования к системе сборки проекта, о которых мы говорили в самом начале.

▍Поддержка ES6 и возможностей динамического импорта ресурсов

Для реализации этих возможностей нам пригодится Babel — популярный транспилятор, который позволяет преобразовывать код, написанный с использованием возможностей ES6, в код, который может выполняться в ES5-средах. Для того чтобы наладить работу Babel с Webpack, мы можем воспользоваться следующими пакетами:

  • @babel/core
  • @babel/plugin-syntax-dynamic-import
  • @babel/preset-env
  • babel-core
  • babel-loader
  • babel-preset-env

Вот пример файла .babelrc, рассчитанного на использование с Webpack:

{
    "presets": ["@babel/preset-env"],
    "plugins": ["@babel/plugin-syntax-dynamic-import"]
}

В ходе настройки Babel строка presets этого файла используется для настройки Babel на транспиляцию ES6 в ES5, а строка plugins — для того, чтобы в Webpack можно было бы пользоваться динамическим импортом.

Вот как Babel используется с Webpack (тут приведён фрагмент файла настроек Webpack — webpack.config.js):

module.exports = {
    entry: {
        // Входные файлы
    },
    output: {
        // Выходные файлы
    },
    module: {
        rules: [
            {
                test: /.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader'
                }
            }
        ]
    },
    plugins: [
        // Плагины
    ]
};

В разделе rules этого файла описывается использование загрузчика babel-loader для настройки процесса транспиляции. Остальные части этого файла, ради краткости, опущены.

▍Поддержка SASS и CSS

Для обеспечения поддержки нашей системой сборки проектов SASS и CSS нам понадобятся следующие плагины:

  • sass-loader
  • css-loader
  • style-loader
  • MiniCssExtractPlugin

Вот как выглядит файл настроек Webpack, в который внесены данные об этих плагинах:

module.exports = {
    entry: {
        // Входные файлы
    },
    output: {
        // Выходные файлы
    },
    module: {
        rules: [
            {
                test: /.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader'
                }
            },
            {
                test: /.scss$/,
                use: [
                    'style-loader',
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    'sass-loader'
                ]
            }
        ]
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: '[name].css'
        }),
    ]
};

Загрузчики регистрируются в разделе rules. Так как мы используем плагин для извлечения CSS-стилей, в раздел plugins вносится соответствующая запись.

▍Раздельная настройка режимов разработки и реальной работы приложения

Это — чрезвычайно важная часть процесса сборки приложения. Всем известно, что при создании приложения одни настройки применяются для сборки той его версии, которая используется при разработке, а другие — для его продакшн-версии. Вот перечень пакетов, которые нам здесь пригодятся:

  • clean-webpack-plugin: для очистки содержимого папки dist.
  • compression-webpack-plugin: для сжатия содержимого папки dist.
  • copy-webpack-plugin: для копирования статических ресурсов, например — файлов, из папок с исходными данными приложения в папку dist.
  • html-webpack-plugin: для создания файла index.html в папке dist.
  • webpack-md5-hash: для хэширования файлов приложения в папке dist.
  • webpack-dev-server: для запуска локального сервера, используемого в ходе разработки.

Вот как выглядит итоговый файл webpack.config.js:

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const WebpackMd5Hash = require('webpack-md5-hash');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = (env, argv) => ({
    entry: {
        main: './src/main.js'
    },
    devtool: argv.mode === 'production' ? false : 'source-map',
    output: {
        path: path.resolve(__dirname, 'dist'),
        chunkFilename:
            argv.mode === 'production'
                ? 'chunks/[name].[chunkhash].js'
                : 'chunks/[name].js',
        filename:
            argv.mode === 'production' ? '[name].[chunkhash].js' : '[name].js'
    },
    module: {
        rules: [
            {
                test: /.js$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader'
                }
            },
            {
                test: /.scss$/,
                use: [
                    'style-loader',
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    'sass-loader'
                ]
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin('dist', {}),
        new MiniCssExtractPlugin({
            filename:
                argv.mode === 'production'
                    ? '[name].[contenthash].css'
                    : '[name].css'
        }),
        new HtmlWebpackPlugin({
            inject: false,
            hash: true,
            template: './index.html',
            filename: 'index.html'
        }),
        new WebpackMd5Hash(),
        new CopyWebpackPlugin([
            // {
            //     from: './src/assets',
            //     to: './assets'
            // },
            // {
            //     from: 'manifest.json',
            //     to: 'manifest.json'
            // }
        ]),
        new CompressionPlugin({
            algorithm: 'gzip'
        })
    ],
    devServer: {
        contentBase: 'dist',
        watchContentBase: true,
        port: 1000
    }
});

Вся конфигурация Webpack представлена в виде функции, которая принимает два аргумента. Здесь использован аргумент argv, который представляет аргументы, передаваемые этой функции при выполнении команд webpack или webpack-dev-server. Вот как описание этих команд выглядит в файле проекта package.json:

"scripts": {
    "build": "webpack --mode production && node build-sw",
    "serve": "webpack-dev-server --mode=development --hot",
  },

В результате, если мы выполним команду npm run build, будет выполнена сборка продакшн-версии приложения. Если выполнить команду npm run serve, будет запущен сервер разработки, поддерживающий процесс работы над приложением.

В разделах plugins и devServer вышеприведённого файла показана настройка плагинов и сервера разработки.

В разделе, который начинается со строи new CopyWebpackPlugin, задают ресурсы, которые нужно скопировать из исходных материалов приложения.

▍Настройка сервис-воркера

Все мы знаем о том, что ручное составление списков файлов, например, предназначенных для кэширования, довольно-таки скучное занятие. Поэтому здесь мы воспользуемся специальным скриптом сборки сервис-воркера, который находит файлы в папке dist и добавляет их в качестве содержимого кэша в шаблоне сервис-воркера. После этого файл сервис-воркера будет записан в папку dist. Те концепции, о которых мы говорили в применении к сервис-воркерам, не меняются. Вот код скрипта build-sw.js:

const glob = require('glob');
const fs = require('fs');

const dest = 'dist/sw.js';
const staticAssetsCacheName = 'todo-assets-v1';
const dynamicCacheName = 'todo-dynamic-v1';

// Начало фрагмента №1
let staticAssetsCacheFiles = glob
    .sync('dist/**/*')
    .map((path) => {
        return path.slice(5);
    })
    .filter((file) => {
        if (/.gz$/.test(file)) return false;
        if (/sw.js$/.test(file)) return false;
        if (!/.+/.test(file)) return false;
        return true;
    });
// Конец фрагмента №1

const stringFileCachesArray = JSON.stringify(staticAssetsCacheFiles);

// Начало фрагмента №2
const serviceWorkerScript = `var staticAssetsCacheName = '${staticAssetsCacheName}';
var dynamicCacheName = '${dynamicCacheName}';

self.addEventListener('install', function (event) {
    self.skipWaiting();
    event.waitUntil(
      caches.open(staticAssetsCacheName).then(function (cache) {
        cache.addAll([
            '/',
            ${stringFileCachesArray.slice(1, stringFileCachesArray.length - 1)}
        ]
        );
      }).catch((error) => {
        console.log('Error caching static assets:', error);
      })
    );
  });

  self.addEventListener('activate', function (event) {
    if (self.clients && clients.claim) {
      clients.claim();
    }
    event.waitUntil(
      caches.keys().then(function (cacheNames) {
        return Promise.all(
          cacheNames.filter(function (cacheName) {
            return (cacheName.startsWith('todo-')) && cacheName !== staticAssetsCacheName;
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          })
        ).catch((error) => {
            console.log('Some error occurred while removing existing cache:', error);
        });
      }).catch((error) => {
        console.log('Some error occurred while removing existing cache:', error);
    }));
  });

  self.addEventListener('fetch', (event) => {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request)
          .then((fetchResponse) => {
              return cacheDynamicRequestData(dynamicCacheName, event.request.url, fetchResponse.clone());
          }).catch((error) => {
            console.log(error);
          });
      }).catch((error) => {
        console.log(error);
      })
    );
  });

  function cacheDynamicRequestData(dynamicCacheName, url, fetchResponse) {
    return caches.open(dynamicCacheName)
      .then((cache) => {
        cache.put(url, fetchResponse.clone());
        return fetchResponse;
      }).catch((error) => {
        console.log(error);
      });
  }
`;
// Конец фрагмента №2

// Начало фрагмента №3
fs.writeFile(dest, serviceWorkerScript, function(error) {
    if (error) return;
    console.log('Service Worker Write success');
});
// Конец фрагмента №3

Разберём этот код.

  • Фрагмент №1. Здесь список файлов из папки dist помещается в массив staticAssetsCacheFiles.
  • Фрагмент №2. Это — шаблон сервис-воркера, о котором мы говорили. При формировании готового кода используются переменные. Это делает шаблон универсальным, позволяя использовать его в будущем, в ходе развития проекта. Шаблон нам, кроме того, нужен из-за того, что в него мы добавляем сведения о содержимом папки dist, которое может со временем меняться. Для этого используется константа stringFileCachesArray.
  • Фрагмент №3. Здесь только что сформированный код сервис-воркера, хранящийся в константе serviceWorkerScript, записывается в файл, находящийся по адресу dist/sw.js.

Для запуска этого скрипта используется команда node build-sw. Её нужно выполнить после того, как будет завершено выполнение команды webpack --mode production.

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

Если вы хотите воспользоваться специальной библиотекой, предназначенной для решения задачи работы прогрессивных веб-приложений в оффлайн-режиме — взгляните на Workbox. Она отличается очень интересными возможностями, поддающимися настройке.

▍Обзор пакетов, используемых в проекте

Вот файл package.json нашего проекта, в котором можно найти сведения о пакетах, используемых в этом проекте:

{
  "name": "vanilla-todos-pwa",
  "version": "1.0.0",
  "description": "A simple todo application using ES6 and Webpack",
  "main": "src/main.js",
  "scripts": {
    "build": "webpack --mode production && node build-sw",
    "serve": "webpack-dev-server --mode=development --hot"
  },
  "keywords": [],
  "author": "Anurag Majumdar",
  "license": "MIT",
  "devDependencies": {
    "@babel/core": "^7.2.2",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",
    "@babel/preset-env": "^7.2.3",
    "autoprefixer": "^9.4.5",
    "babel-core": "^6.26.3",
    "babel-loader": "^8.0.4",
    "babel-preset-env": "^1.7.0",
    "clean-webpack-plugin": "^1.0.0",
    "compression-webpack-plugin": "^2.0.0",
    "copy-webpack-plugin": "^4.6.0",
    "css-loader": "^2.1.0",
    "html-webpack-plugin": "^3.2.0",
    "mini-css-extract-plugin": "^0.5.0",
    "node-sass": "^4.11.0",
    "sass-loader": "^7.1.0",
    "style-loader": "^0.23.1",
    "terser": "^3.14.1",
    "webpack": "^4.28.4",
    "webpack-cli": "^3.2.1",
    "webpack-dev-server": "^3.1.14",
    "webpack-md5-hash": "0.0.6"
  }
}

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

Современные возможности JavaScript

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

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

▍Модули

Мы будем пользоваться возможностями ES6 по импорту и экспорту модулей, рассматривая каждый файл в виде ES6-модуля. Эта возможность часто встречается в популярных фреймворках, вроде Angular и React, пользоваться ей очень удобно. Благодаря имеющейся у нас конфигурации Webpack мы можем пользоваться выражениями импорта и экспорта ресурсов. Вот как это выглядит в файле app.js:

import { appTemplate } from './app.template';
import { AppModel } from './app.model';

export const AppComponent = {
  // Код компонента App...

};

▍Разные способы создания объектов

Создание компонентов — это важная часть процесса разработки нашего приложения. Тут вполне можно воспользоваться каким-нибудь современным средством, наподобие веб-компонентов, но мы, для того, чтобы не усложнять проект, будем пользоваться обычными объектами JavaScript, создавать которые можно либо с использованием объектных литералов, либо используя синтаксис классов, появившийся в стандарте ES6.

Особенность использования классов для создания объектов заключается в том, что после описания класса нужно создать на его основе экземпляр объекта, а после этого экспортировать этот объект. Для того чтобы упростить всё ещё сильнее, мы будем пользоваться здесь обычными объектами, создаваемыми с помощью объектных литералов. Вот код файла app.js, в котором можно видеть их применение.

import { appTemplate } from './app.template';
import { AppModel } from './app.model';

export const AppComponent = {

    init() {
        this.appElement = document.querySelector('#app');
        this.initEvents();
        this.render();
    },

    initEvents() {
        this.appElement.addEventListener('click', event => {
            if (event.target.className === 'btn-todo') {
                import( /* webpackChunkName: "todo" */ './todo/todo.module')
                    .then(lazyModule => {
                        lazyModule.TodoModule.init();
                    })
                    .catch(error => 'An error occurred while loading Module');
            }
        });

        document.querySelector('.banner').addEventListener('click', event => {
            event.preventDefault();
            this.render();
        });
    },

    render() {
        this.appElement.innerHTML = appTemplate(AppModel);
    }
};

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

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

▍Динамический импорт ресурсов

Помните о том, что мы, говоря о паттерне PRPL, ещё не разобрались с той его частью, которая представлена буквой L (Lazy loading)? Динамический импорт ресурсов — это то, что поможет нам организовать ленивую загрузку компонентов или модулей. Так как мы совместно используем архитектуру App Shell и паттерн PRPL для кэширования «скелета» приложения и его ресурсов, в ходе динамического импорта производится загрузка ресурсов из кэша, а не из сети.

Обратите внимание на то, что если бы мы использовали лишь архитектуру App Shell, то оставшиеся ресурсы приложения, то есть, содержимое папки chunks, не были бы кэшированы.

Пример динамического импорта ресурсов можно увидеть в вышеприведённом фрагменте кода компонента AppComponent, в частности, там, где настраивается обработчик события щелчка по кнопке (речь идёт о методе объекта initEvents()). А именно, если пользователь приложения щёлкнет по кнопке btn-todo, будет загружен модуль todo.module. Этот модуль представляет собой обычный JavaScript-файл, который содержит набор компонентов, представленных в виде объектов.

▍Стрелочные функции и шаблонные литералы

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

export const appTemplate = model => `
    <section class="app">
        <h3><font color="#3AC1EF">▍ ${model.title} </font></h3>
        <section class="button">
            <button class="btn-todo"> Todo Module </button>
        </section>
    </section>
`;

Стрелочная функция appTemplate принимает модель (параметр model) и возвращает HTML-строку, содержащую данные, взятые из модели. Формирование строки выполняется с использованием технологии шаблонных литералов. Ими удобно пользоваться для представления многострочных конструкций, в которые нужно добавлять какие-либо данные.

Вот небольшой совет, касающийся шаблонизации компонентов и создания компонентов, которые подходят для повторного использования. Он заключается в использовании метода массива reduce() для сборки HTML-строк:

const WorkModel = [
    {
        id: 1,
        src: '',
        alt: '',
        designation: '',
        period: '',
        description: ''
    },
    {
        id: 2,
        src: '',
        alt: '',
        designation: '',
        period: '',
        description: ''
    },
    //...
];

const workCardTemplate = (cardModel) => `
<section id="${cardModel.id}" class="work-card">
    <section class="work__image">
        <img class="work__image-content" type="image/svg+xml" src="${
            cardModel.src
        }" alt="${cardModel.alt}" />
    </section>
    <section class="work__designation">${cardModel.designation}</section>
    <section class="work__period">${cardModel.period}</section>
    <section class="work__content">
        <section class="work__content-text">
            ${cardModel.description}
        </section>
    </section>
</section>
`;

export const workTemplate = (model) => `
<section class="work__section">
    <section class="work-text">
        <header class="header-text">
            <span class="work-text__header"> Work </span>
        </header>
        <section class="work-text__content content-text">
            <p class="work-text__content-para">
                This area signifies work experience
            </p>
        </section>
    </section>

    <section class="work-cards">
        ${model.reduce((html, card) => html + workCardTemplate(card), '')}
    </section>

</section>
`;

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

Проанализируем его:

  • В начале файла находится команда объявления массива model. Это — пример массива с данными модели, который можно обработать с помощью метода reduce() для того чтобы реализовать шаблон, подходящий для повторного использования.
  • В конце файла можно найти команду model.reduce, которая помогает формировать HTML-строку, содержащую множество компонентов, подходящих для повторного использования. Эта функция принимает, в качестве первого аргумента, аккумулятор, а в качестве второго — значения массива.

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

Демонстрационное приложение

Рассмотрим демонстрационное Todo-приложение, которое создано с использованием рассмотренных технологий. Вот как выглядит работа с ним.

Демонстрационное приложение

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

Пример продакшн-приложения

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

Вот как выглядит работа с сайтом, о котором идёт речь.

Сайт-портфолио

Вот ссылка на сайт. А вот результаты анализа производительности этого сайта с помощью Lighthouse.

Анализ производительности сайта

Итоги

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

Уважаемые читатели! Планируете ли вы использовать подход к разработке веб-приложений, предложенный в этом материале?

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

В этом дубле я продемонстрирую все новейшие функции JavaScript, используя пользовательский интерфейс, который содержит данные об авторе с сеткой и фильтром поиска. Чтобы не усложнять, как только техника будет представлена, я перейду к следующей технике, чтобы не вдаваться в подробности. По этой причине в пользовательском интерфейсе будет опция «Добавить» и выпадающий фильтр поиска. Модель автора будет иметь три поля: имя, адрес электронной почты и необязательную тему. Валидация формы будет включена в основном для того, чтобы продемонстрировать эту безфреймворковую технику, но не до конца.

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

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

Содержание

  1. Getting Started
  2. Интегрировать веб-компоненты
  3. Validate the Form
  4. Наблюдаемые
  5. Observed Attributes
  6. Gotchas
  7. Заключение

Getting Started

Приложение представляет собой типичное приложение JavaScript с двумя зависимостями: http-сервер и Bootstrap. Код будет работать только в браузере, поэтому нет другой серверной части, кроме одной для размещения статических ресурсов. Код размещен на GitHub, и вы можете с ним поиграть.

Предполагая, что на вашем компьютере установлена последняя версия Node LTS :

mkdir framework-less-web-components
cd framework-less-web-components
npm init

В результате должен получиться единственный package.jsonфайл, в который будут помещены зависимости.

Чтобы установить две зависимости:

npm i http-server bootstrap@next --save-exact
  • http-server : HTTP-сервер для размещения статических ресурсов в Jamstack.
  • Bootstrap : элегантный и мощный набор стилей CSS для облегчения веб-разработки.

Если вы чувствуете, что http-serverэто не зависимость, а требование для запуска этого приложения, есть возможность установить его глобально через npm i -g http-server. В любом случае эта зависимость не доставляется клиенту, а обслуживает только статические активы.

Откройте package.jsonфайл и установите точку входа через «start»: «http-server«под scripts. Идите вперед и запустите приложение через npm start, которое станет http://localhost:8080/доступным для браузера. Любой index.htmlфайл, помещенный в корневую папку, автоматически размещается на HTTP-сервере. Все, что вам нужно сделать, это обновить страницу, чтобы получить самые свежие данные.

Структура папок выглядит так:

┳
┣━┓ components
┃ ┣━━ App.js
┃ ┣━━ AuthorForm.js
┃ ┣━━ AuthorGrid.js
┃ ┗━━ ObservableElement.js
┣━┓ model
┃ ┣━━ actions.js
┃ ┗━━ observable.js
┣━━ index.html
┣━━ index.js
┗━━ package.json

Вот для чего предназначена каждая папка:

  • components: Веб-компоненты HTML с App.jsэлементами и настраиваемыми элементами, которые наследуются отObservableElement.js
  • model: состояние и мутации приложения, которые отслеживают изменения состояния пользовательского интерфейса
  • index.html: основной файл статических ресурсов, который можно разместить где угодно

Чтобы создать папки и файлы в каждой папке, выполните следующее:

mkdir components model
touch components/App.js components/AuthorForm.js components/AuthorGrid.js components/ObservableElement.js model/actions.js model/observable.js index.html index.js

Интегрировать веб-компоненты

Вкратце, веб-компоненты — это настраиваемые HTML-элементы. Они определяют настраиваемый элемент, который может быть помещен в разметку, и объявляют метод обратного вызова, который отображает компонент.

Вот краткое изложение настраиваемого веб-компонента:

class HelloWorldComponent extends HTMLElement {
  connectedCallback() { // callback method
    this.innerHTML = 'Hello, World!'
  }
}

// Define the custom element
window.customElements.define('hello-world', HelloWorldComponent)

// The markup can use this custom web component via:
// <hello-world></hello-world>

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

На главной index.htmlстатической странице объявляются веб-компоненты HTML. Я буду использовать Bootstrap для стилизации HTML-элементов и добавления index.jsресурса, который станет основной точкой входа приложения и шлюзом в JavaScript.

Откройте index.htmlфайл и вставьте его на место:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
  <title>Framework-less Components</title>
</head>
<body>
<template id="html-app">
  <div class="container">
    <h1>Authors</h1>
    <author-form></author-form>
    <author-grid></author-grid>
    <footer class="fixed-bottom small">
      <p class="text-center mb-0">
        Hit Enter to add an author entry
      </p>
      <p class="text-center small">
        Created with ❤ By C R
      </p>
    </footer>
  </div>
</template>
<template id="author-form">
  <form>
    <div class="row mt-4">
      <div class="col">
        <input type="text" class="form-control" placeholder="Name" aria-label="Name">
      </div>
      <div class="col">
        <input type="email" class="form-control" placeholder="Email" aria-label="Email">
      </div>
      <div class="col">
        <select class="form-select" aria-label="Topic">
          <option>Topic</option>
          <option>JavaScript</option>
          <option>HTMLElement</option>
          <option>ES7+</option>
        </select>
      </div>
      <div class="col">
        <select class="form-select search" aria-label="Search">
          <option>Search by</option>
          <option>All</option>
          <option>JavaScript</option>
          <option>HTMLElement</option>
          <option>ES7+</option>
        </select>
      </div>
    </div>
  </form>
</template>
<template id="author-grid">
  <table class="table mt-4">
    <thead>
      <tr>
        <th>Name</th>
        <th>Email</th>
        <th>Topic</th>
      </tr>
    </thead>
    <tbody>
    </tbody>
  </table>
</template>
<template id="author-row">
  <tr>
    <td></td>
    <td></td>
    <td></td>
  </tr>
</template>
<nav class="navbar navbar-expand-lg navbar-light bg-dark">
  <div class="container-fluid">
    <a class="navbar-brand text-light" href="/">
      Framework-less Components with Observables
    </a>
  </div>
</nav>
<html-app></html-app>
<script type="module" src="index.js"></script>
</body>
</html>

Обратите особое внимание на scriptтег с typeатрибутом, установленным на module. Это то, что разблокирует импорт / экспорт в обычном JavaScript в браузере. templateТег с idопределяет HTML — элементы, которые позволяют веб — компоненты. Я распался приложение на три основных компоненты: html-app, author-formи author-grid. Поскольку в JavaScript еще ничего не определено, приложение будет отображать панель навигации без каких-либо пользовательских тегов HTML.

Чтобы начать легко, поместите это в ObservableElement.js. Это родительский элемент для всех компонентов автора:

export default class ObservableElement extends HTMLElement {
}

Затем определите html-appкомпонент в App.js:

export default class App extends HTMLElement {
  connectedCallback() {
    this.template = document
      .getElementById('html-app')

    window.requestAnimationFrame(() => {
      const content = this.template
        .content
        .firstElementChild
        .cloneNode(true)

      this.appendChild(content)
    })
  }
}

Обратите внимание на использование export defaultдля объявления классов JavaScript. Эту возможность я включил с помощью moduleтипа, когда ссылался на основной файл сценария. Чтобы использовать веб-компоненты, унаследуйте метод класса HTMLElementи определите его connectedCallback. Об остальном позаботится браузер. Я использую requestAnimationFrameдля рендеринга основного шаблона перед следующей перерисовкой в ​​браузере.

Это распространенный метод, который вы увидите с веб-компонентами. Сначала возьмите шаблон по идентификатору элемента. Затем клонируйте шаблон через cloneNode. Наконец, appendChildновое contentв DOM. Если вы столкнетесь с какими-либо проблемами, когда веб-компоненты не отображаются, убедитесь, что клонированный контент сначала был добавлен в DOM.

Затем определите AuthorGrid.jsвеб-компонент. Этот будет следовать аналогичному шаблону и немного манипулировать DOM:

import ObservableElement from './ObservableElement.js'

export default class AuthorGrid extends ObservableElement {
  connectedCallback() {
    this.template = document
      .getElementById('author-grid')
    this.rowTemplate = document
      .getElementById('author-row')
    const content = this.template
      .content
      .firstElementChild
      .cloneNode(true)
    this.appendChild(content)

    this.table = this.querySelector('table')
    this.updateContent()
  }

  updateContent() {
    this.table.style.display =
      (this.authors?.length ?? 0) === 0
        ? 'none'
        : ''

    this.table
      .querySelectorAll('tbody tr')
      .forEach(r => r.remove())
  }
}

Я определил главный this.tableэлемент с помощью querySelector. Поскольку это класс, можно сохранить хорошую ссылку на целевой элемент, используя this. Этот updateContentметод в основном уничтожает основную таблицу, когда в сетке нет авторов. По желанию оператора цепочки ( ?.) и нулевой коалесцирующий заботится о настройке displayстиля нет.

Взгляните на importоператор, потому что он вводит зависимость с полным расширением в имени файла. Если вы привыкли к разработке Node, то в этом она отличается от реализации в браузере, которая соответствует стандарту, где для этого требуется расширение файла, например.js. Учитесь у меня и обязательно ставьте расширение файла при работе в браузере.

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

Чтобы отобразить форму, откройте AuthorForm.js:

import ObservableElement from './ObservableElement.js'

export default class AuthorForm extends ObservableElement {
  connectedCallback() {
    this.template = document
      .getElementById('author-form')
    const content = this.template
      .content
      .firstElementChild
      .cloneNode(true)

    this.appendChild(content)

    this.form = this.querySelector('form')
    this.form.querySelector('input').focus()
  }

  resetForm(inputs) {
    inputs.forEach(i => {
      i.value = ''
      i.classList.remove('is-valid')
    })
    inputs[0].focus()
  }
}

Он focusнаправляет пользователя, чтобы начать вводить текст на первом элементе ввода, доступном в форме. Обязательно ставьте DOM селекторы послеappendChild, так как в противном случае этот метод не будет работать. resetFormНе используется прямо сейчас, но сбросит состояние формы, когда пользователь нажимает клавишу Enter.

Подключите события через addEventListenerдобавление этого кода внутри connectedCallbackметода. Это можно добавить в самый конец connectedCallbackметода:

this.form
  .addEventListener('keypress', e => {
    if (e.key === 'Enter') {
      const inputs = this.form.querySelectorAll('input')
      const select = this.form.querySelector('select')

      console.log('Pressed Enter: ' +
        inputs[0].value + '|' +
        inputs[1].value + '|' +
        (select.value === 'Topic' ? '' : select.value))

      this.resetForm(inputs)
    }
  })

this.form
  .addEventListener('change', e => {
    if (e.target.matches('select.search')
      && e.target.value !== 'Search by') {
      console.log('Filter by: ' + e.target.value)
    }
  })

Это типичные прослушиватели событий, которые прикрепляются к this.formэлементу в DOM. changeСобытие делегации использует событие для прослушивания всех событий изменения в форме, но предназначается только select.searchэлемент. Это эффективный способ делегировать одно событие как можно большему количеству целевых элементов в родительском элементе. Если это сделано, ввод чего-либо в форме и нажатие Enter сбрасывает форму обратно в нулевое состояние.

Чтобы эти веб-компоненты отображались на клиенте, откройте index.jsи вставьте это:

import AuthorForm from './components/AuthorForm.js'
import AuthorGrid from './components/AuthorGrid.js'
import App from './components/App.js'

window.customElements.define('author-form', AuthorForm)
window.customElements.define('author-grid', AuthorGrid)
window.customElements.define('html-app', App)

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

Validate the Form

Поигравшись с формой, вы можете заметить, что она принимает произвольный ввод, когда требуются и имя, и адрес электронной почты, а тема является необязательной. Подход без фреймворка может представлять собой комбинацию проверки HTML и небольшого количества JavaScript. К счастью, Bootstrap несколько упрощает эту задачу, добавляя / удаляя имена классов CSS через classListвеб-API.

Внутри AuthorForm.jsкомпонента найдите console.logв Enterключевом обработчике событий, найдите журнал с «Pressed Enter» и поместите его прямо над ним:

if (!this.isValid(inputs)) return

Затем определите isValidметод класса в AuthorForm. Это может выходить за рамки resetFormметода:

isValid(inputs) {
  let isInvalid = false

  inputs.forEach(i => {
    if (i.value && i.checkValidity()) {
      i.classList.remove('is-invalid')
      i.classList.add('is-valid')
    } else {
      i.classList.remove('is-valid')
      i.classList.add('is-invalid')
      isInvalid = true
    }
  })

  return !isInvalid
}

В обычном JavaScript при вызове checkValidityиспользуется встроенный валидатор HTML, потому что я пометил элемент ввода с помощью type=»email«. Чтобы проверить наличие обязательных полей, простая проверка правдивости помогает с помощью i.value. classListВеб — API добавляет или удаляет имена классов CSS, поэтому моделирование Bootstrap может выполнять свою работу.

А теперь попробуйте еще раз. Попытка ввести недопустимые данные теперь помечается, а действительные данные сбрасывают форму.

Наблюдаемые

Пришло время для мяса (или картошки для моих друзей-вегетарианцев) этого подхода, потому что веб-компоненты и обработчики событий могут только увести меня. Чтобы сделать это приложение управляемым состоянием, мне понадобится способ отслеживать изменения состояния пользовательского интерфейса. Оказывается, наблюдаемые объекты идеально подходят для этого, потому что они могут запускать обновления пользовательского интерфейса при изменении состояния. Думайте о наблюдаемых как о модели sub / pub, где подписчики отслеживают изменения, а издатель запускает те изменения, которые произошли в состоянии пользовательского интерфейса. Это упрощает объем кода push и pull, необходимый для создания сложных и захватывающих пользовательских интерфейсов без какой-либо инфраструктуры.

Откройте obserable.jsфайл modelи вставьте его:

const cloneDeep = x => JSON.parse(JSON.stringify(x))
const freeze = state => Object.freeze(cloneDeep(state))

export default initialState => {
  let listeners = []

  const proxy = new Proxy(cloneDeep(initialState), {
    set: (target, name, value) => {
      target[name] = value
      listeners.forEach(l => l(freeze(proxy)))
      return true
    }
  })

  proxy.addChangeListener = cb => {
    listeners.push(cb)
    cb(freeze(proxy))
    return () =>
      listeners = listeners.filter(el => el !== cb)
  }

  return proxy
}

Поначалу это может показаться пугающим, но он делает две вещи: захват установщика для перехвата мутаций и добавление слушателей. В ES6 + Proxyкласс включает прокси, который обтекает initialStateобъект. Это может перехватывать базовые операции, подобные этому setметоду, который выполняется при изменении объекта. Возврат trueв сеттере позволяет внутреннему механизму JavaScript узнать, что мутация прошла успешно. В Proxyустанавливает объект обработчика, где такие как ловушки setполучить определены. Поскольку меня интересуют только мутации объекта состояния, у setнего есть ловушка. Все остальные функции, такие как чтение, перенаправляются непосредственно в объект исходного состояния.

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

Функции freezeи cloneDeepсозданы для предотвращения дальнейших мутаций базового объекта состояния. Это сохраняет состояние пользовательского интерфейса более предсказуемым и в некоторой степени не имеющим состояния, поскольку данные перемещаются только в одном направлении.

Теперь перейдите к actions.jsфайлу и вставьте его на место:

export default state => {
  const addAuthor = author => {
    if (!author) return

    state.authors = [...state.authors, {
      ...author
    }]
  }

  const changeFilter = currentFilter => {
    state.currentFilter = currentFilter
  }

  return {
    addAuthor,
    changeFilter
  }
}

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

Чтобы запускать мутации из веб-компонентов, их необходимо зарегистрировать в глобальном window.applicationContextобъекте. Это делает этот объект состояния с изменениями доступным для остальной части приложения.

Откройте основной index.jsфайл и добавьте его прямо над местом, где я зарегистрировал настраиваемые элементы:

import observableFactory from './model/observable.js'
import actionsFactory from './model/actions.js'

const INITIAL_STATE = {
  authors: [],
  currentFilter: 'All'
}

const observableState = observableFactory(INITIAL_STATE)
const actions = actionsFactory(observableState)

window.applicationContext = Object.freeze({
  observableState,
  actions
})

Доступны два объекта: прокси observableStateи объект actionsс мутациями. Программа INITIAL_STATEзагружает приложение с исходными данными. Это то, что устанавливает начальное нулевое состояние конфигурации. Мутации действия принимают наблюдаемое состояние и запускают обновления для всех слушателей, внося изменения в observableStateобъект.

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

Observed Attributes

Для веб-компонентов изменения состояния можно отслеживать с помощью веб-API атрибутов. Это getAttribute, setAttributeи hasAttribute. С этим арсеналом более эффективно сохранять состояние пользовательского интерфейса в DOM.

Взломайте ObservableElement.jsи выпотрошите его, заменив этим кодом:

export default class ObservableElement extends HTMLElement {
  get authors() {
    if (!this.hasAttribute('authors')) return []

    return JSON.parse(this.getAttribute('authors'))
  }

  set authors(value) {
    if (this.constructor
      .observedAttributes
      .includes('authors')) {
      this.setAttribute('authors', JSON.stringify(value))
    }
  }

  get currentFilter() {
    if (!this.hasAttribute('current-filter')) return 'All'

    return this.getAttribute('current-filter')
  }

  set currentFilter(value) {
    if (this.constructor
      .observedAttributes
      .includes('current-filter')) {
      this.setAttribute('current-filter', value)
    }
  }

  connectAttributes () {
    window
      .applicationContext
      .observableState
      .addChangeListener(state => {
        this.authors = state.authors
        this.currentFilter = state.currentFilter
      })
  }

  attributeChangedCallback () {
    this.updateContent()
  }
}

Я специально использовал в current-filterатрибуте змеиный кожух. Это связано с тем, что веб-API атрибутов поддерживает только имена в нижнем регистре. Геттер / сеттер выполняет сопоставление между этим веб-API и тем, что ожидает класс, что является случаем верблюда.

connectAttributesМетод в веб — компонент добавляет свой собственный слушатель для отслеживания состояния мутаций. Есть attributeChangedCallbackдоступный, который срабатывает при изменении атрибута, а веб-компонент обновляет атрибут в DOM. Этот обратный вызов также вызывает, updateContentчтобы сообщить веб-компоненту об обновлении пользовательского интерфейса. Геттер / сеттер ES6 + объявляет те же свойства, что и в объекте состояния. Это то, что делает this.authors, например, доступным для веб-компонента.

Обратите внимание на использование constructor.observedAttributes. Это настраиваемое статическое поле, которое я могу объявить сейчас, чтобы родительский класс ObservableElementмог отслеживать, какие атрибуты важны для веб-компонента. Благодаря этому я могу выбирать, какая часть модели состояния имеет отношение к веб-компоненту.

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

Вернитесь AuthorForm.jsи внесите эти изменения. Комментарии к коду скажут вам, где его разместить (или вы можете проконсультироваться с репозиторием ):

// This goes at top, right below the class declaration
static get observedAttributes() {
  return [
    'current-filter'
  ]
}

// In the Enter event handler, right above resetForm
this.addAuthor({
  name: inputs[0].value,
  email: inputs[1].value,
  topic: select.value === 'Topic' ? '' : select.value
})

// In the select event handler, rigth below console.log
this.changeFilter(e.target.value)

// At the very end of the connectedCallback method
super.connectAttributes()

// These helpers method go at the bottom of the class
addAuthor(author) {
  window
    .applicationContext
    .actions
    .addAuthor(author)
}

changeFilter(filter) {
  window
    .applicationContext
    .actions
    .changeFilter(filter)
}

updateContent() {
  // Capture state mutation to synchronize the search filter
  // with the dropdown for a nice effect, and reset the form
  if (this.currentFilter !== 'All') {
    this.form.querySelector('select').value = this.currentFilter
  }
  this.resetForm(this.form.querySelectorAll('input'))
}

В Jamstack вам может потребоваться вызвать внутренний API для сохранения данных. Я рекомендую использовать вспомогательные методы для этих типов вызовов. Как только постоянное состояние возвращается из API, оно может быть изменено в приложении.

Наконец, найдите AuthorGrid.jsи подключите наблюдаемые атрибуты (последний файл находится здесь ):

// This goes at top, right below the class declaration
static get observedAttributes() {
  return [
    'authors',
    'current-filter'
  ]
}

// At the very end of the connectedCallback method
super.connectAttributes()

// This helper method can go right above updateContent
getAuthorRow(author) {
  const {
    name,
    email,
    topic
  } = author

  const element = this.rowTemplate
    .content
    .firstElementChild
    .cloneNode(true)
  const columns = element.querySelectorAll('td')

  columns[0].textContent = name
  columns[1].textContent = email
  columns[2].textContent = topic

  if (this.currentFilter !== 'All'
    && topic !== this.currentFilter) {
    element.style.display = 'none'
  }

  return element
}

// Inside updateContent, at the very end
this.authors
  .map(a => this.getAuthorRow(a))
  .forEach(e => this.table
    .querySelector('tbody')
    .appendChild(e))

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

Попробуйте это в браузере. Взломайте инструменты разработчика и проверьте HTML. Вы увидите атрибуты, установленные в DOM, например current-filter, в корне веб-компонента. Когда вы щелкаете и нажимаете Enter, обратите внимание, что приложение автоматически отслеживает мутации состояния в DOM.

Gotchas

Для получения дополнительной информации не забудьте оставить инструменты разработчика открытыми, перейдите в отладчик JavaScript и найдите AuthorGrid.js. Затем установите точку останова в любом месте updateContent. Выберите поисковый фильтр. Заметили, что браузер повторяет этот код более одного раза? Это означает, что код, обновляющий пользовательский интерфейс, запускается не один раз, а каждый раз при изменении состояния.

Это из-за этого кода, который находится в ObservableElement:

window
  .applicationContext
  .observableState
  .addChangeListener(state => {
    this.authors = state.authors
    this.currentFilter = state.currentFilter
  })

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

Чтобы исправить это, откройте ObservableElement.jsи вернитесь к установщикам атрибутов HTML:

// This can go outside the observable element class
const equalDeep = (x, y) => JSON.stringify(x) === JSON.stringify(y)

// Inside the authors setter
if (this.constructor.observedAttributes.includes('authors')
  && !equalDeep(this.authors, value)) {

// Inside the currentFilter setter
if (this.constructor.observedAttributes.includes('current-filter')
  && this.currentFilter !== value) {

Это добавляет уровень защитного программирования для обнаружения изменений атрибутов. Когда веб-компонент понимает, что ему не нужно обновлять пользовательский интерфейс, он пропускает установку атрибута.

Теперь вернитесь в браузер с точкой останова, состояние обновления должно произойти updateContentтолько один раз.

Заключение

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

О проекте

Цель данного документа — помочь вам начать разработку приложений на Node.js и научить всему, что необходимо знать о «продвинутом» JavaScript.
Это больше, чем обычный «Hello world»-туториал.

Статус

Вы читаете финальную версию этой книги, в обновлениях исправляются только ошибки или отражаются изменения в новых версиях Node.js. Последнее обновление 12 Февраля 2012.

Код примеров этой книги тестировался на Node.js версии 0.8.8 (сверено с англ. версией —прим.перев.).

Целевая аудитория

Вероятно, документ будет полезен читателям с базовыми знаниями, примерно, как у меня: опыт работы хотя бы с одним объектно-ориентированным языком, таким как Ruby, Python, PHP или Java, небольшой опыт в Javascript и полный новичок в Node.js.

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

Однако, поскольку функции и объекты в JavaScript отличаются от своих аналогов в других языках, они будут описаны достаточно подробно.

Структура учебника

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

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

Мы начнём с выяснения того, чем JavaScript в Node.js отличается от JavaScript в браузере.

Далее, мы остановимся на написании традиционного «Hello world»-приложения, которое является наиболее простым примером «что-то делающего» кода Node.js.

Тогда мы обсудим, какое «реальное» приложение мы хотим создать, проанализируем компоненты, которые необходимо реализовать для написания данного приложения, и начнём работать над каждым из них, шаг за шагом.

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

Исходный код законченного приложения доступен в
the NodeBeginnerBook Github репозитории.

Содержание

  • О проекте
    • Статус
    • Целевая аудитория
    • Структура учебника
  • JavaScript и Node.js
    • JavaScript и Вы
    • Предупреждение
    • Server-side JavaScript
    • «Hello World»
  • Полномасштабное веб-приложение с Node.js
    • Что должно делать наше приложение
    • Задачи
  • Реализация приложения
    • Простой HTTP-сервер
    • Анализ нашего HTTP-сервера
    • Передача функций в качестве параметра
    • Как анонимная функция делает наш HTTP-сервер рабочим
    • Событийно-ориентированные обратные вызовы
    • Как наш сервер обрабатывает запросы
    • Выбор места для нашего серверного модуля
    • Что необходимо для «роутера»?
    • Исполнение королевских постановлений в царстве глаголов
    • Роутинг реальных обработчиков запроса
    • Создание ответа обработчиков запроса
      • Как делать не надо
      • Блокирование и неблокирование
      • Ответ обработчиков запроса с неблокирующими операциями.
    • Сделаем что-нибудь полезное
      • Обработка POST-запросов
      • Обработка загрузки файлов
    • Выводы и перспективы

JavaScript и Node.js

JavaScript и Вы

До того как мы поговорим о технических вещах, позвольте занять некоторое время и поговорить о вас и ваших отношениях с JavaScript.
Эта глава позволит вам понять, имеет ли смысл читать дальше.

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

Что вы хотели узнать — так это действительно полезные вещи; вы хотели знать, как создать сложный сайт.
Для этого вы изучали PHP, Ruby, Java и начинали писать backend-код.

Тем не менее, вы постоянно следили за JavaScript, вы видели, что с появлениям JQuery, Prototype и других фреймворков этот язык стал больше, чем просто window.open().

Однако, это всё ещё относилось к frontend-разработке.
Конечно, jQuery — очень мощный инструмент, но всякий раз, когда вы приправляли ваш сайт разными jQuery-«фишками», в лучшем случае, вы были JavaScript-пользователем нежели JavaScript-разработчиком.

А потом пришел Node.js. JavaScript на сервере: насколько это хорошо?

И вы решили, что пора проверить старый новый JavaScript.
Подождите. Написать Node.js приложение — одно дело, а понять, почему оно должно быть написано таким образом, для этого нужно понимать JavaScript.
И на этот раз — по-настоящему.

В этом — как раз и проблема.
JavaScript живёт двумя, может даже тремя разными жизнями: весёлый маленький DHMTL-помощник из середины 90-х годов, более серьезный frontend-инструмент в лице jQuery и наконец серверный (server-side, backend) JavaScript.
По этой причине не так просто найти информацию, которая поможет вам познать правильный JavaScript, пригодный для написания Node.js приложения в манере, дающий ощущение, что вы не просто использовали JavaScript, а действительно разрабатывали на JavaScript.

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

Конечно, существует отличная документация по Node.js, но её зачастую недостаточно. Нужно руководство.

Моя цель заключается в обеспечении вас руководством.

Предупреждение

Существуют действительно отличные специалисты в области JavaScript. Я не из их числа.

Я — действительно, тот парень, о котором написано в предыдущем параграфе.
Я знаю кое-что о разработке backend веб-приложений, но я всё ещё новичок в «реальном» JavaScript и всё ещё новичок в Node.js.
Я узнал некоторые продвинутые аспекты JavaScript совсем недавно.
Я неопытен.

Вот почему эта книга не из разряда «от новичка к эксперту», а скорее «от новичка к продвинутому новичку».

Если всё удастся, то этот документ станет тем руководством, которое я хотел бы иметь, когда начинал в Node.js.

Server-side JavaScript

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

Node.js — действительно, просто другой контекст: он позволяет вам запускать JavaScript-код вне браузера.

Чтобы ваш JavaScript код выполнился на вычислительной машине вне браузера (на backend), он должен быть интерпретирован и, конечно же, выполнен.
Именно это и делает Node.js. Для этого он использует движок V8 VM от Google — ту же самую среду исполнения для JavaScript, которую использует браузер Google Chrome.

Кроме того, Node.js поставляется со множеством полезных модулей, так что вам не придется писать всё с нуля, как, например, вывод строки в консоль.

Таким образом, Node.js состоит из 2 вещей: среды исполнения и полезных библиотек.

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

«Hello world»

Хорошо, давайте пойдём сразу с места в карьер и напишем наше первое Node.js-приложение: «Hello world».

Откройте ваш любимый редактор и создайте файл под названием helloworld.js.
Мы хотим вывести строку «Hello world» в консоль, для этого пишем следующий код:

console.log("Hello World");

Сохраняем файл и выполняем его посредством Node.js:

node helloworld.js

Это должно вывести Hello World на наш терминал.

Ладно, всё это скучно, правда? Давайте напишем что-нибудь полезное.

Полномасштабное веб-приложение с Node.js

Что должно делать наше приложение

Возьмём что-нибудь попроще, но приближенное к реальности:

  • Пользователь должен иметь возможность использовать наше приложение с браузером;
  • Пользователь должен видеть страницу приветствия по адресу http://domain/start;
  • Когда запрашивается http://domain/upload, пользователь должен иметь возможность загрузить картинку со своего компьютера и просмотреть её в своем браузере.

Вполне достаточно. Конечно, вы могли бы достичь этой цели, немного погуглив и поговнокодив. Но это не то, что нам нужно.

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

Задачи

Давайте проанализируем наше приложение.
Что нужно, чтобы его реализовать:

  • У нас — онлайн веб-приложение, поэтому нам нужен HTTP-сервер;
  • Нашему серверу необходимо обслуживать различные запросы в зависимости от URL, по которому был сделан запрос. Для этого нам нужен какой-нибудь роутер (маршрутизатор), чтобы иметь возможность направлять запросы определенным обработчикам;
  • Для выполнения запросов, пришедших на сервер и направляемые роутером, нам нужны действующие обработчики запросов;
  • Роутер, вероятно, должен иметь дело с разными входящими POST-данными и передавать их обработчикам запросов в удобной форме. Для этого нам нужен какой-нибудь обработчик входных данных;
  • Мы хотим не только обрабатывать запросы, но и показывать пользователю контент по запрошенным URL-адресам, поэтому нам нужна некая логика отображения для обработчиков запросов, чтобы иметь возможность отправлять контент пользовательскому браузеру;
  • Последнее, но не менее важное — пользователь сможет загружать картинки, поэтому нам нужен какой-нибудь обработчик загрузки, который возьмёт на себя заботу о деталях.

Давайте подумаем о том, как бы мы реализовали это на PHP.
Скорее всего, типичное решение будет на HTTP-сервере Apache с установленным mod_php5.

Это относится к первому пункту наших задач, то есть, «принимать HTTP-запросы и отправлять готовые веб-странички пользователю» — вещи, которые PHP сам не делает.

С Node.js — немного иначе.
Потому что в Node.js мы не только создаем наше приложение, мы также реализуем полноценный HTTP-сервер.
Действительно, наше веб-приложение и веб-сервер — в сущности, одно и тоже.

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

Давайте просто начнём реализовывать нашу первую задачу — HTTP-сервер.

Реализация приложения

Простой HTTP-сервер

Когда я подошел к моменту создания своего первого «реального» Node.js-приложения, я задался вопросом, как организовать мой код.

Я должен делать всё в одном файле?
Большинство учебных пособий в интернете учат как создавать простой HTTP-сервер в Node.js, сохраняя всю логику в одном месте.
Что, если я хочу быть уверенным, что мой код останется читабельным по мере реализации всё большего функционала.

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

Это позволяет вам иметь чистый главный файл, который вы исполняете в Node.js и чистые модули, которые могут использоваться главным файлом и друг другом.

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

Я думаю, это более-менее традиционно назвать главным файлом index.js.
А код нашего сервера имеет смысл поместить в файл под названием server.js.

Давайте начнём с модуля сервера.
Создайте файл server.js в корневой директории вашего проекта и поместите туда следующий код:

var http = require("http");

http

.createServer(function(request, response) {
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("Hello World");
  response
.end();
}).listen(8888);

И всё!
Вы написали работающий HTTP-сервер.
Давайте проверим его, запустив и протестировав.
Во-первых, выполните ваш скрипт в Node.js:

node server.js

Теперь откройте ваш браузер и перейдите по адресу http://localhost:8888/.
Должна вывестись веб-страница со строкой «Hello world».

Правда, это довольно интересно?
Как насчёт того, чтобы поговорить о том, что здесь происходит и оставить на потом вопрос о том, как организовать наш проект?
Я обещаю, мы вернемся к нему.

Анализ нашего HTTP-сервера

Хорошо, тогда давайте проанализируем, что здесь действительно происходит.

Первая строчка подключает http-модуль, который поставляется вместе с Node.js и делает его доступным через переменную http.

Далее, мы вызываем одну из функций http-модуля createServer.
Эта функция возвращает объект, имеющий метод listen, принимающий числовое значение порта нашего HTTP-сервера, который необходимо прослушивать.

Пожалуйста, проигнорируйте функцию, которая определяется внутри скобок http.createServer.

Мы могли бы написать код, который запускает наш сервер, прослушивающий порт 8888, так:

var http = require("http");var server = http.createServer();
server
.listen(8888);

Это запустило бы HTTP-сервер прослушивающего порт 8888, который больше ничего не делает (даже не отвечает на входящие запросы).

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

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

Передача функций в качестве параметра

Вы можете в качестве примера сделать что-то подобное:

function say(word) {
  console
.log(word);
}function execute(someFunction, value) {
  someFunction
(value);
}

execute

(say, "Hello");

Разберите пример внимательно! Здесь мы передаём функцию say как первый параметр функции execute.
Не значение, которое возвращает функция say, а саму функцию say!

Таким образом, say становится локальной переменной someFunction внутри execute и execute может вызвать функцию в этой переменной вот так: someFunction() (то есть, добавив скобки).

Конечно же, так как say принимает один параметр (word), execute может передать какое-либо значение в качестве этого параметра, когда вызывает someFunction.

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

function execute(someFunction, value) {
  someFunction
(value);
}

execute

(function(word){ console.log(word) }, "Hello");

Мы определяем функцию, которую хотим передать в execute, прямо там, где у execute должен быть первый параметр.

Из-за того, что нам даже не надо давать имя этой функции, её называют анонимная функция.

Это первый проблеск, который я называю «продвинутый» JavaScript, но давайте всё по порядку.
А сейчас давайте просто примем то, что в JavaScript мы можем передать функцию как параметр, когда вызываем другую функцию.
Мы можем сделать это путём присвоения нашей функции переменной, которую му передаем, или путём определения функции для передачи на месте.

Как анонимная функция делает наш HTTP-сервер рабочим

С этими знаниями давайте вернемся назад к нашему минималистичному HTTP-серверу:

var http = require("http");

http

.createServer(function(request, response) {
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("Hello World");
  response
.end();
}).listen(8888);

Сейчас должно быть ясно, что мы здесь делаем: передаём в функцию createServer анонимную функцию.

Мы можем добиться того же самого через рефакторинг нашего кода:

var http = require("http");function onRequest(request, response) {
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("Hello World");
  response
.end();
}

http

.createServer(onRequest).listen(8888);

Может сейчас самое время спросить: Почему мы это делаем так?

Событийно-ориентированные обратные вызовы

Ответ на вопрос a) не так легко дать (по крайней мере для меня), и b) кроется в самой природе работы Node.js — это событийно-ориентированность, то, благодаря чему он работает так быстро.

Возможно, вы захотите занять немного своего времени и почитать отличный пост Felix Geisendörfer Понимание node.js, чтобы прояснить этот момент.

Все сводится к тому факту, что Node.js работает событийно-ориентированно.
Ах да, я тоже до конца не понимаю, что это значит.
Но я постараюсь объяснить, почему это так важно для тех, кто хочет писать веб-приложения в Node.js.

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

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

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

Когда приходит новый запрос на порт 8888, относительно потоков управления, мы находимся в середине нашей Node.js-программы.
Как это понять, чтоб не помешаться?

Это как раз то, где событийно-ориентированный дизайн Node.js/JavaScript на самом деле помогает. Нам надо узнать некоторые новые понятия, чтобы досконально понять всё это.

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

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

Этот принцип называется обратный вызов или callback.
Мы передаём в некоторый метод функцию и этот метод исполняет её, когда происходит связанное с методом событие.

По крайней мере для меня, это заняло некоторое время, чтобы понять.
Просто почитайте блог Felix Geisendörfer снова, если вы всё ещё не уверены.

Давайте немного поиграем с этим новым понятием.
Можем ли мы доказать, что наш код продолжает работать после создания сервера, даже если нет HTTP-запроса и callback-функция, переданная нами, не вызывается?
Давайте попробуем:

var http = require("http");function onRequest(request, response) {
  console
.log("Request received.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("Hello World");
  response
.end();
}

http

.createServer(onRequest).listen(8888);

console

.log("Server has started.");

Обратите внимание, что я использую console.log для вывода текста «Request received.», когда срабатывает функция onRequest (наш callback), а текст «Server has started.» — сразу после запуска HTTP-сервера.

Когда мы запустим этот код (как обычно, node server.js), он тут же выведет в командной строке «Server has started.».
Всякий раз, когда мы делаем запрос нашему серверу (через переход по адресу http://localhost:8888/ в нашем браузере), в командной строке выводится сообщение «Request received.».

Объектно-ориентированный асинхронный серверный JavaScript с callback-ми в действии :-)

(Обратите внимание, что наш сервер, возможно, будет выводить «Request received.» в консоль 2 раза при открытии страницы в браузере.
Это происходит из-за того, что большинство браузеров будут пытаться загрузить фавикон по адресу http://localhost:8888/favicon.ico при запросе http://localhost:8888/)

Как наш сервер обрабатывает запросы

Хорошо, давайте быстро проанализируем остальной код сервера внутри тела нашей callback-функции onRequest().

Когда callback запускается и наша функция onRequest() срабатывает, в неё передаются 2 параметра: request и response.

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

И наш код делает именно это: Всякий раз, когда запрос получен, он использует функцию response.writeHead() для отправки HTTP-статуса 200 и Content-Type в заголовке HTTP-ответа, а функцию Response.Write() для отправки текста «Hello World» в теле HTTP-ответа.

И последнее, мы вызываем response.end() чтобы завершить наш ответ.

На данный момент, мы не заботимся о деталях запроса, поэтому мы не используем объект request полностью.

Выбор места для нашего серверного модуля

Я обещал, что мы вернёмся к организации нашего приложения.
У нас есть код очень простого HTTP-сервера в файле server.js и я упоминал, что общепринято иметь главный файл с названием index.js, который используется для начальной загрузки и запуска нашего приложения, путём использования других модулей приложения (таких как наш модуль HTTP-сервера в server.js).

Давайте поговорим о том, как сделать server.js настоящим Node.js-модулем, чтобы его можно было использовать в нашем главном файле index.js.

Как вы могли заметить, мы уже использовали модули в нашем коде:

var http = require("http");...

http

.createServer(...);

Где-то внутри Node.js живёт модуль под названием «http» и мы можем использовать его в нашем коде, путём подключения и присвоения его результата локальной переменной.

Это делает нашу локальную переменную объектом, содержащим в себе все публичные методы модуля http.

Общепринитая практика — использовать имя модуля для имени локальной переменной, но мы свободны в своём выборе делать, как нам нравится:

var foo = require("http");...

foo

.createServer(...);

Теперь понятно, как использовать внутренние модули Node.js.
А как создать свой собственный модуль и как его использовать?

Давайте выясним это, превратив наш скрипт server.js в настоящий модуль.

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

Сейчас функционал нашего HTTP-сервера надо экспортировать, что довольно просто: скрипты, подключающие наш модуль сервера, просто запускают сервер.

Чтобы сделать это возможным, поместим код нашего сервера в функцию под название start и будем экспортировать эту функцию:

var http = require("http");function start() {
 
function onRequest(request, response) {
    console
.log("Request received.");
    response
.writeHead(200, {"Content-Type": "text/plain"});
    response
.write("Hello World");
    response
.end();
 
}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Теперь мы можем создать наш основной файл index.js, и запускать наш HTTP-сервер там, хотя код для сервера находится всё ещё в файле server.js.

Создаём файл index.js со следующим содержимым:

var server = require("./server");

server

.start();

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

Вот и всё. Сейчас мы можем запустить наше приложение через главный скрипт и он будет делать всё то же самое, что и раньше:

node index.js

Великолепно — сейчас мы можем помещать различные части нашего приложения в разные файлы и связывать их вместе, посредством превращения их в модули.

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

В очень простом приложении мы могли бы делать это напрямую внутри callback-функции onRequest().
Но, как я говорил, давайте добавим немного больше абстракции, чтобы сделать наш пример интереснее.

Задание соответствия между разными HTTP-запросами и разными частями нашего кода называется «маршрутизация» («routing», роутинг).
Давайте тогда создадим модуль под названием router.

Что необходимо для «роутера»?

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

Итак, нам надо рассматривать HTTP-запрос и извлекать запрошенный URL, а также GET/POST-параметры.
Можно поспорить, должен ли этот код быть частью роутера или сервера (или даже своего собственного модуля), но давайте сейчас пока просто сделаем его частью сервера.

Вся необходимая нам информация доступна через объект request, который передается в качестве первого параметра нашей callback-функции onRequest().
Чтобы интерпретировать эту информацию, нам необходимо добавить кое-какие Node.js-модули, а именно url и querystring.

Модуль url поддерживает методы, которые позволяют нам извлекать различные части URL (такие как запрошенный путь (URL path) и строка параметров запроса (query string)), а querystring в свою очередь, используется для парсинга строки параметров запроса (query string):

                               url.parse(string).query
                                       |
       url.parse(string).pathname      |
                   |                   |
                   |                   |
                 ------ -------------------
http://localhost:8888/start?foo=bar&hello=world
                            ---       -----
                             |          |
                             |          |
          querystring(string)["foo"]    |
                                        |
                     querystring(string)["hello"]

Конечно, мы также можем использовать querystring для парсинга тела POST-запроса, как мы увидим далее.

Давайте сейчас добавим в нашу функцию onRequest() логику, необходимую для извлечения пути URL (pathname), запрошенного браузером:

var http = require("http");
var url = require("url");function start() {
 
function onRequest(request, response) {
   
var pathname = url.parse(request.url).pathname;
    console
.log("Request for " + pathname + " received.");
    response
.writeHead(200, {"Content-Type": "text/plain"});
    response
.write("Hello World");
    response
.end();
 
}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Замечательно.
Теперь наше приложение может различать запросы на основе запрошенного пути URL.
Это позволяет нам направлять запросы нашим обработчикам запросов в зависимости от пути URL, используя наш роутер.
Таким образом, мы можем строить наше приложение RESTful-путём, потому что теперь можем реализовать интерфейс, следующий принципам Идентификации ресурсов (смотри статью в википедии REST для справки).

В контексте нашего приложения, это означает, что мы сможем обрабатывать запросы с URL /start и /upload разными частями нашего кода.
Скоро мы увидим, как всё соединяется вместе.

Теперь самое время написать наш роутер.
Создаём новый файл под названием router.js со следующим содержимым:

function route(pathname) {
  console
.log("About to route a request for " + pathname);
}

exports

.route = route;

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

Нашему HTTP-серверу необходимо знать о роутере и использовать его.
Мы могли бы жёстко прописать эти зависимости в нашем сервере, но, так как мы знаем только сложные способы из нашего опыта в других языках программирования, мы сделаем слабосвязанную зависимость сервера и роутера через внедрение этих зависимостей (можете почитать отличный пост Мартина Фаулера по внедрениям зависимости на английском языке или статью в Википедии на русском языке для дополнительной информации).

Для начала, расширим нашу серверную функцию start(), чтобы дать нам возможность передавать функцию route() как параметр:

var http = require("http");
var url = require("url");function start(route) {
 
function onRequest(request, response) {
   
var pathname = url.parse(request.url).pathname;
    console
.log("Request for " + pathname + " received.");

    route

(pathname);

    response

.writeHead(200, {"Content-Type": "text/plain"});
    response
.write("Hello World");
    response
.end();
 
}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Теперь расширим наш index.js соответственно, то есть внедрим функцию route() нашего роутера в сервер:

var server = require("./server");
var router = require("./router");

server

.start(router.route);

Мы опять передаём функцию, которая не является чем-то новым для нас.

Если мы сейчас запустим наше приложение (node index.js, как обычно) и запросим какой-нибудь URL, вы сможете увидеть в консоли, что наш HTTP-сервер использует наш роутер и передает ему запрошенный pathname:

bash$ node index.js
Request for /foo received.
About to route a request for /foo

(Я опустил слегка надоедливый вывод для запроса /favicon.ico)

Исполнение королевских постановлений в царстве глаголов

Позвольте мне ещё раз побродить вокруг и около и снова поговорить о функциональном программировании.

Передача функций связана не только с техническими соображениями.
Относительно разработки программного обеспечения это — почти философия.
Просто подумайте: в нашем index-файле мы могли бы передавать объект router в наш сервер и сервер мог бы вызывать функцию route этого объекта.

Этим способом мы бы передавали нечто и сервер использовал бы это нечто, чтобы сделать что-то.
Эй, роутер, не могли бы вы показать мне маршрут?

Но серверу не нужно нечто.
Ему нужно только получить что-то сделанное, а чтоб получить уже что-то сделанное, вам не нужно нечто совсем, вам необходимо действие.
Вам не нужно существительное, вам нужен глагол.

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

Я понял это, когда читал шедевр Стива Йегге Execution in the Kingdom of Nouns (частичный перевод на русский Исполнение королевских постановлений в царстве существительных).
Почитайте это обязательно. Это одно из лучших произведений о программировании, которое я когда-либо имел удовольствие встречать.

Роутинг реальных обработчиков запроса

Вернёмся к делу.
Наш HTTP-сервер и наш роутер запросов сейчас — лучшие друзья, и общаются друг с другом так, как мы хотели.

Конечно, этого недостаточно.
«Роутинг» подразумевает, что мы хотим обрабатывать запросы на разные URL по-разному.
Мы хотели бы иметь «бизнес-логику» для запросов к /start в одной функции, а для запросов к /upload в другой.

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

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

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

function start() {
  console
.log("Request handler 'start' was called.");
}function upload() {
  console
.log("Request handler 'upload' was called.");
}

exports

.start = start;
exports
.upload = upload;

Это позволяет нам связать обработчики запросов с роутером, давая нашему роутеру что-нибудь маршрутизировать.

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

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

Как мы собираемся передать их?
Сейчас у нас есть два обработчика, но в реальном приложении это число будет увеличиваться и меняться.
И мы уверены, что не хотим возиться с роутером каждый раз, когда добавляется новый URL + обработчик запроса.
И какие-нибудь if запрос == x then вызвать обработчик y в роутере будут более чем убоги.

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

Это решение немного разочаровывает тем фактом, что JavaScript не поддерживает ассоциативные массивы. Или нет?
Оказывается в действительности, если нам нужны ассоциативные массивы, мы должны использовать объекты!

Об этом есть хорошее введение http://msdn.microsoft.com/en-us/magazine/cc163419.aspx.
Позвольте мне процитировать подходящую часть:

В C++ или C#, когда мы говорим об объектах, мы ссылаемся на экземпляры классов или структуры.
Объекты имеют разные свойства и методы, в зависимости от шаблонов (классов), экземплярами которых они являются.
Но не в случае с JavaScript-объектами.
В JavaScript, объекты — это просто коллекция пар имя/значение — JavaScript-объект — это как словарь со строковыми ключами.

Если JavaScript-объекты это просто коллекции пар имя/значение, как тогда у них могут быть методы?
Итак, значения могут быть строками, числами и т.д. или функциями!

Хорошо, наконец-то возвращаемся к нашему коду. Мы решили, что мы хотим передать список из requestHandlers как объект и, для того, чтобы достичь слабое связывание, мы хотим внедрить этот объект в route().

Начнём с добавления объекта в наш главный файл index.js:

var server = require("./server");
var router = require("./router");
var requestHandlers = require("./requestHandlers");var handle = {}
handle
["/"] = requestHandlers.start;
handle
["/start"] = requestHandlers.start;
handle
["/upload"] = requestHandlers.upload;

server

.start(router.route, handle);

Хотя handle — это больше из разряда «нечто» (коллекция обработчиков запроса), я, всё-таки, предлагаю называть его глаголом, потому что в результате это будет функциональное выражение в нашем роутере, как вы скоро увидите.

Как вы можете видеть, это действительно просто — назначать различные URL соответствующему обработчику запроса: просто добавляя пару ключ/значение из «/» и requestHandlers.start, мы можем выразить красивым и аккуратным способом, что не только запросы к «/start», но также и запросы к «/» должны быть обработаны обработчиком start.

После определения объекта мы передали его в сервер как дополнительный параметр.
Изменим наш server.js, чтобы использовать его:

var http = require("http");
var url = require("url");function start(route, handle) {
 
function onRequest(request, response) {
   
var pathname = url.parse(request.url).pathname;
    console
.log("Request for " + pathname + " received.");

    route

(handle, pathname);

    response

.writeHead(200, {"Content-Type": "text/plain"});
    response
.write("Hello World");
    response
.end();
 
}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Мы добавили параметр handle в функцию start() и передаём объект handle в callback-функцию route() в качестве перового параметра.

Соответственно, изменим функцию route() в нашем файле router.js:

function route(handle, pathname) {
  console
.log("About to route a request for " + pathname);
 
if (typeof handle[pathname] === 'function') {
    handle
[pathname]();
 
} else {
    console
.log("No request handler found for " + pathname);
 
}
}

exports

.route = route;

Что мы здесь делаем — мы проверяем, существует ли обработчик запроса для данного пути, и если существует, просто вызываем соответствующую функцию.
Из-за того, что мы имеем доступ к нашим функциям обработчиков запроса из нашего объекта просто, как если бы имели доступ к элементу ассоциативного массива, у нас есть это прекрасное выражение handle[pathname]();, о котором говорилось ранее: «Пожалуйста, handle этот pathname».

Хорошо, это всё, что нужно, чтобы связать сервер, роутер и обработчики запроса вместе!
При запуске нашего приложения и запроса http://localhost:8888/start в браузере, мы можем убедиться, что надлежащий обработчик запроса действительно был вызван:

Server has started.
Request for /start received.
About to route a request for /start
Request handler 'start' was called.

Так же открываем http://localhost:8888/ в нашем браузере и убеждаемся, что эти запросы в самом деле обрабатываются обработчиком запросов start:

Request for / received.
About to route a request for /
Request handler 'start' was called.

Создание ответа обработчиков запроса

Замечательно. Вот только если бы обработчики запроса могли отправлять что-нибудь назад браузеру, было бы ещё лучше, правильно?

Вспомните, «Hello World», который выводит ваш браузер в запрошенной странице, всё ещё исходит от функции onRequest в нашем файле server.js.

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

Как делать не надо

Прямой подход, который мы захотим использовать как разработчики с опытом в PHP или Ruby, на самом деле ложный: он может прекрасно работать, иметь большой смысл, а потом, когда мы этого не ждём, неожиданно всё развалится.

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

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

Мы начнём с обработчиков запроса и заставим их возвращать то, что хотели бы показать в браузере.
Нам надо изменить requestHandlers.js вот так:

function start() {
  console
.log("Request handler 'start' was called.");
 
return "Hello Start";
}function upload() {
  console
.log("Request handler 'upload' was called.");
 
return "Hello Upload";
}

exports

.start = start;
exports
.upload = upload;

Хорошо. Также, роутер должен вернуть серверу то, что обработчики запроса вернули ему.
Поэтому надо отредактировать router.js так:

function route(handle, pathname) {
  console
.log("About to route a request for " + pathname);
 
if (typeof handle[pathname] === 'function') {
   
return handle[pathname]();
 
} else {
    console
.log("No request handler found for " + pathname);
   
return "404 Not found";
 
}
}

exports

.route = route;

Как видим, возвращается некоторый текст «404 Not found», если запрос не может быть маршрутизирован.

И самое последнее, но не менее важное, нам нужен рефакторинг нашего сервера, чтобы заставить его отвечать браузеру с контентом обработчиков запроса, возвращаемых через роутер. Трансформируем server.js в:

var http = require("http");
var url = require("url");function start(route, handle) {
 
function onRequest(request, response) {
   
var pathname = url.parse(request.url).pathname;
    console
.log("Request for " + pathname + " received.");

    response

.writeHead(200, {"Content-Type": "text/plain"});
   
var content = route(handle, pathname)
    response
.write(content);
    response
.end();
 
}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Если запустим наше написаное приложение, всё будет работать замечательно: запрос http://localhost:8888/start выдаст в браузере результат «Hello Start», запрос http://localhost:8888/upload даст нам «Hello Upload», а http://localhost:8888/foo выведет «404 Not found».

OK, тогда в чём проблема?
Короткий ответ: потому что мы столкнемся с проблемами, если один из обработчиков запроса захочет использовать неблокирующую операцию в будущем.

Подробный ответ займёт немного больше времени.

Блокирование и неблокирование

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

Вместо того, чтобы объяснять, что такое «блокирование» и «неблокирование», давайте продемонстрируем себе, что произойдёт, если мы добавим блокирующую операцию в наши обработчики запроса.

Для этого модифицируем обработчик запроса start так, чтобы он ждал 10 секунд до того как вернёт свою строку «Hello Start».
В JavaScript нет такой штуки как sleep(), поэтому мы будем использовать хитрый хак.

Пожалуйста, измените requestHandlers.js как описано далее:

function start() {
  console
.log("Request handler 'start' was called.");function sleep(milliSeconds) {
   
var startTime = new Date().getTime();
   
while (new Date().getTime() < startTime + milliSeconds);
 
}

  sleep

(10000);
 
return "Hello Start";
}function upload() {
  console
.log("Request handler 'upload' was called.");
 
return "Hello Upload";
}

exports

.start = start;
exports
.upload = upload;

Просто объясню, что этот код делает: когда функция start() вызвана, Node.js ожидает 10 секунд и только тогда возвращает «Hello Start».
Когда вызывается upload(), она выполняется немедленно, как и раньше.

(Конечно, вы уже поняли, вместо засыпания на 10 секунд, в start() могут быть реальные блокирующие операции, такие как сложные длительные вычисления.)

Давайте посмотрим, что поменялось.

Как обычно, нам надо перезапустить сервер.
На этот раз я попрошу вас следовать немного более сложному «протоколу», чтобы увидеть, что произошло: во-первых, откройте браузер или таб.
В первом окне браузера, введите, пожалуйста, http://localhost:8888/start в адресную строку, но не переходите пока по этому адресу!

В адресную строку второго окна браузера введите http://localhost:8888/upload и снова не переходите по адресу.

Теперь сделайте, как описано далее: нажмите клавишу Enter в первом окне («/start»), а затем быстро переключитесь на второе окно («/upload») и нажмите тоже Enter.

Что вы будете наблюдать: URL /start потребуется 10 секунд для загрузки, как мы и ожидали.
Но URL /upload так же потребуется 10 секунд на загрузку, хотя в соответствующем обработчике запроса нет sleep()!

Почему? Потому что start() содержит блокирующую операцию.
Like in «it’s blocking everything else from working».

И в этом проблема, потому что, как говорят: «В node всё работает параллельно, за исключением вашего кода».

Это значит, что Node.js может обрабатывать одновременно множество вещей, но при этом не разделяет всё на отдельные потоки — Node.js однопоточный.
Он делает это, запуская цикл событий, а мы, разработчики, можем использовать это — мы должны избегать блокирующих операций, где это возможно, и использовать неблокирующие операции вместо них.

Но для этого нам надо использовать обратные вызовы, передавая функции внутри тех функций, которые могут сделать то, что занимает некоторое время (как, например, sleep() на 10 секунд или запрос к базе данных или какое-то дорогостоящее вычисление.)

Таким образом, мы как бы говорим: «Эй, возможноДолгаяФункция(), пожалуйста, сделай вот это, но я, однопотоковый Node.js, не собираюсь ждать здесь, пока ты закончишь, я продолжу выполнение строчек кода ниже тебя, а ты возьми пока вот эту функцию callbackFunction() и вызови её, когда всё сделаешь. Спасибо!»

(Если хотите почитать об этом более подробно, пожалуйста посмотрите пост Mixu на Understanding the node.js event loop.)

И мы сейчас увидим, почему способ, которым мы создали «обработчик запроса обрабатывающий ответ» в нашем приложении не позволит правильно использовать неблокирующие операции.

Ещё раз давайте попробуем испытать проблему на своей шкуре, модифицировав наше приложение.

Мы снова используем наш обработчик запроса start. Пожалуйста, измените его следующим образом (файл requestHandlers.js)

var exec = require("child_process").exec;function start() {
  console
.log("Request handler 'start' was called.");
 
var content = "empty";

  exec

("ls -lah", function (error, stdout, stderr) {
    content
= stdout;
 
});return content;
}function upload() {
  console
.log("Request handler 'upload' was called.");
 
return "Hello Upload";
}

exports

.start = start;
exports
.upload = upload;

Как можно видеть, мы просто внедрили новый модуль Node.js child_process.
Мы сделали так, потому что это позволит нам использовать очень простую, но полезную неблокирующую операцию: exec().

Что делает exec() — она выполняет shell-команду внутри Node.js.
В этом примере мы собираемся использовать её, чтобы получить список всех файлов в текущей директории («ls -lah»), позволяя нам отобразить этот список в браузере пользователя, запросившего URL /start.

Что делает этот код: создает новую переменную content (с начальным значением «empty»), выполняет «ls -lah», заполняет переменную результатом и возвращает её.

Как обычно, запустим наше приложение и посетим http://localhost:8888/start.

Которая загрузит нам красивую страничку со строкой «empty». Что тут не так?

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

Если хотите удостовериться, замените «ls -lah» на более дорогостоящую операцию «find /»).

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

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

Проблемой является то, что exec(), чтобы работать без блокирования, использует callback-функцию.

В нашем примере это анонимная функция, которая передаётся как второй параметр в функцию exec():

function (error, stdout, stderr) {
  content
= stdout;
}

И здесь лежит корень нашей проблемы: наш собственный код исполняется синхронно, что означает, что сразу после вызова exec(), Node.js продолжит выполнять return content;.
К этому моменту content ещё «empty», из-за того, что callback-функция, переданная в exec(), до сих пор не вызвана — потому что операция exec() асинхронная.

Теперь «ls -lah» — очень недорогая и быстрая операция (если только в директории не миллион файлов).
Именно поэтому callback вызывается относительно оперативно — но это, всё же, происходит асинхронно.

Использование более дорогостоящих команд делает это более очевидным: «find /» занимает около 1 минуты на моей машине, но если я заменяю «ls -lah» на «find /» в обработчике запроса, то я всё ещё немедленно получаю HTTP-ответ, когда открываю URL /start.
Ясно, что exec() делает что-то в фоновом режиме, пока Node.js продолжает исполнять приложение и мы можем предположить, что callback-функция, которую мы передали в exec(), будет вызвана только когда команда «find /» закончит выполняться.

Но как нам достичь нашей цели, то есть, показать пользователю список файлов в текущей директории?

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

Ответ обработчиков запроса с неблокирующими операциями.

Я употребил фразу «правильный способ».
Опасная вещь.
Довольно часто не существует единого «правильного способа».

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

Сейчас наше приложение способно транспортировать контент (который обработчики запроса хотели бы показать пользователю) от обработчиков запроса к HTTP-серверу, возвращая его через слои приложения (обработчик запроса -> роутер -> сервер).

Наш новый подход заключается в следующем: вместо доставки контента серверу мы будем сервер доставлять к контенту.
Чтобы быть более точным, мы будем внедрять объект response (из серверной callback-функции onRequest()) через роутер в обработчики запроса.
Обработчики смогут тогда использовать функции этого объекта для ответа на сами запросы.

Достаточно разъяснений. Вот — пошаговый рецепт изменения нашего приложения.

Начнём с нашего server.js:

var http = require("http");
var url = require("url");function start(route, handle) {
 
function onRequest(request, response) {
   
var pathname = url.parse(request.url).pathname;
    console
.log("Request for " + pathname + " received.");

    route

(handle, pathname, response);
 
}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Вместо ожидания возврата значения от функции route(), мы передаём наш объект response в качестве третьего параметра.
Кроме того, мы удалили всякие вызовы методов response из обработчика onRequest(), потому что мы рассчитываем, что route позаботится об этом.

Далее идёт router.js:

function route(handle, pathname, response) {
  console
.log("About to route a request for " + pathname);
 
if (typeof handle[pathname] === 'function') {
    handle
[pathname](response);
 
} else {
    console
.log("No request handler found for " + pathname);
    response
.writeHead(404, {"Content-Type": "text/plain"});
    response
.write("404 Not found");
    response
.end();
 
}
}

exports

.route = route;

Та же схема: вместо ожидания возврата значения от наших обработчиков события, мы передаём объект respond.

Если обработчик запроса не может быть использован, мы заботимся об ответе с надлежащим заголовком «404» и телом ответа.

И последнее, но не менее важное, мы модифицируем requestHandlers.js:

var exec = require("child_process").exec;function start(response) {
  console
.log("Request handler 'start' was called.");

  exec

("ls -lah", function (error, stdout, stderr) {
    response
.writeHead(200, {"Content-Type": "text/plain"});
    response
.write(stdout);
    response
.end();
 
});
}function upload(response) {
  console
.log("Request handler 'upload' was called.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("Hello Upload");
  response
.end();
}

exports

.start = start;
exports
.upload = upload;

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

Обработчик start будет отвечать изнутри анонимного обратного вызова exec(), а обработчик upload будет всё ещё выдавать «Hello Upload», но теперь посредством объекта response.

Если вы запустили наше приложение снова (node index.js), всё должно работать как и ожидалось.

Если хотите убедиться, что дорогостоящая операция в /start больше не будет блокировать запросы на /upload, модифицируйте ваш requestHandlers.js как показано далее:

var exec = require("child_process").exec;function start(response) {
  console
.log("Request handler 'start' was called.");

  exec

("find /",
   
{ timeout: 10000, maxBuffer: 20000*1024 },
   
function (error, stdout, stderr) {
      response
.writeHead(200, {"Content-Type": "text/plain"});
      response
.write(stdout);
      response
.end();
   
});
}function upload(response) {
  console
.log("Request handler 'upload' was called.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("Hello Upload");
  response
.end();
}

exports

.start = start;
exports
.upload = upload;

Благодаря этому, HTTP-запросы к http://localhost:8888/start будут занимать не менее 10 секунд, но запросы к http://localhost:8888/upload будут получать ответ немедленно, даже если /start всё ещё занят вычислениями.

Сделаем что-нибудь полезное

До сих пор мы делали всё прекрасно и изысканно, но мы не создали ничего значимого для клиентов нашего супер-сайта.

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

OK, давайте шаг за шагом, но с разъяснением больших техник и принципов JavaScript, и в то же время, давайте немного ускоримся.
Автору слишком нравится слушать самого себя.

Здесь «шаг за шагом» означает примерно 2 шага: сначала мы посмотрим как обрабатывать входящие POST-запросы (но не загрузку файла), и на втором шаге мы используем внешний модуль Node.js для обработки загрузки файла.
Я выбирал этот подход по двум причинам.

Во-первых, обрабатывать базовые POST-запросы относительно просто в Node.js, но для обучения это — достаточно стоящее упражнение.

Во-вторых, обработка загрузки файла (к примеру, multipart POST-запросы) это не так просто в Node.js, поэтому выходит за рамки этого учебника, но пример использования для этого внешнего модуля имеет смысл включить в учебник для начинающих.

Обработка POST-запросов

Давайте сделаем попроще: предоставим текcтовое поле, которое может быть заполнено пользователем и отправлено на сервер в POST-запросе.
После получения и обработки этого запроса мы отобразим содержимое текстового поля.

HTML-код для формы текстового поля должен формировать наш обработчик запроса /start, так давайте сразу же добавим его в файл requestHandlers.js:

function start(response) {
  console
.log("Request handler 'start' was called.");var body = '<html>'+
   
'<head>'+
   
'<meta http-equiv="Content-Type" content="text/html; '+
   
'charset=UTF-8" />'+
   
'</head>'+
   
'<body>'+
   
'<form action="/upload" method="post">'+
   
'<textarea name="text" rows="20" cols="60"></textarea>'+
   
'<input type="submit" value="Submit text" />'+
   
'</form>'+
   
'</body>'+
   
'</html>';

    response

.writeHead(200, {"Content-Type": "text/html"});
    response
.write(body);
    response
.end();
}function upload(response) {
  console
.log("Request handler 'upload' was called.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("Hello Upload");
  response
.end();
}

exports

.start = start;
exports
.upload = upload;

Если теперь этот код не выиграет Webby Awards, то я не знаю, какой сможет.
Вы должны увидеть эту очень простую форму, когда запросите http://localhost:8888/start в вашем браузере.
Если это не так — возможно, вы не перезагрузили приложение.

Я уже слышу вас: помещать содержимое представления прямо в обработчик запроса некрасиво.
Тем не менее, я решил не включать этот дополнительный уровень абстракции (то есть, разделение представления и логики) в наш учебник, потому что, я думаю, что это не научит нас чему-нибудь стоящему в контексте JavaScript или Node.js.

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

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

Это имеет смысл, потому что POST-запросы могут быть потенциально очень большими — ничто не мешает пользователю ввести текст размером в несколько мегабайтов.
Обработка всего массива данных за один раз может привести к блокирующей операции.

Чтобы сделать весь процесс неблокирующим, Node.js обслуживает POST-данные небольшими порциями, а callback-функции вызываются при определённых событиях.
Эти события — data (когда приходит новая порция POST-данных) и end (когда все части данных были получены).

Надо сообщить Node.js, какие функции вызывать, когда эти события произойдут.
Это делается путём добавления слушателей (listeners) в объект request, который передаётся в нашу callback-функцию onRequest, когда HTTP-запрос получен.

В основном, это выглядит так:

request.addListener("data", function(chunk) {
 
// called when a new chunk of data was received
});

request

.addListener("end", function() {
 
// called when all chunks of data have been received
});

Возникает вопрос, где реализовать эту логику.
В настоящее время мы можем получить доступ к объекту request только в нашем сервере — мы не передаём его в роутер и в обработчики запроса, как делаем это с объектом response.

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

Таким образом, идея — в том, чтобы поместить обратные вызовы событий data и end в сервер, собирать все куски POST-данных в data и вызывать роутер при получении события end, пока идёт передача собранных порций данных в роутер, который в свою очередь передаёт их в обработчики запроса.

Начинаем с server.js:

var http = require("http");
var url = require("url");function start(route, handle) {
 
function onRequest(request, response) {
   
var postData = "";
   
var pathname = url.parse(request.url).pathname;
    console
.log("Request for " + pathname + " received.");

    request

.setEncoding("utf8");

    request

.addListener("data", function(postDataChunk) {
      postData
+= postDataChunk;
      console
.log("Received POST data chunk '"+
      postDataChunk
+ "'.");
   
});

    request

.addListener("end", function() {
      route
(handle, pathname, response, postData);
   
});}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Здесь, в основном, мы сделали три вещи: во-первых, определили, что ожидаем полученные данные в кодировке UTF-8, затем добавили слушатель для события «data», который шаг за шагом заполняет нашу новую переменную postData всякий раз, когда прибывает новая порция POST-данных, и далее — переходим к вызову нашего роутера в обратном вызове события end, чтобы убедиться, что вызов происходит, когда все POST-данные собраны.
Мы также передаём POST-данные в роутере, потому что они нам понадобятся в обработчиках запроса.

Добавление логирования в консоли для каждой порции полученных данных — возможно, плохая идея для конечного кода (мегабайты POST-данных, помните?), но это имеет смысл, чтобы посмотреть, что происходит.

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

Давайте добавим ещё больше крутизны в наше приложение.
На странице /upload мы будем показывать принятый контент.
Чтобы сделать это возможным, нам необходимо передавать postData в обработчики запроса. В router.js:

function route(handle, pathname, response, postData) {
  console
.log("About to route a request for " + pathname);
 
if (typeof handle[pathname] === 'function') {
    handle
[pathname](response, postData);
 
} else {
    console
.log("No request handler found for " + pathname);
    response
.writeHead(404, {"Content-Type": "text/plain"});
    response
.write("404 Not found");
    response
.end();
 
}
}

exports

.route = route;

И в requestHandlers.js мы включаем эти данные в нашем ответе обработчика запроса upload:

function start(response, postData) {
  console
.log("Request handler 'start' was called.");var body = '<html>'+
   
'<head>'+
   
'<meta http-equiv="Content-Type" content="text/html; '+
   
'charset=UTF-8" />'+
   
'</head>'+
   
'<body>'+
   
'<form action="/upload" method="post">'+
   
'<textarea name="text" rows="20" cols="60"></textarea>'+
   
'<input type="submit" value="Submit text" />'+
   
'</form>'+
   
'</body>'+
   
'</html>';

    response

.writeHead(200, {"Content-Type": "text/html"});
    response
.write(body);
    response
.end();
}function upload(response, postData) {
  console
.log("Request handler 'upload' was called.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("You've sent: " + postData);
  response
.end();
}

exports

.start = start;
exports
.upload = upload;

Вот и всё, теперь мы можем получить POST-данные и использовать их в наших обработчиках запроса.

И последнее по этой теме: то, что мы передаём в роутер и обработчики запроса, является полным телом нашего POST-запроса.
Мы, вероятно, захотим использовать индивидуальные поля, составляющие POST-данные, в нашем случае значение поля text.

Мы уже читали про модуль querystring, который поможет нам с этим:

var querystring = require("querystring");function start(response, postData) {
  console
.log("Request handler 'start' was called.");var body = '<html>'+
   
'<head>'+
   
'<meta http-equiv="Content-Type" content="text/html; '+
   
'charset=UTF-8" />'+
   
'</head>'+
   
'<body>'+
   
'<form action="/upload" method="post">'+
   
'<textarea name="text" rows="20" cols="60"></textarea>'+
   
'<input type="submit" value="Submit text" />'+
   
'</form>'+
   
'</body>'+
   
'</html>';

    response

.writeHead(200, {"Content-Type": "text/html"});
    response
.write(body);
    response
.end();
}function upload(response, postData) {
  console
.log("Request handler 'upload' was called.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("You've sent the text: "+
  querystring
.parse(postData).text);
  response
.end();
}

exports

.start = start;
exports
.upload = upload;

Это всё, что можно сказать про обработку POST-данных в рамках учебника для начинающих.

Обработка загрузки файлов

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

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

Внешний модуль, который мы собираемся использовать, node-formidable от Felix Geisendörfer.
Этот модуль поможет нам абстрагироваться от мерзких деталей парсинга входящих файловых данных.
В конце концов, обработка входящих файлов это не что иное, как «просто» обработка POST-данных, но, в действительности, дьявол кроется в деталях, поэтому в нашем случае имеет смысл использовать готовое решение.

Чтобы использовать код Феликса, соответствующий модуль Node.js должен быть инсталлирован.
На борту Node.js есть собственный менеджер пакетов, называемый NPM.
Он позволяет нам инсталировать внешние модули Node.js в очень удобной форме.
С учетом установленного Node.js, всё сводится к

npm install formidable

в нашей командной строке. Если вы в конце увидели следующее:

npm info build Success: formidable@1.0.9
npm ok

…это значит — всё хорошо.

Модуль formidable теперь доступен в нашем коде — всё, что нужно, это просто запросить его как один из тех модулей, которые мы использовали ранее:

var formidable = require("formidable");

По сути, formidable делает форму, отправленную через HTTP POST, доступной для парсинга в Node.js.
Всё, что нам надо — это создать новый экземпляр объекта IncomingForm, который является абстракцией отправленной формы и может быть использован для парсинга объекта request нашего HTTP-сервера, для полей и файлов, отправленных через эту форму.

Пример кода со страницы проекта node-formidable показывает, как разные части сочетаются друг с другом:

var formidable = require('formidable'),
    http
= require('http'),
    sys
= require('sys');

http

.createServer(function(req, res) {
 
if (req.url == '/upload' && req.method.toLowerCase() == 'post') {
   
// parse a file upload
   
var form = new formidable.IncomingForm();
    form
.parse(req, function(err, fields, files) {
      res
.writeHead(200, {'content-type': 'text/plain'});
      res
.write('received upload:nn');
      res
.end(sys.inspect({fields: fields, files: files}));
   
});
   
return;
 
}// show a file upload form
  res
.writeHead(200, {'content-type': 'text/html'});
  res
.end(
   
'<form action="/upload" enctype="multipart/form-data" '+
   
'method="post">'+
   
'<input type="text" name="title"><br>'+
   
'<input type="file" name="upload" multiple="multiple"><br>'+
   
'<input type="submit" value="Upload">'+
   
'</form>'
 
);
}).listen(8888);

Если вы поместите этот код в файл и исполните его посредством node, вы сможете отправлять простые формы, включая загрузку фото, и увидите, как организован объект files, который передавался в callback, определенном в вызове form.parse.

received upload:

{ fields: { title: 'Hello World' },
  files:
   { upload:
	  { size: 1558,
		path: '/tmp/1c747974a27a6292743669e91f29350b',
		name: 'us-flag.png',
		type: 'image/png',
		lastModifiedDate: Tue, 21 Jun 2011 07:02:41 GMT,
		_writeStream: [Object],
		length: [Getter],
		filename: [Getter],
		mime: [Getter] } } }

Чтобы выполнить последний пункт нашей задачи, мы должны включить логику парсинга форм модуля formidable в структуру нашего кода, плюс ко всему, надо разобраться, как отдавать контент загруженного файла (который сохранен в директории /tmp) браузеру.

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

Мы, очевидно, собираемся считать содержимое этого файла в наш Node.js-сервер, и неудивительно, что для этого имеется соответствующий модуль под названием fs.

Давайте добавим ещё один обработчик запроса для URL /show, который будет «захардкоженно» показывать содержимое файла /tmp/test.png.
Конечно же, имеет смысл в первую очередь поместить реальную png-картинку в этот каталог.

Мы собираемся изменить requestHandlers.js, как показано далее:

var querystring = require("querystring"),
    fs
= require("fs");function start(response, postData) {
  console
.log("Request handler 'start' was called.");var body = '<html>'+
   
'<head>'+
   
'<meta http-equiv="Content-Type" '+
   
'content="text/html; charset=UTF-8" />'+
   
'</head>'+
   
'<body>'+
   
'<form action="/upload" method="post">'+
   
'<textarea name="text" rows="20" cols="60"></textarea>'+
   
'<input type="submit" value="Submit text" />'+
   
'</form>'+
   
'</body>'+
   
'</html>';

    response

.writeHead(200, {"Content-Type": "text/html"});
    response
.write(body);
    response
.end();
}function upload(response, postData) {
  console
.log("Request handler 'upload' was called.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("You've sent the text: "+
  querystring
.parse(postData).text);
  response
.end();
}function show(response, postData) {
  console
.log("Request handler 'show' was called.");
  fs
.readFile("/tmp/test.png", "binary", function(error, file) {
   
if(error) {
      response
.writeHead(500, {"Content-Type": "text/plain"});
      response
.write(error + "n");
      response
.end();
   
} else {
      response
.writeHead(200, {"Content-Type": "image/png"});
      response
.write(file, "binary");
      response
.end();
   
}
 
});
}

exports

.start = start;
exports
.upload = upload;
exports
.show = show;

Также, надо преобразовать новый обработчик запроса в URL вида /show в файле index.js:

var server = require("./server");
var router = require("./router");
var requestHandlers = require("./requestHandlers");var handle = {}
handle
["/"] = requestHandlers.start;
handle
["/start"] = requestHandlers.start;
handle
["/upload"] = requestHandlers.upload;
handle
["/show"] = requestHandlers.show;

server

.start(router.route, handle);

Перезапускаем сервер, открываем http://localhost:8888/show в браузере и видим картинку /tmp/test.png.

Хорошо. Всё, что нам надо теперь — это:

  • добавить поле для загрузки файлов в форму, находящуюся по адресу /start,
  • интегрировать node-formidable в обработчик запроса upload, чтобы сохранять загруженные файлы в /tmp/test.png,
  • внедрить загруженную картинку в HTML, отдаваемый по URL /upload.

Первый шаг — простой. Нам надо добавить тип кодировки multipart/form-data в нашу HTML-форму, удалить текстовое поле, добавить поле загрузки файла и поменять текст кнопки отправки формы на «Upload file».
Давайте просто сделаем это в файле requestHandlers.js:

var querystring = require("querystring"),
    fs
= require("fs");function start(response, postData) {
  console
.log("Request handler 'start' was called.");var body = '<html>'+
   
'<head>'+
   
'<meta http-equiv="Content-Type" '+
   
'content="text/html; charset=UTF-8" />'+
   
'</head>'+
   
'<body>'+
   
'<form action="/upload" enctype="multipart/form-data" '+
   
'method="post">'+
   
'<input type="file" name="upload">'+
   
'<input type="submit" value="Upload file" />'+
   
'</form>'+
   
'</body>'+
   
'</html>';

    response

.writeHead(200, {"Content-Type": "text/html"});
    response
.write(body);
    response
.end();
}function upload(response, postData) {
  console
.log("Request handler 'upload' was called.");
  response
.writeHead(200, {"Content-Type": "text/plain"});
  response
.write("You've sent the text: "+
  querystring
.parse(postData).text);
  response
.end();
}function show(response, postData) {
  console
.log("Request handler 'show' was called.");
  fs
.readFile("/tmp/test.png", "binary", function(error, file) {
   
if(error) {
      response
.writeHead(500, {"Content-Type": "text/plain"});
      response
.write(error + "n");
      response
.end();
   
} else {
      response
.writeHead(200, {"Content-Type": "image/png"});
      response
.write(file, "binary");
      response
.end();
   
}
 
});
}

exports

.start = start;
exports
.upload = upload;
exports
.show = show;

Замечательно. Следующий шаг — немного более сложный, конечно.
Первая проблема следующая: мы хотим обрабатывать загрузку файлов в нашем обработчике запроса upload, и тут надо будет передать объект request при вызове form.parse модуля node-formidable.

Но всё, что у нас есть — это объект response и массив postData.
Грустно.
Похоже, что придётся передавать каждый раз объект request из сервера в роутер и обработчик запроса.
Может быть, имеется более элегантное решение, но этот способ может делать работу уже сейчас.

Давайте полностью удалим всё, что касается postData в нашем сервере и обработчиках запроса — он нам не нужен для обработки загрузки файла и, мало того, — даже создает проблему: мы уже «поглотили» события data объекта request в сервере, а следовательно, form.parse, которому так же надо поглащать эти события, не сможет получить больше данных (потому что Node.js не буферизирует данные).

Начнём с server.js — удалим обработку postData и строку с request.setEncoding (node-formidable сам всё сделает) и передадим request в роутер:

var http = require("http");
var url = require("url");function start(route, handle) {
 
function onRequest(request, response) {
   
var pathname = url.parse(request.url).pathname;
    console
.log("Request for " + pathname + " received.");
    route
(handle, pathname, response, request);
 
}

  http

.createServer(onRequest).listen(8888);
  console
.log("Server has started.");
}

exports

.start = start;

Следующий — router.js — мы больше не передаём postData, а вместо этого передаём request:

function route(handle, pathname, response, request) {
  console
.log("About to route a request for " + pathname);
 
if (typeof handle[pathname] === 'function') {
    handle
[pathname](response, request);
 
} else {
    console
.log("No request handler found for " + pathname);
    response
.writeHead(404, {"Content-Type": "text/html"});
    response
.write("404 Not found");
    response
.end();
 
}
}

exports

.route = route;

Теперь объект request может быть использован в функции обработчика запроса upload.
node-formidable будет заниматься сохранением загруженного файла в локальный файл /tmp, но, конечно, мы сами должны сделать, чтобы этот файл переименовывался в /tmp/test.png.
Да, мы придерживаемся действительно простых вещей и принимаем, что могут загружаться только PNG-картинки.

Имеется небольшая дополнительная сложность в логике переименования: Windows-реализации Node.js не нравится, когда пытаются переименовать существующий файл, вот почему нам необходимо удалять файл в случае ошибки.

Давайте добавим в requestHandlers.js код управления загрузкой файла и переименованием:

var querystring = require("querystring"),
	fs = require("fs"),
	formidable = require("formidable");

function start(response) {
  console.log("Request handler 'start' was called.");

  var body = '<html>'+
	'<head>'+
	'<meta http-equiv="Content-Type" '+
	'content="text/html; charset=UTF-8" />'+
	'</head>'+
	'<body>'+
	'<form action="/upload" enctype="multipart/form-data" '+
	'method="post">'+
	'<input type="file" name="upload" multiple="multiple">'+
	'<input type="submit" value="Upload file" />'+
	'</form>'+
	'</body>'+
	'</html>';

	response.writeHead(200, {"Content-Type": "text/html"});
	response.write(body);
	response.end();
}

function upload(response, request) {
  console.log("Request handler 'upload' was called.");

  var form = new formidable.IncomingForm();
  console.log("about to parse");
  form.parse(request, function(error, fields, files) {
	console.log("parsing done");

/* Возможна ошибка в Windows: попытка переименования уже существующего файла */
	fs.rename(files.upload.path, "/tmp/test.png", function(err) {
	  if (err) {
		fs.unlink("/tmp/test.png");
		fs.rename(files.upload.path, "/tmp/test.png");
	  }
	});
	response.writeHead(200, {"Content-Type": "text/html"});
	response.write("received image:<br/>");
	response.write("<img src='/show' />");
	response.end();
  });
}

function show(response) {
  console.log("Request handler 'show' was called.");
  fs.readFile("/tmp/test.png", "binary", function(error, file) {
	if(error) {
	  response.writeHead(500, {"Content-Type": "text/plain"});
	  response.write(error + "n");
	  response.end();
	} else {
	  response.writeHead(200, {"Content-Type": "image/png"});
	  response.write(file, "binary");
	  response.end();
	}
  });
}

exports.start = start;
exports.upload = upload;
exports.show = show;

Вот и всё. Перезапускаем сервер, и последний пункт нашей задачи реализован.
Выбираем локальную PNG-картинку с диска, загружаем на сервер и видим её на нашей веб-страничке.

Выводы и перспективы

Поздравляю, наша миссия выполнена!
Мы написали простое, уже полностью готовое web-приложение на Node.js.
Мы поговорили о server-side JavaScript, функциональном программировании, блокирующих и неблокирующих операциях, callback-ах, событиях, обычаях, внутренних и внешних модулях и о многом другом.

Конечно, имеется много вещей, которые мы не обсудили: как общаться с базой данных, как писать unit-тесты, как создавать внешние модули, с возможностью инсталяции через NPM или даже что-нибудь простое, например, как обрабатывать GET-запросы.

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

Хорошей новостью является то, что Node.js-сообщество — очень активное (думаю, даже гиперактивное от кофеина, но в хорошем смысле), имеется множество ресурсов, кроме этого, и множество мест, где можно получить ответы на ваши вопросы.
Node.js community wiki и the NodeCloud directory являются, возможно, лучшими отправными точками.

В предыдущей теме было создано первое приложение на Angular с применением TypeScript. Использование языка программирования TypeScript представляет наиболее распространенный подход для создания приложений на Angular. Однако это не единственный подход. Теоретически мы можем использовать также Dart, ES2015 и ES5(стандартный JavaScript). И в этой статье рассмотрим создание первого приложения с помощью кода javascript, который поддерживается всеми браузерами.

Создадим каталог приложения, а в нем определим каталог app.

В этот каталог app добавим новый файл app.component.js со следующим кодом:

function Item(purchase, price, done) {
  this.purchase = purchase
  this.price = price
  this.done = done
}

var AppComponent = ng.core
  .Component({
    selector: 'my-app',
    template: `<div class="page-header">
        <h1> Список покупок </h1>
    </div>
    <div class="panel">
        <div class="form-inline">
            <div class="form-group">
                <div class="col-md-8">
                    <input class="form-control" [(ngModel)]="text" placeholder = "Название" />
                </div>
            </div>
            <div class="form-group">
                <div class="col-md-6">
                    <input type="number" class="form-control" [(ngModel)]="price" placeholder="Цена" />
                </div>
            </div>
            <div class="form-group">
                <div class="col-md-offset-2 col-md-8">
                    <button class="btn btn-default" (click)="addItem(text, price)">Добавить</button>
                </div>
            </div>
        </div>
        <table class="table table-striped">
            <thead>
                <tr>
                    <th>Предмет</th>
                    <th>Цена</th>
                    <th>Куплено</th>
                </tr>
            </thead>
            <tbody>
                <tr *ngFor="let item of items">
                    <td>{{item.purchase}}</td>
                    <td>{{item.price}}</td>
                    <td><input type="checkbox" [(ngModel)]="item.done" /></td>
                </tr>
            </tbody>
        </table>
    </div>`,
  })
  .Class({
    constructor: function () {
      this.items = [
        new Item('Хлеб', 15.9),
        new Item('Масло', 60),
        new Item('Картофель', 22.6, true),
        new Item('Сыр', 310),
      ]
    },
  })

AppComponent.prototype.addItem = function (text, price) {
  if (
    text == undefined ||
    text.trim() == '' ||
    price == undefined
  )
    return
  this.items.push(new Item(text, price))
}

Здесь определен главный компонент приложения — AppComponent. Для его создания применяется функция ng.core.Component() из библиотеки @angular/core.

Весь код почти аналогичен коду компонента на TypeScript из прошлой темы.

Также добавим в папку app новый файл app.module.js:

var AppModule = ng.core
  .NgModule({
    imports: [
      ng.platformBrowser.BrowserModule,
      ng.forms.FormsModule,
    ],
    declarations: [AppComponent],
    bootstrap: [AppComponent],
  })
  .Class({
    constructor: function () {},
  })

Модуль создается с помощью функции ng.core.NgModule(), которая определена в библиотеке @angular/core.

Свойство imports у модуля указывает на модули, которые будут использоваться. Свойство declarations хранит набор используемых компонентов, а свойство bootstrap определяет загружаемый компонент — AppComponent.

И далее создадим в папке app новый файл main.js:

ng.platformBrowserDynamic
  .platformBrowserDynamic()
  .bootstrapModule(AppModule)

Этот файл будет загружать основной модуль приложения.

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

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Приложение покупок</title>
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1"
    />
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"
    />
    <script src="https://unpkg.com/core-js/client/shim.min.js"></script>
    <script src="https://unpkg.com/zone.js/dist/zone.min.js"></script>
    <script src="https://unpkg.com/rxjs/bundles/Rx.min.js"></script>

    <script src="https://unpkg.com/@angular/[email protected]/bundles/core.umd.js"></script>
    <script src="https://unpkg.com/@angular/[email protected]/bundles/common.umd.js"></script>
    <script src="https://unpkg.com/@angular/[email protected]/bundles/compiler.umd.js"></script>
    <script src="https://unpkg.com/@angular/[email protected]/bundles/forms.umd.js"></script>
    <script src="https://unpkg.com/@angular/[email protected]/bundles/platform-browser.umd.js"></script>
    <script src="https://unpkg.com/@angular/[email protected]/bundles/platform-browser-dynamic.umd.js"></script>
  </head>
  <body>
    <my-app>Загрузка...</my-app>
    <script src="app/app.component.js"></script>
    <script src="app/app.module.js"></script>
    <script src="app/main.js"></script>
  </body>
</html>

Для подключения внешних файлов здесь применяется CDN unpkg.com.

Первая группа файлов аналогична тем, что подключались в прошлой теме в приложении на TypeScript:

<link
  rel="stylesheet"
  href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"
/>
<script src="https://unpkg.com/core-js/client/shim.min.js"></script>
<script src="https://unpkg.com/zone.js/dist/zone.min.js"></script>
<script src="https://unpkg.com/rxjs/bundles/Rx.min.js"></script>

Но кроме этих файлов также необходимо подключить все используемые модули. Можно заметить, что в коде используются функции, которые начинаются с ng.*, например, ng.core.Component, ng.forms.FormsModule и другие. Это тот функционал, который предоставляется непосредственно библиотеками Angular, и эти библиотеки надо подключить:

<script src="https://unpkg.com/@angular/[email protected]/bundles/core.umd.js"></script>
<script src="https://unpkg.com/@angular/[email protected]/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/[email protected]/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/[email protected]/bundles/forms.umd.js"></script>
<script src="https://unpkg.com/@angular/[email protected]/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/[email protected]/bundles/platform-browser-dynamic.umd.js"></script>

В итоге у нас получится следующая структура проекта:

app
  app.component.js
  app.module.js
  main.js
index.html

Теперь запустим проект. Для этого достаточно перенести файл веб-страницы в браузер:

Скриншот приложения

Данный пример демонстрирует создание простейшего приложения на языке JavaScript в интегрированной среде WebStorm. По данному примеру можно получить программный каркас для изучения синтаксиса и основных возможностей JavaScript и его интеграции с HTML/CSS.


Содержание

  • 1. Условие задачи
  • 2. Выполнение
    • 2.1. Создание проекта в IDE WebStorm
    • 2.2. Создание пустого проекта
    • 2.3. Создание HTML-файла
    • 2.4. Запуск HTML-файла
    • 2.5. Добавление кода JavaScript
      • 2.5.1. Код JavaScript в тексте main.html. Вывод сообщения на странице веб-браузера с помощью метода document.write()
      • 2.5.2. Вывод сообщения в отдельном окне. Метод alert()
      • 2.5.3. Вызов кода JavaScript, который был сформирован в отдельном файле
      • 2.5.4. Подключение файла на языке JavaScript
      • 2.5.5. Написание кода JavaScript в файле hello.js
      • 2.5.6. Подключение JavaScript кода к файлу main.html
    • 2.6. Запуск файла main.html в браузере. Просмотр результатов

Поиск на других ресурсах:

1. Условие задачи

Используя сочетание языков программирования HTML и JavaScript разработать программу, которая выводит на экран сообщение «Hello, world!». Программу разработать в интегрированной среде WebStorm.

Продемонстрировать различные способы вывода сообщения:

  • в документе веб-браузера;
  • в отдельном диалоговом окне.

Программный код JavaScript должен быть реализован двумя способами:

  • непосредственно в документе HTML;
  • в отдельном файле hello.js, подключаемом к документу HTML.

 

2. Выполнение
2.1. Создание проекта в IDE WebStorm

Для разработки приложений на языках JavaScript, HTML&CSS хорошо подходит интегрированная среда разработки WebStorm от компании JetBrains.

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

Для создания нового проекта нужно выбрать команду Create New Project.

WebStorm. JavaScript. Окно "Welcome to WebStorm"

Рисунок 1. Окно «Welcome to WebStorm»

В результате откроется окно «New Project», изображенное на рисунке 2.

WebStorm. JavaScript. Окно выбора типа нового проекта

Рисунок 2. Окно выбора типа нового проекта

Система предлагает следующие виды проектов:

  • Empty Project – пустой проект;
  • Angular CLI – реализация проекта на основе npm-модуля (JavaScript Package Manager), предназначенного для регистрации программного обеспечения из терминала. Более подробно о npm-модуле можно прочитать по следующей ссылке;
  • AngularJS – проект с использованием фреймворка с открытым кодом AngularJS;
  • Bootstrap – проект на основе открытого бесплатного фреймворка Bootstrap, используемого для front-end разработки сайтов. Получить подробную информацию о данном фреймворке можно по ссылке;
  • Cordova App – приложение с использованием платформы для разработки мобильных приложений Apache Cordova;
  • HTML5 Boilerplate – проект, основанный на использовании популяного шаблона с поддержкой HTML/CSS/JavaScript;
  • Meteor App – проект на основе веб-фреймворка MeteorJS, использующего Node.js;
  • Node.js, Node.js Express – проекты на основе платформы с открытым кодом Node.js, предназначенной для создания сетевых приложений. Подробную информацию о фреймворках можно получить здесь и здесь;
  • React App – проект на основе JavaScript-библиотеки React с открытым программным кодом, которая используется для создания пользовательских интерфейсов в больших веб-приложениях, использующих данные, изменяющиеся со временем без перезагрузки страницы;
  • React Native – проект на основе React-архитектуры, предложенной компанией Facebook. Подробную документацию об архитектуре React Native можно получить здесь;
  • Vue.js – проект на основе JavaScript фреймворка с открытым кодом Vue.js, который используется для создания пользовательских интерфейсов. Подробную информацию о данном фреймворке можно получить здесь;
  • Yeoman – проект на основе набора приложений Yeoman (Yo), позволяющий генерировать (создавать) проекты на любом языке, в том числе и JavaScript. Более подробно о Yeoman можно прочитать здесь.

 

2.2. Создание пустого проекта

В нашем случае, пока не нужно использовать дополнительные библиотеки или фреймворки. Поэтому при создании проекта нужно выбрать Empty Project (рисунок 3).

В поле Location: устанавливается папка, в которой будут размещаться файлы проекта. В нашем случае устанавливается папка

D:ProjectsWebStormHello

По желанию можно установить другую папку.

WebStorm. JavaScript. Выбор пустого проекта в окне New Project

Рисунок 3. Выбор пустого проекта в окне New Project

После выбора кнопки Create откроется окно среды WebStorm, как показано на рисунке 4.

Окно интегрированной среды WebStorm

Рисунок 4. Окно интегрированной среды WebStorm

 

2.3. Создание HTML-файла

Чтобы запустить и протестировать код программы на JavaScript, сначала необходимо создать файл на языке HTML, который будет запускаться в некотором браузере. Для создания HTML-файла можно использовать один из двух способов:

  • кликнуть правой кнопкой мышки на имени проекта Hello и в контекстном меню выбрать последовательность команд New->HTML File (рисунок 5);
  • в главном меню выбрать последовательность команд File->New->HTML File (рисунок 6).

WebStorm. JavaScript. Команда контекстного меню New->HTML File

Рисунок 5. Команда контекстного меню New->HTML File

WebStorm. Выбор команды создания HTML-документа из главного меню

Рисунок 6. Выбор команды создания HTML-документа из главного меню

После вызова команды создания HTML-файла откроется окно New HTML File (рисунок 7), в котором нужно задать имя main (или другое) и выбрать версию языка HTML 5 file. Свой выбор необходимо зафиксировать нажатием клавиши Enter.

WebStorm. JavaScript. Указание имени HTML-файла и версии HTML 5

Рисунок 7. Указание имени HTML-файла и версии HTML 5

В результате, в главном окне среды WebStorm будет создана заготовка на языке HTML, который имеет следующий текст

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>

</body>
</html>

На рисунке 8 изображен вид окна среды WebStorm после добавления HTML-файла.

WebStorm. JavaScript. Проект Hello после добавления файлаРисунок 8. Проект Hello после добавления файла main.html

 

2.4. Запуск HTML-файла

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

  • щелчком мышки на кнопке с изображением веб-браузера (рисунок 9);
  • выбором последовательности команд Run->Run…->main.html (если файл main.html запускается впервые) или Run->main.html (если файл main.html запускается повторно) как показано на рисунке 10.

После вызова команды запуска файла main.html откроется один из выбраных браузеров с пустой страницей.

WebStorm. Список браузеров, которые могут выполнить HTML-файл

Рисунок 9. Список браузеров, которые могут выполнить (открыть) файл main.html

WebStorm. Запуск HTML-файла

Рисунок 10. Запуск файла main.html впервые (команда Run…) и повторно (команда Run ‘main.html’)

 

2.5. Добавление кода JavaScript

В файле main.html код программы на JavaScript вписывается между тэгами

<body>
  ...
</body>

В наиболее общем случае код может быть вписан одним из трех способов:

1. Непосредственно в html-файле. В этом случае код на JavaScript размещается между тегами

<script>
  ...
</script>

2. В файле с расширением *.js, который также подключается между тегами

<script>
  ...
</script>

3. Непосредственно в html-файле с помещением кода в атрибут события HTML-элемента.

В данной теме рассматриваются первые два случая.

 

2.5.1. Код JavaScript в тексте main.html. Вывод сообщения на странице веб-браузера с помощью метода document.write()

Вид файла main.html в случае включения в него кода JavaScript, выводящего сообщение «Hello, world!», следующий

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
  <script>
    // Текст на JavaScript
    document.write("Hello, world!")
  </script>
</body>
</html>

Здесь между тэгами <script>…</script> вписана строка

document.write("Hello, world!")

которая выводит сообщение. Теперь можно запустить файл main.html в веб-браузере и проверить работу программы.

 

2.5.2. Вывод сообщения в отдельном окне. Метод alert()

Можно вызвать окно для отображения сообщения «Hello, world!». В этом случае используется вызов метода alert(). Модифицированный фрагмент кода файла main.html имеет вид:

... 

<body>
  <script>
    // Текст на JavaScript
    // Вызов окна с сообщением
    alert("Hello, world!")
  </script>
</body>

...

 

2.5.3. Вызов кода JavaScript, который был сформирован в отдельном файле

Если размер кода JavaScript велик, то целесообразно разместить (структурировать) его в одном или нескольких отдельных файлах.

Чтобы в HTML-файле вызвать код программы на JavaScript, размещаемый в файле, нужно использовать строку вида

<script src="myfile.js"> </script>

здесь

  • myfile.js – название подключаемого файла.

 

2.5.4. Подключение файла на языке JavaScript

Процесс добавления файла на JavaScript подобен созданию HTML-файла. Также существует 2 способа подключения JavaScript-файла в проект:

  • с помощью контекстного меню (рисунок 11);
  • с помощью вызова последовательности команд File->New->JavaScript File (рисунок 12).

WebStorm. Добавление JavaScript-файла к проекту из контекстного меню

Рисунок 11. Добавление JavaScript-файла к проекту из контекстного меню

WebStorm. Команды главного меню для добавления JavaScript-файла в проект

Рисунок 12. Команды главного меню для добавления JavaScript-файла в проект

В результате откроется окно уточнения New JavaScript file, в котором нужно задать имя файла (рисунок 13) и нажать клавишу Enter. В нашем случае указывается имя hello. В результате система создаст файл с именем hello.js. Расширение *.js означает, что файл создается на языке JavaScript.

WebStorm. JavaScript. Указание имени файла hello.js

Рисунок 13. Указание имени файла hello.js

После создания файла в окне Project, отображающего структуру проекта, появится файл hello.js (рисунок 14).

WebStorm. Отображение JavaScript-файла в структуре проекта

Рисунок 14. Отображение файла hello.js в структуре проекта

Файл JavaScript добавлен, теперь можно переходить к созданию кода.

 

2.5.5. Написание кода JavaScript в файле hello.js

С помощью мыши или клавиатуры можно переключаться между файлами hello.js и main.html.

В файле hello.js можно писать программный код (скрипт) на языке JavaScript. В нашем случае вписывается строка вывода сообщения в отдельном окне:

// В файле hello.js вписывается код на JavaScript
alert("Hello, world!")

На рисунке 15 отображено окно редактора WebStorm с кодом JavaScript.

Окно редактора WebStorm с программой на JavaScript

Рисунок 15. Окно редактора WebStorm с программой на JavaScript

Для вывода сообщения в главном документе веб-браузера в файле hello.js можно использовать метод document() по следующему образцу

document.write("Hello, world!")

 

2.5.6. Подключение JavaScript кода к файлу main.html

Для того чтобы выполнять скрипт, размещаемый в файле hello.js, нужно в файле main.html вписать следующий код

<script
  src = hello.js>
</script>

Здесь имя исполняемого JavaScript-файла задается с помощью атрибута hello.js.

После корректировки, полный код файла main.html будет выглядеть так:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
  <script
    src = hello.js>
  </script>
</body>
</html>

Подобным образом в код HTML-файла можно добавлять любое количество файлов на языке JavaScript.

 

2.6. Запуск файла main.html в браузере. Просмотр результатов

После выполненных действий можно открывать файл main.html в браузере. Этот процесс уже был описан в п. 2.4. После открытия файла в некотором браузере результат выполнения программы должен быть примерно следующий

WebStorm. JavaScript. Выполнение программы

Рисунок 16. Результат выполнения программы

 


How to Develop and Deploy Your First Full-Stack Web App Using A Static Site and Node.js

This tutorial will show you how to convert a static website that uses HTML, CSS and JavaScript (JS) to a dynamic one using MongoDB, Express, Static HTML, CSS, JS, and Node.js.

Our tech stack will be similar to the popular MEAN/MERN stack (MongoDB, Express, Angular or React, and NodeJS). But instead of using Angular or React, we will use a templating engine called EJS (Embedded JavaScript.)

Other popular templating engines include Handlebars, Pug, and Nunjucks.

Afterwards, we will deploy our Node.js web app to DigitalOcean and cover domain names, SSL, reverse proxies, and process managers.

Learning a templating language can be easier than a JS framework. You can just write HTML, and it lets you insert the same piece of code in multiple locations (called partials) or pass server-side variables to be displayed on the front-end (such as a username).

Table of Contents

  • Developing Your First Node.js Web App
    • Installing Node.js
    • Testing The Install
    • Creating Your First Server
    • Next Steps
    • Templating Basics
    • Passing Server-Side Data to the Front-End
  • Deploying Your First Node.js Web App
    • Setting Up DigitalOcean
    • Connecting To Your Droplet
    • Deploying Your Node.js Web App
    • Configuring Your Domain Name
    • Removing the Port Number From Your URL
    • Running the App on Boot (Setting Up A Process Manager)

Developing Your First Node.js Web App

Installing Node.js

First, make sure you’ve installed Node.js on your local machine or VPS hosting provider. If you haven’t installed it, go to the Node.js website to do so.

With Node.js, you can write server-side code using a special form of JavaScript so you can use an already familiar language.

The Node.js installer comes bundled with the package manager NPM. NPM is a repository for Node Modules, reusable pieces of code that can extend the functionality of your server. It’s similar to a plugin repository, and Node Modules can be thought of as code snippets or libraries (depending on how large they are).

Windows Users: Need to add Node and NPM to their PATH so they can call them easily on the command line. For more in-depth instructions, see my guide here.

Testing the Install

To test that the installation has worked correctly, open a terminal window, and type node -v and npm -v. If the resulting message starts with a v and is followed by some numbers (indicating a version), then the installation has been successful. Now you’re ready to create your first server.

Creating Your First Server

Once you have created a static website, the first step in creating a Node.js app is to create an Express web server.

First, move all your website’s static files (HTML, CSS, JS, images, etc.) into a folder called public and create a file called server.js in the root directory of your website folder. In the server.js file type:

// Load Node modules
var express = require('express');
// Initialise Express
var app = express();
// Render static files
app.use(express.static('public'));
// Port website will run on
app.listen(8080);

Then in the terminal, type: npm init. Press enter to accept the default parameters for all the following options, but make sure the entry point is server.js.

Finally, type: npm start and then go to the IP Address of your VPS host, or localhost:8080/index.html (or the name of one of your webpages) in the browser. The Express server you just created should now be serving your website’s static files.

Next Steps

Moving forward, we will discuss how to convert your static files to dynamic ones using the EJS templating engine. Then we’ll look at how to copy repeated code using partials and inject server-side variables to the front-end.

Templating Basics

Installing EJS

The first step to use EJS is to install it. A simple npm install ejs --save will do the trick. The --save parameter saves the module to the package.json file.

This makes it so anyone who clones the git repo (or otherwise downloads the site’s files) can install all the required Node modules for the project (called dependencies) using the npm install command instead. Then they don’t have to type npm install (module name) for however many modules they need.

Converting Static Pages to EJS Files

Next, you need to convert your static HTML files into dynamic EJS ones and set up your folder structure in the way EJS expects.

In the root directory of your website, create a folder called views. Inside that folder create two sub-folders called pages and partials. Move all your HTML files into the pages sub-folder and rename the .html file extensions to .ejs.

Your folder structure should look similar to the picture below.

nodejs-file-structure

Reusing Code — Creating Your First EJS Partial

When creating static sites, there’s often code that you repeat on every page such as the head (where the meta tags are located), header, and footer sections.

It’s inconvenient to change them on every page (especially on larger sites) if alterations are needed. But if you use EJS partials then you won’t have to. Editing one template (partial) file will update the code on every page that the file is included in.

We’ll take a typical part of a website to be templated, the header, as an example. Create a new file called header.ejs in the partials folder. Copy and paste all the code between the <header></header> tags on one of your EJS pages into it.

Finally, on all pages with a header delete the code between the <header></header> tags (the same code you copied to the header.ejs partial file) and replace it with <% include('../partials/header') %>. Now, you’ve created your first EJS partial. Repeat the process for any other repetitive pieces of code such as the head and footer sections.

Small Tip: If you find it hard to differentiate between your pages and partials since they have the same .ejs file extension, it can be helpful to put an underscore _ in front of the names of partials (so _ header.ejs). This is a naming convention that some developers use that can be helpful.

Rendering EJS Pages

Now we get to the exciting part: making the server render the EJS pages and partials so you can see them on the front-end.

server.js Example

// Load Node modules
var express = require('express');
const ejs = require('ejs');
// Initialise Express
var app = express();
// Render static files
app.use(express.static('public'));
// Set the view engine to ejs
app.set('view engine', 'ejs');
// Port website will run on
app.listen(8080);

// *** GET Routes - display pages ***
// Root Route
app.get('/', function (req, res) {
    res.render('pages/index');
});

First, we need to add the EJS Node module to our server. So, in the server.js file (see example above), add const ejs = require('ejs');.

Second, we need to tell our Express server to use EJS so add app.set('view engine', 'ejs');.

Now, we need to configure routes. Routes tell the server what to do when a user goes to a certain URL in your website such as http://testapp.com/login.

There are two types of routes, GET and POST. GET routes display pages and POST routes upload data from the front-end to the server (usually via a form) typically before a page is rendered and the uploaded data is somehow used.

Since we only want to display our EJS pages, we will just use GET routes. Add them after the app.listen(8080) line in server.js. For the index page, the route will be:

// *** GET Routes - display pages ***
// Root Route
app.get('/', function (req, res) {
    res.render('pages/index');
});

The ‘/’ specifies the URL of the website the code will activate on, the req stands for request and res for response. So, the response returned when going to http://testapp.com is rendering (displaying to the browser) the pages/index.ejs page. Add similar routes for your other EJS pages.

Passing Server-Side Data to the Frontend

The main attraction of templating, apart from reusing code, is that you can pass server-side variables to the front-end. Either a single variable like the current user’s username, or an array, like the details of every registered user.

However, the real strength of passing server-side variables becomes apparent when using APIs or databases.

For a basic example, the below code will display «Louise» in the h2 tag of the index page:

server.js

// Route Route
app.get('/', function (req, res) {
    var name = "Louise";
    // Render index page
    res.render('pages/index', {
        // EJS variable and server-side variable
        name: name
    });
});

The first name is the name of the EJS variable (the name for displaying it on the front-end), and the second is the variable that contains the data you want to send. (They don’t have to be identical.)

index.ejs

<h2>My name is <%= name %></h2>

For a simple array, you can use this example instead, which will create a p tag for every name in the listnames variable:

server.js

// Route Route
app.get('/', function (req, res) {
    var listnames = ["Louise", "Sadie", "Erik", "Raph", "Gina"];
    // Render index page
    res.render('pages/index', {
        // EJS variable and server-side variable
        listnames: listnames
    });
});

index.ejs

<% listnames.forEach(function(name) { %>
        <p><%= name %></p>
        <% }); %>

Congratulations. You’ve finished developing your first Node.js web app. In the next part, we will see how we can make it live (deploy it) on the web so you can show it off.

Deploying Your First Node.js Web App

There are many hosting platforms you can use to deploy your Node.js web apps such as Section, Heroku, Vultr, Linode, Google Cloud Platform and Amazon Web Services.

In this walk-through, we will be using DigitalOcean to deploy our Node.js app.

Setting up DigitalOcean

First, create an account on the DigitalOcean platform. There are discount codes available to add free credit to your account such as the code available in the Github Student Developer Pack. Be aware that you can only redeem one code per account.

Second, you need to create a droplet. A droplet is a VPS (Virtual Private Server.) It’s similar to a Linux VM which is hosted on a server farm somewhere.

Once you’ve logged into your account, go to droplets under the Manage heading and click create and then droplets.

You can leave most of the settings as the default but change the plan to the basic $5 a month which contains enough resources for your app. You can scale this up later if needed.

Also, choose the datacenter closest to the target audience of your app and change the authentication to password. While password authentication is less secure (SSH Keys is recommended), it’s much easier to set up. So, for demonstration purposes, we’ll use this method.

All that’s left now is to pick a name (hostname) and click Create Droplet.

Connecting to your Droplet

Shortly afterward, you’ll receive an email containing the username and password of your droplet which you’ll use to login.

Back on the DigitalOcean website, under droplets, click the name of your newly created droplet, and then click on Console. This will open a new tab that will let you control your droplet.

Alternatively, you can use any SSH client with the IP address and user credentials contained in the email.

On your first login, since you used password authentication, it will prompt you to set a new password. A great way to generate secure passwords and store them is a password manager like LastPass.

Deploying Your Node.js Web App

First, you’ll need to copy the code for your web app to your droplet. If you’re using source control such as Git, then it’s as simple as installing git using apt-get install git -y and then using the git clone command git clone (link to your repository), adding the link to your repository at the end.

Second, you’ll need to install Node. Type:

curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt-get install -y nodejs

Third, you’ll need to navigate to the folder containing your web app. Type ls and then enter to view all the folders in your current working directory (location). This will look like the image below:

website-folders

Type cd and then the name of the folder that appears. Type ls again and you should see the files in your web app’s root directory.

Next, you’ll need to install the node modules (dependencies) for your web app. If you installed all your modules with -save at the end, which saves them to the package.json file, then just type npm install and press enter.

If not, when you run npm start an error will appear with module not found. Type npm install (module name) and press enter and then try running npm start again. Repeat the process until the error disappears.

If you need to install MongoDB (if you’ve created a MongoDB database), then follow these instructions.

Finally, type npm start to start your web app. Now that your web app is running, in a new browser tab, type the IP Address of your droplet (found in the email that DigitalOcean sent when you created the droplet) followed by a colon and the port your app runs on. For example, 167.172.54.51:8080.

If you’re using an Express web server (which if you followed my getting started with Node.js guide, you did), you’ll find the port number located in the app.listen() line inside the server.js file. For example, app.listen(8080) which is a common port used.

Congratulations, your first Node.js web app should be displayed in your web browser which is running on your DigitalOcean droplet.

Configuring Your Domain Name

You typed in an IP Address and port number to view your web app but, wouldn’t you prefer a custom domain name like yourapp.com?

Assuming you’ve already bought a domain, the first step is to add a DNS record so your domain name will resolve to the IP address of your DigitalOcean droplet. A DNS record tells your browser what to do when they load your domain. In this case, it should go to the IP address of your droplet.

If you’ve not bought a domain, domain registrars like Namecheap sell domain names and often other services such as email and static/CMS hosting, though there are benefits to going with a dedicated hosting and email provider.

Netlify offers hosting for static sites and SiteGround for CMS websites. Office365 and GSuite are the kings of custom email providers. See my guide for Setting Up a Professional Email to read a comparison of Office365 and GSuite.

advanced-dns

Login to your domain registrar and go to the advanced DNS settings of your domain. For example, on Namecheap, it’s the Advanced DNS tab on the Manage Domain screen.

dns-records

You want to add a new record as follows: the type should be set to A, the host should be either @ or blank (depending on your provider), and the value should be the IP Address of your droplet. Repeat the process for the host www which will do the same for the www version of your domain.

dns-check

It can take up to 24-48hrs for the changes to process, but it’s usually between 15 minutes to an hour. A quick way to check when it’s done is to go to DNSChecker. Type in your domain name and make sure the type is set to A. When the result comes back as the IP Address of your droplet, then you’ve connected your domain successfully.

The final test is to type your domain name followed by a colon and then the port number (e.g. yourdomain.com:8080). You should now see your web app loading.

Removing the Port Number from your URL

Now that you’ve got a cool domain name hooked up to your web app, you’ll probably want to remove that pesky port number.

We can do this by setting up what’s called a reverse proxy. A reverse proxy will tell your droplet when a user goes to yourdomain.com, it should serve the site at yourdomain.com:8080. We will use the popular reverse proxy Nginx to do so.

The first step is to install Nginx. Type the following to update your package list (so you can get the latest version) and install Nginx:

sudo apt-get update
sudo apt-get install nginx

Since DigitalOcean droplets are created with a firewall enabled, you’ll have to allow Nginx through it so it can work properly. sudo ufw allow 'Nginx Full' will do this.

To check the installation has gone smoothly, go to the http version of your domain name e.g. http://yourdomain.com. If you see a Welcome to Nginx landing page, then it’s been successful.

The second step is to secure your reverse proxy. Currently going to https://yourdomain.com won’t work. That’s because we haven’t configured SSL yet, and we need to install a package called Certbot to do so.

To install Certbot, type the following to ensure you get the latest version:

sudo add-apt-repository ppa:certbot/certbot
sudo apt-get install python-certbot-nginx

Next, you need to add your domain to Nginx so Certbot can generate a certificate to the correct domain. Open the configuration file using sudo nano /etc/nginx/sites-available/default and replace the underscores in the server_name line to your domain. For example, server_name yourdomain.com www.yourdomain.com;. Save the file and exit by typing CTRL+x, y and then enter.

To test that there are no errors in the file, type sudo nginx -t and if there’s none, type sudo systemctl reload nginx to reload Nginx so it will use the updated configuration.

Now we just need to generate the SSL certificate. sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com will start the process. You should choose option 2 for the redirect process because it will forward anyone trying to access the insecure version of your site (http) to the secure (https) version instead.

To test this, go to https://yourdomain.com and you should see the Nginx Welcome screen again.

Finally, we’re onto the last step, adding the Nginx configuration for your web app. For demonstration purposes, we’ll just modify the default one instead of creating a new one specifically for your web app. If you need to host several web apps on one droplet, you’d need to add a new configuration for each site.

Type: sudo nano /etc/nginx/sites-available/default to edit the default configuration file.

You need to change the server_name parameter to the name of your domain. For example: yourdomain.com. Under location /, proxy_pass should be changed to http://localhost:(port name). The ssl_certificate_key should be modified: /etc/letsencrypt/live/(domain name)/privkey.pem. Finally, add the code block below to the end of the file and then type CTRL+X, and then y to exit.

server {
    if ($host = auroraspotter.space) {
        return 301 https://$host$request_uri;
    } # managed by Certbot
        listen 80 default_server;
        listen [::]:80 default_server;
        server_name auroraspotter.space;
    return 404; # managed by Certbot

Here’s a complete example of what it should look like. Note: the server_name should be the name of your domain.

server {
        root /var/www/html;      
        index index.html index.htm index.nginx-debian.html;
        server_name auroraspotter.space;
         
location / {
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header X-NginX-Proxy true;
       proxy_pass http://localhost:8080;
       proxy_set_header Host $http_host;
       proxy_cache_bypass $http_upgrade;
       proxy_redirect off;
 }
    listen [::]:443 ssl ipv6only=on; # managed by Certbot
    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/auroraspotter.space/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/auroraspotter.space/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
    if ($host = auroraspotter.space) {
        return 301 https://$host$request_uri;
    } # managed by Certbot
    
        listen 80 default_server;
        listen [::]:80 default_server;
        
        server_name auroraspotter.space;
    return 404; # managed by Certbot

To test that there are no errors in the file, type sudo nginx -t. If there’s none, type sudo systemctl reload nginx to reload Nginx so it will use the updated configuration.

Finally, you should be able to go to yourdomain.com and your web app will be running.

Running the App on Boot (Setting up a Process Manager)

You’ve hooked your domain name up to your droplet and configured Nginx to serve your web app, but how do you keep it running all the time especially after restarting your droplet?

That’s where a process manager comes in. It will manage your Node.js web app, log any errors, and start/stop it as needed. We will be using the process manager called PM2.

The first step is to install PM2 using sudo npm install pm2@latest -g. Next, to run it on boot, run pm2 startup systemd. It should say to setup the startup script, copy and paste the following command which will be sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u (username) --hp /home/(username).

If you’re using the default login that DigitalOcean provided, this will be root. Type this into the terminal and press enter. If it says command successfully executed (like below) then it has worked.

[ 'systemctl enable pm2-root' ]
[PM2] Writing init configuration in /etc/systemd/system/pm2-root.service
[PM2] Making script booting at startup...
[PM2] [-] Executing: systemctl enable pm2-root...
[PM2] [v] Command successfully executed.

Using the cd command, navigate to the folder of your web app. Then type pm2 start server.js. This will start the web app using pm2. Afterward, type pm2 save which will save it to be started on boot. If it says successfully saved, then it’s been saved correctly.

[PM2] Saving current process list...
[PM2] Successfully saved in /root/.pm2/dump.pm2

Finally, type sudo systemctl start pm2-(username).

Try restarting your droplet by typing reboot and after a few minutes, go to yourdomain.com. Your web app should be up and running like normal.

If you’re looking to build on the skills you’ve learned in this tutorial, I suggest using EJS templating to work with APIs and databases.



Learn to code for free. freeCodeCamp’s open source curriculum has helped more than 40,000 people get jobs as developers. Get started

How to Develop and Deploy Your First Full-Stack Web App Using A Static Site and Node.js

This tutorial will show you how to convert a static website that uses HTML, CSS and JavaScript (JS) to a dynamic one using MongoDB, Express, Static HTML, CSS, JS, and Node.js.

Our tech stack will be similar to the popular MEAN/MERN stack (MongoDB, Express, Angular or React, and NodeJS). But instead of using Angular or React, we will use a templating engine called EJS (Embedded JavaScript.)

Other popular templating engines include Handlebars, Pug, and Nunjucks.

Afterwards, we will deploy our Node.js web app to DigitalOcean and cover domain names, SSL, reverse proxies, and process managers.

Learning a templating language can be easier than a JS framework. You can just write HTML, and it lets you insert the same piece of code in multiple locations (called partials) or pass server-side variables to be displayed on the front-end (such as a username).

Table of Contents

  • Developing Your First Node.js Web App
    • Installing Node.js
    • Testing The Install
    • Creating Your First Server
    • Next Steps
    • Templating Basics
    • Passing Server-Side Data to the Front-End
  • Deploying Your First Node.js Web App
    • Setting Up DigitalOcean
    • Connecting To Your Droplet
    • Deploying Your Node.js Web App
    • Configuring Your Domain Name
    • Removing the Port Number From Your URL
    • Running the App on Boot (Setting Up A Process Manager)

Developing Your First Node.js Web App

Installing Node.js

First, make sure you’ve installed Node.js on your local machine or VPS hosting provider. If you haven’t installed it, go to the Node.js website to do so.

With Node.js, you can write server-side code using a special form of JavaScript so you can use an already familiar language.

The Node.js installer comes bundled with the package manager NPM. NPM is a repository for Node Modules, reusable pieces of code that can extend the functionality of your server. It’s similar to a plugin repository, and Node Modules can be thought of as code snippets or libraries (depending on how large they are).

Windows Users: Need to add Node and NPM to their PATH so they can call them easily on the command line. For more in-depth instructions, see my guide here.

Testing the Install

To test that the installation has worked correctly, open a terminal window, and type node -v and npm -v. If the resulting message starts with a v and is followed by some numbers (indicating a version), then the installation has been successful. Now you’re ready to create your first server.

Creating Your First Server

Once you have created a static website, the first step in creating a Node.js app is to create an Express web server.

First, move all your website’s static files (HTML, CSS, JS, images, etc.) into a folder called public and create a file called server.js in the root directory of your website folder. In the server.js file type:

// Load Node modules
var express = require('express');
// Initialise Express
var app = express();
// Render static files
app.use(express.static('public'));
// Port website will run on
app.listen(8080);

Then in the terminal, type: npm init. Press enter to accept the default parameters for all the following options, but make sure the entry point is server.js.

Finally, type: npm start and then go to the IP Address of your VPS host, or localhost:8080/index.html (or the name of one of your webpages) in the browser. The Express server you just created should now be serving your website’s static files.

Next Steps

Moving forward, we will discuss how to convert your static files to dynamic ones using the EJS templating engine. Then we’ll look at how to copy repeated code using partials and inject server-side variables to the front-end.

Templating Basics

Installing EJS

The first step to use EJS is to install it. A simple npm install ejs --save will do the trick. The --save parameter saves the module to the package.json file.

This makes it so anyone who clones the git repo (or otherwise downloads the site’s files) can install all the required Node modules for the project (called dependencies) using the npm install command instead. Then they don’t have to type npm install (module name) for however many modules they need.

Converting Static Pages to EJS Files

Next, you need to convert your static HTML files into dynamic EJS ones and set up your folder structure in the way EJS expects.

In the root directory of your website, create a folder called views. Inside that folder create two sub-folders called pages and partials. Move all your HTML files into the pages sub-folder and rename the .html file extensions to .ejs.

Your folder structure should look similar to the picture below.

nodejs-file-structure

Reusing Code — Creating Your First EJS Partial

When creating static sites, there’s often code that you repeat on every page such as the head (where the meta tags are located), header, and footer sections.

It’s inconvenient to change them on every page (especially on larger sites) if alterations are needed. But if you use EJS partials then you won’t have to. Editing one template (partial) file will update the code on every page that the file is included in.

We’ll take a typical part of a website to be templated, the header, as an example. Create a new file called header.ejs in the partials folder. Copy and paste all the code between the <header></header> tags on one of your EJS pages into it.

Finally, on all pages with a header delete the code between the <header></header> tags (the same code you copied to the header.ejs partial file) and replace it with <% include('../partials/header') %>. Now, you’ve created your first EJS partial. Repeat the process for any other repetitive pieces of code such as the head and footer sections.

Small Tip: If you find it hard to differentiate between your pages and partials since they have the same .ejs file extension, it can be helpful to put an underscore _ in front of the names of partials (so _ header.ejs). This is a naming convention that some developers use that can be helpful.

Rendering EJS Pages

Now we get to the exciting part: making the server render the EJS pages and partials so you can see them on the front-end.

server.js Example

// Load Node modules
var express = require('express');
const ejs = require('ejs');
// Initialise Express
var app = express();
// Render static files
app.use(express.static('public'));
// Set the view engine to ejs
app.set('view engine', 'ejs');
// Port website will run on
app.listen(8080);

// *** GET Routes - display pages ***
// Root Route
app.get('/', function (req, res) {
    res.render('pages/index');
});

First, we need to add the EJS Node module to our server. So, in the server.js file (see example above), add const ejs = require('ejs');.

Second, we need to tell our Express server to use EJS so add app.set('view engine', 'ejs');.

Now, we need to configure routes. Routes tell the server what to do when a user goes to a certain URL in your website such as http://testapp.com/login.

There are two types of routes, GET and POST. GET routes display pages and POST routes upload data from the front-end to the server (usually via a form) typically before a page is rendered and the uploaded data is somehow used.

Since we only want to display our EJS pages, we will just use GET routes. Add them after the app.listen(8080) line in server.js. For the index page, the route will be:

// *** GET Routes - display pages ***
// Root Route
app.get('/', function (req, res) {
    res.render('pages/index');
});

The ‘/’ specifies the URL of the website the code will activate on, the req stands for request and res for response. So, the response returned when going to http://testapp.com is rendering (displaying to the browser) the pages/index.ejs page. Add similar routes for your other EJS pages.

Passing Server-Side Data to the Frontend

The main attraction of templating, apart from reusing code, is that you can pass server-side variables to the front-end. Either a single variable like the current user’s username, or an array, like the details of every registered user.

However, the real strength of passing server-side variables becomes apparent when using APIs or databases.

For a basic example, the below code will display «Louise» in the h2 tag of the index page:

server.js

// Route Route
app.get('/', function (req, res) {
    var name = "Louise";
    // Render index page
    res.render('pages/index', {
        // EJS variable and server-side variable
        name: name
    });
});

The first name is the name of the EJS variable (the name for displaying it on the front-end), and the second is the variable that contains the data you want to send. (They don’t have to be identical.)

index.ejs

<h2>My name is <%= name %></h2>

For a simple array, you can use this example instead, which will create a p tag for every name in the listnames variable:

server.js

// Route Route
app.get('/', function (req, res) {
    var listnames = ["Louise", "Sadie", "Erik", "Raph", "Gina"];
    // Render index page
    res.render('pages/index', {
        // EJS variable and server-side variable
        listnames: listnames
    });
});

index.ejs

<% listnames.forEach(function(name) { %>
        <p><%= name %></p>
        <% }); %>

Congratulations. You’ve finished developing your first Node.js web app. In the next part, we will see how we can make it live (deploy it) on the web so you can show it off.

Deploying Your First Node.js Web App

There are many hosting platforms you can use to deploy your Node.js web apps such as Section, Heroku, Vultr, Linode, Google Cloud Platform and Amazon Web Services.

In this walk-through, we will be using DigitalOcean to deploy our Node.js app.

Setting up DigitalOcean

First, create an account on the DigitalOcean platform. There are discount codes available to add free credit to your account such as the code available in the Github Student Developer Pack. Be aware that you can only redeem one code per account.

Second, you need to create a droplet. A droplet is a VPS (Virtual Private Server.) It’s similar to a Linux VM which is hosted on a server farm somewhere.

Once you’ve logged into your account, go to droplets under the Manage heading and click create and then droplets.

You can leave most of the settings as the default but change the plan to the basic $5 a month which contains enough resources for your app. You can scale this up later if needed.

Also, choose the datacenter closest to the target audience of your app and change the authentication to password. While password authentication is less secure (SSH Keys is recommended), it’s much easier to set up. So, for demonstration purposes, we’ll use this method.

All that’s left now is to pick a name (hostname) and click Create Droplet.

Connecting to your Droplet

Shortly afterward, you’ll receive an email containing the username and password of your droplet which you’ll use to login.

Back on the DigitalOcean website, under droplets, click the name of your newly created droplet, and then click on Console. This will open a new tab that will let you control your droplet.

Alternatively, you can use any SSH client with the IP address and user credentials contained in the email.

On your first login, since you used password authentication, it will prompt you to set a new password. A great way to generate secure passwords and store them is a password manager like LastPass.

Deploying Your Node.js Web App

First, you’ll need to copy the code for your web app to your droplet. If you’re using source control such as Git, then it’s as simple as installing git using apt-get install git -y and then using the git clone command git clone (link to your repository), adding the link to your repository at the end.

Second, you’ll need to install Node. Type:

curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt-get install -y nodejs

Third, you’ll need to navigate to the folder containing your web app. Type ls and then enter to view all the folders in your current working directory (location). This will look like the image below:

website-folders

Type cd and then the name of the folder that appears. Type ls again and you should see the files in your web app’s root directory.

Next, you’ll need to install the node modules (dependencies) for your web app. If you installed all your modules with -save at the end, which saves them to the package.json file, then just type npm install and press enter.

If not, when you run npm start an error will appear with module not found. Type npm install (module name) and press enter and then try running npm start again. Repeat the process until the error disappears.

If you need to install MongoDB (if you’ve created a MongoDB database), then follow these instructions.

Finally, type npm start to start your web app. Now that your web app is running, in a new browser tab, type the IP Address of your droplet (found in the email that DigitalOcean sent when you created the droplet) followed by a colon and the port your app runs on. For example, 167.172.54.51:8080.

If you’re using an Express web server (which if you followed my getting started with Node.js guide, you did), you’ll find the port number located in the app.listen() line inside the server.js file. For example, app.listen(8080) which is a common port used.

Congratulations, your first Node.js web app should be displayed in your web browser which is running on your DigitalOcean droplet.

Configuring Your Domain Name

You typed in an IP Address and port number to view your web app but, wouldn’t you prefer a custom domain name like yourapp.com?

Assuming you’ve already bought a domain, the first step is to add a DNS record so your domain name will resolve to the IP address of your DigitalOcean droplet. A DNS record tells your browser what to do when they load your domain. In this case, it should go to the IP address of your droplet.

If you’ve not bought a domain, domain registrars like Namecheap sell domain names and often other services such as email and static/CMS hosting, though there are benefits to going with a dedicated hosting and email provider.

Netlify offers hosting for static sites and SiteGround for CMS websites. Office365 and GSuite are the kings of custom email providers. See my guide for Setting Up a Professional Email to read a comparison of Office365 and GSuite.

advanced-dns

Login to your domain registrar and go to the advanced DNS settings of your domain. For example, on Namecheap, it’s the Advanced DNS tab on the Manage Domain screen.

dns-records

You want to add a new record as follows: the type should be set to A, the host should be either @ or blank (depending on your provider), and the value should be the IP Address of your droplet. Repeat the process for the host www which will do the same for the www version of your domain.

dns-check

It can take up to 24-48hrs for the changes to process, but it’s usually between 15 minutes to an hour. A quick way to check when it’s done is to go to DNSChecker. Type in your domain name and make sure the type is set to A. When the result comes back as the IP Address of your droplet, then you’ve connected your domain successfully.

The final test is to type your domain name followed by a colon and then the port number (e.g. yourdomain.com:8080). You should now see your web app loading.

Removing the Port Number from your URL

Now that you’ve got a cool domain name hooked up to your web app, you’ll probably want to remove that pesky port number.

We can do this by setting up what’s called a reverse proxy. A reverse proxy will tell your droplet when a user goes to yourdomain.com, it should serve the site at yourdomain.com:8080. We will use the popular reverse proxy Nginx to do so.

The first step is to install Nginx. Type the following to update your package list (so you can get the latest version) and install Nginx:

sudo apt-get update
sudo apt-get install nginx

Since DigitalOcean droplets are created with a firewall enabled, you’ll have to allow Nginx through it so it can work properly. sudo ufw allow 'Nginx Full' will do this.

To check the installation has gone smoothly, go to the http version of your domain name e.g. http://yourdomain.com. If you see a Welcome to Nginx landing page, then it’s been successful.

The second step is to secure your reverse proxy. Currently going to https://yourdomain.com won’t work. That’s because we haven’t configured SSL yet, and we need to install a package called Certbot to do so.

To install Certbot, type the following to ensure you get the latest version:

sudo add-apt-repository ppa:certbot/certbot
sudo apt-get install python-certbot-nginx

Next, you need to add your domain to Nginx so Certbot can generate a certificate to the correct domain. Open the configuration file using sudo nano /etc/nginx/sites-available/default and replace the underscores in the server_name line to your domain. For example, server_name yourdomain.com www.yourdomain.com;. Save the file and exit by typing CTRL+x, y and then enter.

To test that there are no errors in the file, type sudo nginx -t and if there’s none, type sudo systemctl reload nginx to reload Nginx so it will use the updated configuration.

Now we just need to generate the SSL certificate. sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com will start the process. You should choose option 2 for the redirect process because it will forward anyone trying to access the insecure version of your site (http) to the secure (https) version instead.

To test this, go to https://yourdomain.com and you should see the Nginx Welcome screen again.

Finally, we’re onto the last step, adding the Nginx configuration for your web app. For demonstration purposes, we’ll just modify the default one instead of creating a new one specifically for your web app. If you need to host several web apps on one droplet, you’d need to add a new configuration for each site.

Type: sudo nano /etc/nginx/sites-available/default to edit the default configuration file.

You need to change the server_name parameter to the name of your domain. For example: yourdomain.com. Under location /, proxy_pass should be changed to http://localhost:(port name). The ssl_certificate_key should be modified: /etc/letsencrypt/live/(domain name)/privkey.pem. Finally, add the code block below to the end of the file and then type CTRL+X, and then y to exit.

server {
    if ($host = auroraspotter.space) {
        return 301 https://$host$request_uri;
    } # managed by Certbot
        listen 80 default_server;
        listen [::]:80 default_server;
        server_name auroraspotter.space;
    return 404; # managed by Certbot

Here’s a complete example of what it should look like. Note: the server_name should be the name of your domain.

server {
        root /var/www/html;      
        index index.html index.htm index.nginx-debian.html;
        server_name auroraspotter.space;
         
location / {
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header X-NginX-Proxy true;
       proxy_pass http://localhost:8080;
       proxy_set_header Host $http_host;
       proxy_cache_bypass $http_upgrade;
       proxy_redirect off;
 }
    listen [::]:443 ssl ipv6only=on; # managed by Certbot
    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/auroraspotter.space/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/auroraspotter.space/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
    if ($host = auroraspotter.space) {
        return 301 https://$host$request_uri;
    } # managed by Certbot
    
        listen 80 default_server;
        listen [::]:80 default_server;
        
        server_name auroraspotter.space;
    return 404; # managed by Certbot

To test that there are no errors in the file, type sudo nginx -t. If there’s none, type sudo systemctl reload nginx to reload Nginx so it will use the updated configuration.

Finally, you should be able to go to yourdomain.com and your web app will be running.

Running the App on Boot (Setting up a Process Manager)

You’ve hooked your domain name up to your droplet and configured Nginx to serve your web app, but how do you keep it running all the time especially after restarting your droplet?

That’s where a process manager comes in. It will manage your Node.js web app, log any errors, and start/stop it as needed. We will be using the process manager called PM2.

The first step is to install PM2 using sudo npm install pm2@latest -g. Next, to run it on boot, run pm2 startup systemd. It should say to setup the startup script, copy and paste the following command which will be sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u (username) --hp /home/(username).

If you’re using the default login that DigitalOcean provided, this will be root. Type this into the terminal and press enter. If it says command successfully executed (like below) then it has worked.

[ 'systemctl enable pm2-root' ]
[PM2] Writing init configuration in /etc/systemd/system/pm2-root.service
[PM2] Making script booting at startup...
[PM2] [-] Executing: systemctl enable pm2-root...
[PM2] [v] Command successfully executed.

Using the cd command, navigate to the folder of your web app. Then type pm2 start server.js. This will start the web app using pm2. Afterward, type pm2 save which will save it to be started on boot. If it says successfully saved, then it’s been saved correctly.

[PM2] Saving current process list...
[PM2] Successfully saved in /root/.pm2/dump.pm2

Finally, type sudo systemctl start pm2-(username).

Try restarting your droplet by typing reboot and after a few minutes, go to yourdomain.com. Your web app should be up and running like normal.

If you’re looking to build on the skills you’ve learned in this tutorial, I suggest using EJS templating to work with APIs and databases.



Learn to code for free. freeCodeCamp’s open source curriculum has helped more than 40,000 people get jobs as developers. Get started

Понравилась статья? Поделить с друзьями:
  • Как написать веб приложение на java
  • Как написать веб интерфейс
  • Как написать веб бота
  • Как написать вдали от дома
  • Как написать вдалеке или вдалике