Node.js для фронтенд-разработчиков | В паутине

Node.js для фронтенд-разработчиков

Это руководство ориентировано на разработчиков, которые знают JavaScript, но еще не очень хорошо владеют Node.

Вам, вероятно, уже приходится работать с Node.js: npm scripts, конфигурацией webpack, задачами gulp, автоматической сборкой и запуском тестов. Несмотря на то, что для работы не требуется глубокое понимание всех этих действий, иногда они могут сбивать с толку.

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

Версия Node

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

У Node.js есть публичный график релизов, из которого видно, что нечетные версии не имеют долгосрочной поддержки. Текущая LTS-версия (long-term support) будет активно разрабатываться до апреля 2019 года, а затем поддерживаться до 31 декабря 2019 года.

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

Node.js широко применяется в современном фронтенде – трудно найти проект проект, который не использует инструменты этой платформы. Скорее всего, вы уже знакомы с nvm (node version manager), который позволяет установить несколько версий Node одновременно для разных проектов. Это полезно, ведь если разные приложения используют разные релизы, было бы сложно синхронизировать и тестировать их на одной машине. Такие инструменты существуют и для многих других языков, например, virtualenv для Python, rbenv для Ruby.

Babel не нужен

Так как вы можете выбирать любую LTS-версию Node.js, то ничего не мешает использовать ту, в которой поддерживаются практически все современные возможности языка (ES 2015), за исключением хвостовой рекурсии.

Следовательно, Babel вам нужен, только если вы застряли с очень старой версией, или используете JSX-синтаксис, или хотите применять суперновые возможности, которые еще не вошли в стандарт.

Также нет необходимости в использовании webpack или browserify, и поэтому у нас нет инструмента для перезагрузки кода – вы можете использовать nodemon для перезапуска приложения после внесения изменений.

И поскольку мы никуда не отправляем написанный код, нет необходимости его минимизировать – на один шаг меньше в рабочем процессе. Вы просто используете свой код как есть! Очень непривычно.

Стиль коллбэков

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

const fs = require('fs');

fs.readFile('myFile.js', (err, file) => {
  if (err) {
    console.error('There was an error reading file :(');
    // process - это глобальный объект в Node
    // https://nodejs.org/api/process.html#process_process_exit_code
    process.exit(1);
  }

  // какие-то действия с полученным содержимым файла
});

Вскоре выяснилось, что этот стиль крайне затрудняет написание читаемого и поддерживаемого кода, именно с ним связано callback hell. Позже появился асинхронный Promise – он был стандартизирован в ECMAScript 2015 (это глобальный объект как в браузере, так и в Node.js). Недавно ECMAScript 2017 ввела синтаксис async / await, доступный в Node.js 7.6+, который вы уже можете использовать.

С промисами мы избегаем "ада коллбэков", но появляется другая проблема: старый код и многие встроенные модули до сих пор используют обратные вызовы. Однако их можно преобразовать в обещания. Для примера давайте конвертируем метод fs.readFile:

const fs = require('fs');

function readFile(...arguments) {
  return new Promise((resolve, reject) => {
    fs.readFile(...arguments, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
}

Этот шаблон может быть легко расширен на любую функцию. Кроме того, существует специальный метод utils.promisify. Вот пример из официальной документации:

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

const stat = util.promisify(fs.stat);
stat('.').then((stats) => {
  // Какие-то манипуляции со `stats`
}).catch((error) => {
  // Обработка ошибки
});

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

Вы все еще можете столкнуться со старым кодом с обратными вызовами. Рекомендуется обернуть его с помощью utils.promisify.

Цикл событий

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

Некоторые методы JavaScript позволяют извлечь код из основной очереди и выполнить его асинхронно:

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

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

Этот метод уже существует в некоторых браузерах, но еще не стандартизирован, и его следует использовать с осторожностью. Он похож на setTimeout(0), но иногда имеет приоритет над ним. Именование здесь также не самое подходящее – мы говорим о следующей итерации цикла событий, а это не совсем immediate.

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

Давайте посмотрим на пример со всем вышеперечисленным:

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

const fs = require('fs');
console.log('beginning of the program');

const promise = new Promise(resolve => {
  // функция, переданная в конструктор Promise 
  // выполняется синхронно!
  console.log('I am in the promise function!');
  resolve('resolved message');
});

promise.then(() => {
  console.log('I am in the first resolved promise');
}).then(() => {
  console.log('I am in the second resolved promise');
});

process.nextTick(() => {
  console.log('I am in the process next tick now');
});

fs.readFile('index.html', () => {
  console.log('==================');
  setTimeout(() => {
    console.log('I am in the callback from setTimeout with 0ms delay');
  }, 0);

  setImmediate(() => {
    console.log('I am from setImmediate callback');
  });
});

setTimeout(() => {
  console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);

setImmediate(() => {
  console.log('I am from setImmediate callback');
});

Вот правильный вывод:

> node event-loop.js
beginning of the program
I am in the promise function!
I am in the process next tick now
I am in the first resolved promise
I am in the second resolved promise
I am in the callback from setTimeout with 0ms delay
I am from setImmediate callback
==================
I am from setImmediate callback
I am in the callback from setTimeout with 0ms delay

Вы можете узнать больше о цикле событий и методе process.nextTick в официальной документации Node.js.

Event Emitters

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

class EventEmitter {
  constructor() {
    this.events = {};
  }
  checkExistence(event) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
  }
  once(event, cb) {
    this.checkExistence(event);

    const cbWithRemove = (...args) => {
      cb(...args);

      this.off(event, cbWithRemove);
    };

    this.events[event].push(cbWithRemove);
  }
  on(event, cb) {
    this.checkExistence(event);

    this.events[event].push(cb);
  }
  off(event, cb) {
    this.checkExistence(event);

    this.events[event] = this.events[event].filter(
      registeredCallback => registeredCallback !== cb
    );
  }
  emit(event, ...args) {
    this.checkExistence(event);

    this.events[event].forEach(cb => cb(...args));
  }
}

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

Этот код позволяет подписаться на события, отписаться от них позже позже, а также создавать. Объект ответа, объект запроса, потоки в Node – все они фактически расширяют или реализуют EventEmitter!

Эта простая концепция реализована во многих npm-пакетах: 1, 2, 3 и ряде других.

Потоки

Потоки в Node – самая лучшая и непонятная идея.

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

function (req, res) {
  const filename = req.url.slice(1);
  fs.readFile(filename, (err, data) => {
    if (err) {
      res.statusCode = 500;
      res.end('Something went wrong');
    } else {
      res.end(data);
    }
  }); 
}

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

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

function (req, res) {
  const filename = req.url.slice(1);
  const filestream = fs.createReadStream(filename, { encoding: 'utf-8' });

  let result = '';

  filestream.on('data', chunk => {
    result += chunk;
  });

  filestream.on('end', () => {
    res.end(result);
  });

  // если файл не существует, сработает коллбэк
  filestream.on('error', () => {
    res.statusCode = 500;
    res.end('Something went wrong');
  });
}

Здесь мы создаем поток для чтения из файла. Этот поток реализует класс EventEmitter. По событию data мы получаем следующий чанк, а событие end сигнализирует, что поток закончился. Эта реализация работает так же, как и раньше – мы ждем, пока весь файл будет прочитан, а затем возвращаем егопользователю. Проблема сохранения данных в памяти никуда не делась.

Объект ответа сам является записываемым потоком, это значит, что мы можем отдавать ему информацию напрямую, не сохраняя ее:

function (req, res) {
  const filename = req.url.slice(1);
  const filestream = fs.createReadStream(filename, { encoding: 'utf-8' });

  filestream.on('data', chunk => {
    res.write(chunk);
  });

  filestream.on('end', () => {
    res.end();
  });

  filestream.on('error', () => {
    res.statusCode = 500;
    res.end('Something went wrong');
  });
}

Объект ответа – это записываемый поток, а fs.createReadStream создает читаемый поток. Они оба являются дуплексными и трансформируемыми. Узнать больше о потоках в Node вы можете здесь.

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

Осталась еще одна проблема: эти потоки имеют разные задержки. Через некоторое время поток ответа будет перегружен, так как он намного медленнее. У Node.js есть решение для этой проблемы: каждый читаемый поток имеет метод pipe, который контролирует загрузку и управляет подачей данных, приостанавливая и возобновляя ее по необходимости. Используя этот метод, можно упростить код:

function (req, res) {
  const filename = req.url.slice(1);
  const filestream = fs.createReadStream(filename, { encoding: 'utf-8' });

  filestream.pipe(res);

  filestream.on('error', () => {
    res.statusCode = 500;
    res.end('Something went wrong');
  });
}

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

Система модулей

Node.js использует модули commonjs. Каждый раз, когда вы используете require,чтобы получить какой-то модуль внутри конфигурации webpack или когда объявляете module.exports, вы фактически применяете этот механизм. Возможно, также видели что-то вроде exports.something = {}, без слова module. Давайте разберемся, как это работает.

Прежде всего, речь пойдет о модулях commonjs с обычным расширением .js, а не о .esm/.mjs-файлах (модули ECMAScript), которые позволяют использовать синтаксис import/export. Кроме того, важно понимать, что webpack и browserify (и другие инструменты сборки) используют свою собственную функцию require, которая немного отличается.

Итак, откуда мы на самом деле получаем эти "глобальные" объекты module, require и exports? Их добавляет сама платформа – вместо того, чтобы просто выполнять файл javascript, она фактически обертывает его в функцию со всеми этими переменными:

function (exports, require, module, __filename, __dirname) {
  // код модуля
}

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

node -e "console.log(require('module').wrapper)"

Эти переменные вводятся в модуль и доступны в нем как "глобальные", хотя на самом деле таковыми не являются. Обязательно взгляните на них внутри модуля и в "главном "файле – можно просто вызвать console.log(module) – и сравните.


Далее рассмотрим объект exports и тонкости работы с ним:

exports.name = 'our name'; // это работает

exports = { name: 'our name' }; // это не работает

module.exports = { name: 'our name' }; // это работает!

Приведенный выше пример может озадачить вас. exports - это аргумент, передаваемый функции. Если мы присваиваем ему новый объект, то просто переписываем эту переменную, а старая ссылка исчезает. А module.exports ссылается на тот же объект:

module.exports === exports; // true

Наконец, require – это функция, которая берет имя модуля и возвращает его объект exports. Как именно она находит модуль? Существует довольно простая последовательность действий:

  • сравнить модули ядра с указанным именем;
  • если путь начинается с ./ или. ./ , попробовать найти такой файл;
  • если файл не найден, попробовать найти каталог с таким именем с файлом index.js
  • если путь не начинается с ./ или ./, зайти в node_modules/ и проверить наличие там папки/ файла:
    • в той директории, где запустился скрипт
    • на один уровень выше, пока не найдется /node_modules

Есть и некоторые другие места поиска, которые в основном предназначены для совместимости. Также вы можете указать свой путь для поиска с помощью переменной NODE_PATH. Если вы хотите видеть точный порядок поиска модулей, просто выведите объект модуля на консоль и найдите свойство paths. Оно выглядит примерно так:

tmp node test.js
=> Module {
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/seva.zaikov/tmp/test.js',
  loaded: false,
  children: [],
  paths:
   [ '/Users/seva.zaikov/tmp/node_modules',
     '/Users/seva.zaikov/node_modules',
     '/Users/node_modules',
     '/node_modules' ] }

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

Переменные окружения

Как указано в 12-factor-app, рекомендуется хранить конфигурацию в переменных среды. Можно настроить переменные для сеанса терминала:

export MY_VARIABLE="some variable value"

Node – это кросс-платформенный движок, и в идеале ваше приложение должно запускаться на любой платформе. Примеры в статье охватывают только MacOS / Linux и не будут работать для Windows. Синтаксис переменных среды в Windows отличается, вы можете использовать что-то вроде cross-env, но имейте это ввиду и в других случаях.

Вы можете добавить эту строку в свой профиль bash/zsh, чтобы она была настроена в любой новой сессии.

Однако обычно вы просто запускаете приложение с этими переменными:

APP_DB_URI="....." SECRET_KEY="secret key value" node server.js

Доступ к ним можно получить с помощью объекта process.env:

const CONFIG = {
  db: process.env.APP_DB_URI,
  secret: process.env.SECRET_KEY
}

Все вместе

В этом примере мы создадим простой http-сервер, который вернет файл с именем, указанным в URL после символа /. Если файл не существует, вернется ошибка 404 Not Found. Если пользователь попытается использовать относительный или вложенный путь, мы отправим ему ошибку 403.

На этот раз задокументируем код как следует:

// мы подключаем только встроенные модули,
// поэтому Node.js не ищет в папках `node_modules`

// https://nodejs.org/api/http.html#http_http_createserver_options_requestlistener
const { createServer } = require("http");
const fs = require("fs");
const url = require("url");
const path = require("path");

// устанавливаем переменную окружения с именем папки с файлами
// мы можем использовать другую папку локально
const FOLDER_NAME = process.env.FOLDER_NAME;
const PORT = process.env.PORT || 8080;

const server = createServer((req, res) => {
  // req.url содержит полный адрес со строкой запроса
  // раньше мы это игнорировали, но теперь нужно убедиться
  // что мы получаем только pathname без querystring
  // https://nodejs.org/api/http.html#http_message_url
  const parsedURL = url.parse(req.url);
  // убираем первый символ `/`
  const pathname = parsedURL.pathname.slice(1);

  // чтобы вернуть ответ, вызываем `res.end()`
  // https://nodejs.org/api/http.html#http_response_end_data_encoding_callback
  //
  // > Метод response.end() должен вызываться при каждом ответе.
  // если не вызвать его, то соединение не закроется и клиент
  // будет ждать, пока не закончится тайм-аут соединения
  // 
  // по умолчанию возвращается ответ с кодом 200
  // (https://en.wikipedia.org/wiki/List_of_HTTP_status_codes)
  // если что-то пойдет не так, нужно вернуть корректный код состояния
  // используя свойство `res.statusCode = ...`:
  // https://nodejs.org/api/http.html#http_response_statuscode

  if (pathname.startsWith(".")) {
    res.statusCode = 403;
    res.end("Relative paths are not allowed");
  } else if (pathname.includes("/")) {
    res.statusCode = 403;
    res.end("Nested paths are not allowed");
  } else {
    // https://nodejs.org/en/docs/guides/working-with-different-filesystems/
    // чтобы сохранять кроссплатформенность, нельзя просто создать путь
    // нужно использовать специфичный для платформы разделитель
    // это делает метод path.join():
    // https://nodejs.org/api/path.html#path_path_join_paths
    const filePath = path.join(__dirname, FOLDER_NAME, pathname);
    const fileStream = fs.createReadStream(filePath);

    fileStream.pipe(res);

    fileStream.on("error", e => {
      // обрабатываем только несуществующие файлы, 
      // но есть много возможных кодов ошибок
      // распространенные коды вы можете найти здесь
      // https://nodejs.org/api/errors.html#errors_common_system_errors
      if (e.code === "ENOENT") {
        res.statusCode = 404;
        res.end("This file does not exist.");
      } else {
        res.statusCode = 500;
        res.end("Internal server error");
      }
    });
  }
});

server.listen(PORT, () => {
  console.log(`application is listening at the port ${PORT}`);
});

Заключение

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

Комментарии (0)

Ваш email не будет опубликован. Все поля обязательны