https://habrahabr.ru/post/349136/- Разработка веб-сайтов
- ReactJS
- JavaScript
В
предыдущей статье был рассмотрен простой проект универсального приложения на React.js, в котором используются только стандартные средства и фрагменты кода из официальной документации React.js. Но этого недостаточно для удобной разработки. Нужно сформировать окружение так, чтобы были стандартные возможности (например «горячая» перегрузка компонентов) в равной степени как для серверной, так и для клиентской части фронтенда.
Проект из
предыдущей статьи построен на описании роутов в виде простого объекта:
// routes.js
module.exports = [
{
path: '/',
exact: true,
// component: Home,
componentName: 'home'
}, {
path: '/users',
exact: true,
// component: UsersList,
componentName: 'components/usersList',
}, {
path: '/users/:id',
exact: true,
// component: User,
componentName: 'components/user',
},
];
Этот объект задает также разбиение кода на фрагменты (code splitting). Во так это сконфигурировано для клиентского webpack:
const webpack = require('webpack'); //to access built-in
const HtmlWebpackPlugin = require('html-webpack-plugin'); //installed via npm
const path = require('path');
const CommonsChunkPlugin = webpack.optimize.CommonsChunkPlugin;
const nodeEnv = process.env.NODE_ENV || 'development';
const port = Number(process.env.PORT) || 3000;
const isDevelopment = nodeEnv === 'development';
const routes = require('../src/react/routes');
const hotMiddlewareScript = `webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000`;
const entry = {};
for (let i = 0; i < routes.length; i++ ) {
entry[routes[i].componentName] = [
'../src/client.js',
'../src/react/' + routes[i].componentName + '.js',
];
if (isDevelopment) {
entry[routes[i].componentName].unshift(hotMiddlewareScript);
}
}
module.exports = {
name: 'client',
target: 'web',
cache: isDevelopment,
devtool: isDevelopment ? 'cheap-module-source-map' : 'hidden-source-map',
context: __dirname,
entry,
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: isDevelopment ? '/static/' : '/static/',
filename: isDevelopment ? '[name].bundle.js': '[name].[hash].bundle.js',
chunkFilename: isDevelopment ? '[name].bundle.js': '[name].[hash].bundle.js',
},
module: {
rules: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
cacheDirectory: isDevelopment,
babelrc: false,
presets: [
'es2015',
'es2017',
'react',
'stage-0',
'stage-3'
],
plugins: [
"transform-runtime",
"syntax-dynamic-import",
].concat(isDevelopment ? [
["react-transform", {
"transforms": [{
"transform": "react-transform-hmr",
"imports": ["react"],
"locals": ["module"]
}]
}],
] : [
]
),
}
}
]
},
plugins: [
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
new webpack.NamedModulesPlugin(),
//new webpack.optimize.UglifyJsPlugin(),
function(compiler) {
this.plugin("done", function(stats) {
require("fs").writeFileSync(path.join(__dirname, "../dist", "stats.generated.js"),
'module.exports=' + JSON.stringify(stats.toJson().assetsByChunkName) + ';console.log(module.exports);\n');
});
}
].concat(isDevelopment ? [
] : [
new CommonsChunkPlugin({
name: "common",
minChunks: 2
}),
]
),
};
В каждый фрагмент результирующего кода включается общая точка входа
client.js
, основной компонент для соответсвующего имени роута, а для окружения development еще и
webpack-hot-middleware/client
.
Для рабочего билда дополнительно формируется модуль с общим для всех компонгентов кодом:
new CommonsChunkPlugin({
name: "common",
minChunks: 2
}),
Значение
minChunks
позволяет управлять рамером фрагментов. При значении 2 любой участок одинакового кода, который используется в двух фрагментах будет перемещен в файл с именем
common.bundle.js
. Увеличение значения позволяет уменьшить размер модуля
common.bundle.js
. И увеличивает размер других фрагментов.
Для билда серверного фронтенда используется другой файл с конфигурацией webpack:
const webpack = require('webpack');
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const externalFolder = new RegExp(`^${path.resolve(__dirname, '../src')}/(react|redux)/.*$`)
const nodeEnv = process.env.NODE_ENV || 'development';
const isDevelopment = nodeEnv === 'development';
module.exports = {
name: 'server',
devtool: isDevelopment ? 'eval' : false,
entry: './src/render.js',
target: 'node',
bail: !isDevelopment,
externals: [
nodeExternals(),
function(context, request, callback) {
if (request == module.exports.entry
|| externalFolder.test(path.resolve(context, request))){
return callback();
}
return callback(null, 'commonjs2 ' + request);
}
],
output: {
path: path.resolve(__dirname, '../src'),
filename: 'render.bundle.js',
libraryTarget: 'commonjs2',
},
module: {
rules: [{
test: /\.jsx?$/,
exclude: [/node_modules/],
use: "babel-loader?retainLines=true"
}]
}
};
Он значительно проще т.к. нам не нужно разбивать серверный код на фрагменты, а также обеспечивать поддержку старых версий браузеров (которые не поддерживают ES2017).
Опция
devtool: 'eval'
для режима разработчика показывает в сообщении об ошибке реальный файл и номер строки исходного кода.
Функция определяющая каталоги не воходящие в билд:
const externalFolder = new RegExp(`^${path.resolve(__dirname, '../src')}/(react|redux)/.*$`);
...
function(context, request, callback) {
if (request == module.exports.entry
|| externalFolder.test(path.resolve(context, request))){
return callback();
}
return callback(null, 'commonjs2 ' + request);
}
Предполагается что все модули кроме react и redux будут написаны с учетом возможностей node.js и не будут преобразовываться в legacy JavaScript.
Теперь рассмотрим код сервера, который может работать в режиме разработчика с hot reload, и в режиме продакшна:
'use strict';
const path = require('path');
const createServer = require('http').createServer;
const express = require('express');
const port = Number(process.env.PORT) || 3000;
const api = require('./src/api/routes');
const app = express();
const serverPath = path.resolve(__dirname, './src/render.bundle.js');
let render = require(serverPath);
let serverCompiler
const nodeEnv = process.env.NODE_ENV || 'development';
const isDevelopment = nodeEnv === 'development';
app.set('env', nodeEnv);
if (isDevelopment) {
const webpack = require('webpack');
serverCompiler = webpack([require('./webpack/config.server')]);
const webpackClientConfig = require('./webpack/config.client');
const webpackClientDevMiddleware = require('webpack-dev-middleware');
const webpackClientHotMiddleware = require('webpack-hot-middleware');
const clientCompiler = webpack(webpackClientConfig);
app.use(webpackClientDevMiddleware(clientCompiler, {
publicPath: webpackClientConfig.output.publicPath,
headers: {'Access-Control-Allow-Origin': '*'},
stats: {colors: true},
historyApiFallback: true,
}));
app.use(webpackClientHotMiddleware(clientCompiler, {
log: console.log,
path: '/__webpack_hmr',
heartbeat: 10 * 1000
}));
app.use('/static', express.static('dist'));
app.use('/api', api);
app.use('/', (req, res, next) => render(req, res, next));
} else {
app.use('/static', express.static('dist'));
app.use('/api', api);
app.use('/', render);
}
app.listen(port, () => {
console.log(`Listening at ${port}`);
});
if (isDevelopment) {
const clearCache = () => {
const cacheIds = Object.keys(require.cache);
for (let id of cacheIds) {
if (id === serverPath) {
delete require.cache[id];
return;
}
}
}
const watch = () => {
const compilerOptions = {
aggregateTimeout: 300,
poll: 150,
};
serverCompiler.watch(compilerOptions, onServerChange);
function onServerChange(err, stats) {
if (err || stats.compilation && stats.compilation.errors && stats.compilation.errors.length) {
console.log('Server bundling error:', err || stats.compilation.errors);
}
clearCache();
try {
render = require(serverPath);
} catch (ex) {
console.log('Error detecded', ex)
}
return;
}
}
watch();
}
Если со слушателями изменения клиенской части фронтенда все понятно и хорошо описано в документации, то с серверной частью рендеринга я нашел решение
в статье и немного упростил его. Суть такая, что в режиме разработчика функция рендеринга оборачивается другой функцией, которая вызывает всегда самый актуальный вариант функции рендеринга. При этом, после того как компилятор обнаруживает изменения в исходных файлах, происходит очстка кэша require и повторная загрузка скомпилирванного модуля:
clearCache();
try {
render = require(serverPath);
} catch (ex) {
console.log('Error detecded', ex)
}
Теперь при изменении исходного текста компонентов будет скомпилирована как серверная, так и клиентская часть кода, после чего компонент в браузере перегрузится. Параллельно перегрузится и код серверного рендеринга компонента.
Как это часто бывает, проделанная работа уперлась в непредвиденный момент. Code splitting это хорошо. Но как же ведет себя асинрхронно загружаемый компонент в реальной жизни? Увы, весь код роутинга и рендеринга React.js синхронный, и на время первой загрузки компонента отображается прелоадер (его можно сделать кастомным). Но для этого ли я все начинал? Все же решение нашлось. На основании стандартного компонента Link можно создать асинхронный компонента AsyncLink:
import React from "react";
import PropTypes from "prop-types";
import invariant from "invariant";
import { Link, matchPath } from 'react-router-dom';
import routes from './routes';
const isModifiedEvent = event =>
!!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
class AsyncLink extends Link {
handleClick = (event) => {
if (this.props.onClick) this.props.onClick(event);
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
!this.props.target && // let browser handle "target=_blank" etc.
!isModifiedEvent(event) // ignore clicks with modifier keys
) {
event.preventDefault();
const { history } = this.context.router;
const { replace, to } = this.props;
function locate() {
if (replace) {
history.replace(to);
} else {
history.push(to);
}
}
if (this.context.router.history.location.pathname) {
const route = routes.find((route) => matchPath(this.props.to, route) ? route : null);
if (route) {
import(`${String('./' + route.componentName)}`).then(function() {locate();})
} else {
locate();
}
} else {
locate();
}
}
};
}
export default AsyncLink;
Вобщем все достаточно гладко после этого начало работать.
apapacy@gmail.com
14 февраля 2018 года