Сборка проекта в web-разработке

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

Во-первых, начиная с macOS Mojave, Apple полностью перешла на свой фреймворк Metal 2, не только убив поддержку NVidia CUDA (до свидания MatLAB, Photoshop, несколько десятков удобный утилит и вся рабочая разработка на CUDA C/C++), но и усложнив работу с OpenGL.

Во-вторых, сейчас вся разработка интерфейсов построена на web-технологиях, и именно эта область развивается быстрее всего. Это значит, что для построения нормального переиспользуемого решения, которое не будет объявлено deprecated уже через полгода после выпуска, также стоит поддаться web тренду. Ведь даже среда разработки, которой я иногда пользуюсь для Haskell — это Visual Studio Code, построенный на основе Electron (сейчас, правда, его почти полностью вытеснил SpaceVim). На нем же работает приложение Ghost (в котором пишется этот текст), а также Slack, Skype и многие другие программы общего пользования.

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

В мире web-разработки безраздельно правит JavaScript, а в мире JavaScript связка node + npm, позволяющая использовать его не только для браузерной разработки, но и писать удобные консольные инструменты. В том числе для сборки классических браузерных проектов. 😊

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

<!doctype html>
<html>
  <head>
    <title>Пример</title>
    <!-- Подключаем файл со стилями -->
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>
  <body>
    <!-- Создаем контейнер, в котором будет происходить отрисовка -->
    <div id="container"></div>
    <!-- Подключаем ThreeJS с официального сайта -->
    <script src="https://threejs.org/build/three.js"></script>
    <!-- Подключаем файл со скриптом, который будет делать отрисовку -->
    <script type="text/javascript" src="app.js"></script>
  </body>
</html>
index.html
body {
  background-color: #000;
  margin: 0px;
  overflow: hidden;
  color: white;
  text-align: center;
}

#container {
  position: absolute;
  width: 100%;
  height: 100%;
}
style.css
var container = document.querySelector('#container');


var scene = new THREE.Scene();
scene.background = new THREE.Color('skyblue');

var fov = 35; // AKA Field of View
var aspect = container.clientWidth / container.clientHeight;
var near = 0.1; // the near clipping plane
var far = 100; // the far clipping plane

var camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 0, 10);

var geometry = new THREE.BoxBufferGeometry(2, 2, 2);
var material = new THREE.MeshBasicMaterial();
var mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

var renderer = new THREE.WebGLRenderer();

renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);

container.appendChild(renderer.domElement);
renderer.render(scene, camera);
app.js

В результате мы увидим замечательный белый квадратик (на самом деле кубик) на голубом фоне. Учиться работать с ThreeJS мы будем в следующий раз, а пока попробуем понять, что здесь не так и почему бы не идти просто путем такой разработки в трех файлах.

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

  • невозможность разбиения кода на модули;
  • зависимость от кода на удаленном сервере;
  • ограничение vanila-JS, который неравномерно развивается в разных браузерах;
  • отсутствие проверок кода и выполнения на нем каких-либо CI процедур;
  • необходимость вносить изменения сразу в несколько мест (html, css, js);
  • и многое другое...

Будем последовательно бороться с этими недугами.

Создание npm-проекта

В первую очередь создадим проект в нашей директории:

$ npm init -y
Wrote to /prjoect/path/package.json:

{
  "name": "example-project",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Теперь её содержимое выглядит так:

$ tree .
.
├── app.js
├── index.html
├── package.json
└── style.css

0 directories, 4 files

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

Подключение webpack

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

$ npm install --save-dev webpack webpack-cli
...
+ webpack-cli@3.3.7
+ webpack@4.39.3
added 453 packages from 237 contributors and audited 5286 packages in 13.776s
found 0 vulnerabilities

Теперь мы можем собирать один JS-"бандл" из нескольких модулей. Воспользуемся этим для того, чтоб подключить ThreeJS:

$ npm install --save three
...
+ three@0.108.0
added 1 package from 1 contributor and audited 5287 packages in 2.73s
found 0 vulnerabilities

Теперь мы можем добавить в наш файл app.js подключение ThreeJS:

var THREE = require('three');
...

Из index.html можно убрать строчку подключения библиотеки со стороннего CDN. Но это не единственное изменение, которое нам потребуется сделать в HTML-файле. Дело в том, что результатом "сборки" будет не модификация JS-файла и даже не добавление новых строчек в HTML — это будет создание нового JS, который будет содержать в себе весь используемый код. По умолчанию такой бандл будет лежать в dist/main.js, а потому на него и сошлемся. Итоговый вид нашего index.html будет:

<!doctype html>
<html>
  <head>
    <title>Пример</title>
    <!-- Подключаем файл со стилями -->
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>
  <body>
    <!-- Создаем контейнер, в котором будет происходить отрисовка -->
    <div id="container"></div>
    <!-- Подключаем файл со скомпилированным javascript -->
    <script type="text/javascript" src="./dist/main.js"></script>
  </body>
</html>
index.html

Как же теперь собрать наш проект? Для этого в скрипты файла package.json нужно добавить соответствующую команду:

...
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --mode production"
  },
...
package.json

Теперь проект может быть собран командой:

$ npm run build

> example-project@1.0.0 build /path/to/project
> webpack --mode production


Insufficient number of arguments or no entry found.
Alternatively, run 'webpack(-cli) --help' for usage info.

Hash: c8e9ac5d52d7c3c70555
Version: webpack 4.39.3
Time: 52ms
Built at: 04.09.2019 09:56:46

ERROR in Entry module not found: Error: Can't resolve './src' in '/path/to/project'
npm ERR! code ELIFECYCLE
npm ERR! errno 2
npm ERR! example-project@1.0.0 build: `webpack --mode production`
npm ERR! Exit status 2
npm ERR!
npm ERR! Failed at the example-project@1.0.0 build script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR!     /home/dir/.npm/_logs/2019-09-04T06_56_46_316Z-debug.log

Кажется, что-то пошло не так. Действительно, Webpack по умолчанию ищет все JS-файлы в директории src, а главный файл ожидает увидеть под именем index.js. Поможем ему:

$ mkdir src
$ mv app.js src/index.js
$ npm run build

> example-project@1.0.0 build /path/to/project
> webpack --mode production

Hash: 9a8895c35652a46d21af
Version: webpack 4.39.3
Time: 706ms
Built at: 04.09.2019 10:05:35
  Asset     Size  Chunks                    Chunk Names
main.js  587 KiB       0  [emitted]  [big]  main
Entrypoint main [big] = main.js
[0] ./src/index.js 842 bytes {0} [built]
    + 1 hidden module
...

Скорее всего мы увидим еще несколько предупреждений (они связаны с тем, чот получившийся бандл слишком большой — мы добавили в него всю библиотеку ThreeJS, а не только нужные нам функции), но мы пока их проигнорируем и насладимся результатом, открыв index.html. Браузер покажет нам ровно тот же белый квадрат на голубом фоне.

Пакуем HTML

Во время наших манипуляций с компиляцией JS-файлов нам пришлось также менять и наш HTML. В будущем таких манипуляций может быть еще много, а потому почему бы не научиться генерировать часть этого HTML автоматически? Webpack поможет нам и в этом. Но для этого нужно научиться его конфигурировать.

Вся конфигурация сборки с помощью Webpack описывается в специальном файле webpack.config.js. Создадим базовую болванку такого файла:

module.exports = {
  // Здесь указываем, какой файл считать главным.
  entry: './src/index.js',
  // Скомпилированный бандл положим под именем main.js.
  output: {
    filename: "main.js"
  },
  module: {
    rules: [
    // Здесь мы будем описывать правила трансформаций, которые будут
    // применяться к файлам различного типа.
    ]
  },
  // Здесь мы применим различные плагины, расширяющие возможности Webpack
  plugins: [
  ]
};
webpack.config.js

Запуск npm run build приведет ровно к тому же результату, поскольку мы, фактически, воспроизвели конфигурацию по умолчанию. При изменении output.filename нам опять придется менять index.html. Чтоб этого не делать мы можем воспользоваться волшебным плагином HtmlWebpackPlugin. Для начала поставим его, а также HTML-загрузчик, который нам также потребуется:

$ npm install --save-dev html-webpack-plugin html-loader
...
+ html-loader@0.5.5
+ html-webpack-plugin@3.2.0
added 63 packages from 75 contributors and audited 5407 packages in 4.757s
found 0 vulnerabilities

Как несложно догадаться, теперь нам потребуется промодифицировать конфиг Webpack`а:

// Импортируем модуль плагина в нашу конфигурацию.
var HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: './src/index.js',
  output: {
    filename: "main.js"
  },
  module: {
    rules: [
      {
        // Для всех файлов HTML
        test: /\.html$/,
        use: [
          {
            // Воспользуемся загрузчиком html-loader
            loader: "html-loader",
            // Который заодно проминимизирует наш HTML
            options: { minimize: true }
          }
        ]
      }
    ]
  },
  plugins: [
    // Скопируем наш index.html в папку dist
    new HtmlWebpackPlugin({
      template: "./index.html",
      filename: "./index.html"
    })
  ]
};
webpack.config.js

Также удалим из index.html вызов скрипта, теперь Webpack подставит его самостоятельно:

<!doctype html>
<html>
  <head>
    <title>Пример</title>
    <!-- Подключаем файл со стилями -->
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>
  <body>
    <!-- Создаем контейнер, в котором будет происходить отрисовка -->
    <div id="container"></div>
  </body>
</html>
index.html

После npm run build теперь содержимое dist будет таким:

$ tree dist
dist
├── index.html
└── main.js

0 directories, 2 files

Новый index.html теперь избавлен от комментариев и содержит вызов скрипта. А потому мы можем увидеть наш знакомый белый квадрат при его открытии (NB: это неправда!). Сам скрипт теперь можно переименовывать как угодно, всё сохранится.

Пакуем CSS

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

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

$ npm install --save-dev css-loader style-loader
...
+ style-loader@1.0.0
+ css-loader@3.2.0
added 16 packages from 47 contributors and audited 5543 packages in 3.974s
found 0 vulnerabilities

Также промодифицируем файл конфигурации Webpack. А именно, в секцию module.rules добавим следующее:

...
{
    test: /\.css$/,
    use: [
        {
            loader: "style-loader"
        },
        {
            loader: "css-loader"
        }
    ]
}
...
webpack.config.js

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

<!doctype html>
<html>
  <head>
    <title>Пример</title>
  </head>
  <body>
    <!-- Создаем контейнер, в котором будет происходить отрисовка -->
    <div id="container"></div>
  </body>
</html>
index.html
var css = require("./../style.css");
var THREE = require("three");
...
src/index.js

Теперь наш Webpack с помощью css-loader подгрузит наши стили прямо в JavaScript, и нам больше не придется ничего копировать. Команды npm install и npm run build теперь будут собирать полностью рабочий проект без необходимости что-либо доделывать руками. Это покрывает создание базового проекта web-приложения без каких-либо надстроек или дополнительных фишек типа поддержки новых версий ECMAScript. О них мы поговорим в другой раз.

Итоговый проект будет выглядеть так (автоматически скачиваемые или генерируемые файлы скрыты):

$ tree
.
├── index.html
├── package.json
├── src
│   └── index.js
├── style.css
└── webpack.config.js

1 directory, 5 files
Список файлов
{
  "name": "example",
  "version": "1.0.0",
  "description": "",
  "private": true,
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --mode production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "css-loader": "^3.2.0",
    "html-loader": "^0.5.5",
    "html-webpack-plugin": "^3.2.0",
    "style-loader": "^1.0.0",
    "webpack": "^4.40.2",
    "webpack-cli": "^3.3.9"
  },
  "dependencies": {
    "three": "^0.108.0"
  }
}
package.json
var HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: './src/index.js',
  output: {
    filename: "main.js"
  },
  module: {
    rules: [
      {
        // Для всех файлов HTML
        test: /\.html$/,
        use: [
          {
            // Воспользуемся загрузчиком html-loader
            loader: "html-loader",
            // Который заодно проминимизирует наш HTML
            options: { minimize: true }
          }
        ]
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: "style-loader"
          },
          {
            loader: "css-loader"
          }
        ]
      }
    ]
  },
  plugins: [
    // Скопируем наш index.html в папку dist
    new HtmlWebpackPlugin({
      template: "./index.html",
      filename: "./index.html"
    })
  ]
};
webpack.config.js
<!doctype html>
<html>
  <head>
    <title>Пример</title>
  </head>
  <body>
    <!-- Создаем контейнер, в котором будет происходить отрисовка -->
    <div id="container"></div>
  </body>
</html>
index.html
body {
  background-color: #000;
  margin: 0px;
  overflow: hidden;
  color: white;
  text-align: center;
}

#container {
  position: absolute;
  width: 100%;
  height: 100%;
}
style.css
var css = require("./../style.css");
var THREE = require("three");

var container = document.querySelector('#container');

var scene = new THREE.Scene();
scene.background = new THREE.Color('skyblue');

var fov = 35; // AKA Field of View
var aspect = container.clientWidth / container.clientHeight;
var near = 0.1; // the near clipping plane
var far = 100; // the far clipping plane

var camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 0, 10);

var geometry = new THREE.BoxBufferGeometry(2, 2, 2);
var material = new THREE.MeshBasicMaterial();
var mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

var renderer = new THREE.WebGLRenderer();

renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);

container.appendChild(renderer.domElement);
renderer.render(scene, camera);
src/index.js