В предыдущей статье был рассмотрен простой проект универсального приложения на React.js, в котором используются только стандартные средства и фрагменты кода из официальной документации React.js. Но этого недостаточно для удобной разработки. Нужно сформировать окружение так, чтобы были стандартные возможности (например «горячая» перегрузка компонентов) в равной степени как для серверной, так и для клиентской части фронтенда.
Проект из предыдущей статьи построен на описании роутов в виде простого объекта:
Этот объект задает также разбиение кода на фрагменты (code splitting). Во так это сконфигурировано для клиентского webpack:
В каждый фрагмент результирующего кода включается общая точка входа
Для рабочего билда дополнительно формируется модуль с общим для всех компонгентов кодом:
Значение
Для билда серверного фронтенда используется другой файл с конфигурацией webpack:
Он значительно проще т.к. нам не нужно разбивать серверный код на фрагменты, а также обеспечивать поддержку старых версий браузеров (которые не поддерживают ES2017).
Опция
Функция определяющая каталоги не воходящие в билд:
Предполагается что все модули кроме react и redux будут написаны с учетом возможностей node.js и не будут преобразовываться в legacy JavaScript.
Теперь рассмотрим код сервера, который может работать в режиме разработчика с hot reload, и в режиме продакшна:
Если со слушателями изменения клиенской части фронтенда все понятно и хорошо описано в документации, то с серверной частью рендеринга я нашел решение в статье и немного упростил его. Суть такая, что в режиме разработчика функция рендеринга оборачивается другой функцией, которая вызывает всегда самый актуальный вариант функции рендеринга. При этом, после того как компилятор обнаруживает изменения в исходных файлах, происходит очстка кэша require и повторная загрузка скомпилирванного модуля:
Теперь при изменении исходного текста компонентов будет скомпилирована как серверная, так и клиентская часть кода, после чего компонент в браузере перегрузится. Параллельно перегрузится и код серверного рендеринга компонента.
Как это часто бывает, проделанная работа уперлась в непредвиденный момент. Code splitting это хорошо. Но как же ведет себя асинрхронно загружаемый компонент в реальной жизни? Увы, весь код р��утинга и рендеринга React.js синхронный, и на время первой загрузки компонента отображается прелоадер (его можно сделать кастомным). Но для этого ли я все начинал? Все же решение нашлось. На основании стандартного компонента Link можно создать асинхронный компонента AsyncLink:
Вобщем все достаточно гладко после этого начало работать.
https://github.com/apapacy/uni-react
apapacy@gmail.com
14 февраля 2018 года
Проект из предыдущей статьи построен на описании роутов в виде простого объекта:
// 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;
Вобщем все достаточно гладко после этого начало работать.
https://github.com/apapacy/uni-react
apapacy@gmail.com
14 февраля 2018 года
