feat: code split & use react helmet

This commit is contained in:
endiliey 2018-08-23 21:46:14 +08:00
parent bf1e30dc52
commit 406106b67e
19 changed files with 241 additions and 146 deletions

View file

@ -1,3 +1,4 @@
generated
__fixtures__
dist
website

View file

@ -4,7 +4,8 @@ const chalk = require('chalk');
const fs = require('fs-extra');
const globby = require('globby');
const load = require('../load');
const createProdConfig = require('../webpack/prod');
const createServerConfig = require('../webpack/server');
const createClientConfig = require('../webpack/client');
function compile(config) {
return new Promise((resolve, reject) => {
@ -35,11 +36,14 @@ module.exports = async function build(siteDir, cliOptions = {}) {
const props = await load(siteDir);
// create compiler from generated webpack config
const config = createProdConfig(props).toConfig();
const serverConfig = createServerConfig(props).toConfig();
const clientConfig = createClientConfig(props).toConfig();
// compile!
await compile(config);
// we build the client bundles first
await compile(clientConfig);
// then we build the server bundles (render the static HTML and pick client bundle)
await compile(serverConfig);
// copy static files
const {outDir} = props;

View file

@ -10,8 +10,9 @@ const serveStatic = require('koa-static');
const history = require('connect-history-api-fallback');
const portfinder = require('portfinder');
const serve = require('webpack-serve');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const load = require('../load');
const createDevConfig = require('../webpack/dev');
const createClientConfig = require('../webpack/client');
async function getPort(reqPort) {
portfinder.basePort = parseInt(reqPort, 10) || 3000;
@ -52,7 +53,18 @@ module.exports = async function start(siteDir, cliOptions = {}) {
const {baseUrl} = props;
// create compiler from generated webpack config
const config = createDevConfig(props).toConfig();
let config = createClientConfig(props);
const {siteConfig} = props;
config.plugin('html-webpack-plugin').use(HtmlWebpackPlugin, [
{
hash: true,
template: path.resolve(__dirname, '../core/devTemplate.ejs'),
filename: 'index.html',
title: siteConfig.title
}
]);
config = config.toConfig();
const compiler = webpack(config);
// webpack-serve

19
lib/core/clientEntry.js Normal file
View file

@ -0,0 +1,19 @@
import React from 'react';
import {BrowserRouter} from 'react-router-dom';
import ReactDOM from 'react-dom';
import App from './App';
import prerender from './prerender';
import routes from '@generated/routes'; // eslint-disable-line
// Client side render (e.g: running in browser) to become single-page application (SPA)
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
prerender(routes, window.location.pathname).then(() => {
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('app')
);
});
}

View file

@ -1,12 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {BrowserRouter} from 'react-router-dom';
import App from './App';
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('app')
);

18
lib/core/prerender.js Normal file
View file

@ -0,0 +1,18 @@
import {matchRoutes} from 'react-router-config';
/**
* This helps us to make sure all the async component for that particular route
* is loaded before rendering. This is to avoid loading screens on first page load
*/
export default function prerender(routeConfig, providedLocation) {
const matches = matchRoutes(routeConfig, providedLocation);
return Promise.all(
matches.map(match => {
const {component} = match.route;
if (component && component.preload) {
return component.preload();
}
return undefined;
})
);
}

View file

@ -1,35 +0,0 @@
import React from 'react';
import {BrowserRouter, StaticRouter} from 'react-router-dom';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';
import App from './App';
// Client side render (e.g: running in browser) to become single-page application (SPA)
if (typeof document !== 'undefined') {
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('app')
);
}
// Renderer for static-site-generator-webpack-plugin (async rendering via callbacks)
export default function render(locals, callback) {
const context = {};
const body = ReactDOMServer.renderToString(
<StaticRouter location={locals.path} context={context}>
<App />
</StaticRouter>
);
// Build HTML template
const assets = Object.keys(locals.webpackStats.compilation.assets);
const css = assets.filter(value => value.match(/\.css$/));
const js = assets.filter(value => value.match(/\.js$/));
const {title, baseUrl, lang = 'en', template} = locals;
const html = template({body, baseUrl, css, js, title, lang});
callback(null, html);
}

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="<%- lang%>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%- title %></title>
<% css.forEach(function(file){ %>
<link href="<%-baseUrl %><%- file %>" rel="stylesheet">
<% }); %>
</head>
<body>
<div id="app"><%- body %></div>
<% js.forEach(function(file){ %>
<script src="<%-baseUrl %><%- file %>"></script>
<% }); %>
</body>
</html>

59
lib/core/serverEntry.js Normal file
View file

@ -0,0 +1,59 @@
import React from 'react';
import {StaticRouter} from 'react-router-dom';
import ReactDOMServer from 'react-dom/server';
import Helmet from 'react-helmet';
import App from './App';
import prerender from './prerender';
import routes from '@generated/routes'; // eslint-disable-line
import webpackClientStats from '@build/client.stats.json'; //eslint-disable-line
// Renderer for static-site-generator-webpack-plugin (async rendering via promises)
export default function render(locals) {
return prerender(routes, locals.path).then(() => {
const context = {};
const appHtml = ReactDOMServer.renderToString(
<StaticRouter location={locals.path} context={context}>
<App />
</StaticRouter>
);
const helmet = Helmet.renderStatic();
const htmlAttributes = helmet.htmlAttributes.toString();
const bodyAttributes = helmet.bodyAttributes.toString();
const metaStrings = [
helmet.title.toString(),
helmet.meta.toString(),
helmet.link.toString()
];
const metaHtml = metaStrings.filter(Boolean).join('\n ');
const assets = webpackClientStats.assetsByChunkName.main;
const jsFiles = assets.filter(value => value.match(/\.js$/));
const cssFiles = assets.filter(value => value.match(/\.css$/));
const {baseUrl} = locals;
const html = `
<!DOCTYPE html>
<html${htmlAttributes ? ` ${htmlAttributes}` : ''}>
<head>
${metaHtml}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${cssFiles.map(
cssFile =>
`<link rel="stylesheet" type="text/css" href="${baseUrl}${cssFile}" />`
)}
</head>
<body${bodyAttributes ? ` ${bodyAttributes}` : ''}>
<div id="app">${appHtml}</div>
${jsFiles.map(
jsFile =>
`<script type="text/javascript" src="${baseUrl}${jsFile}"></script>`
)}
</body>
</html>
`;
return html;
});
}

View file

@ -1,40 +1,32 @@
const {fileToComponentName} = require('./utils');
async function genRoutesConfig({docsData = [], pagesData = []}) {
function genDocsRoute({path: docsPath, source}) {
const componentName = fileToComponentName(source);
return `
{
path: ${JSON.stringify(docsPath)},
exact: true,
component: (props) => (
<Docs {...props}>
<${componentName} />
</Docs>
)
component: Loadable({
loader: () => import('@docs/${source}'),
loading: Loading,
render(loaded, props) {
let Content = loaded.default;
return <Docs {...props}><Content /></Docs>;
}
})
}`;
}
function genDocsImport({source}) {
const componentName = fileToComponentName(source);
return `import ${componentName} from '@docs/${source}';`;
}
function genPagesRoute({path: pagesPath, source}) {
const componentName = fileToComponentName(source);
return `
{
path: ${JSON.stringify(pagesPath)},
exact: true,
component: ${componentName}
component: Loadable({
loader: () => import('@pages/${source}'),
loading: Loading
})
}`;
}
function genPagesImport({source}) {
const componentName = fileToComponentName(source);
return `import ${componentName} from '@pages/${source}';`;
}
const notFoundRoute = `,
{
path: '*',
@ -43,10 +35,10 @@ async function genRoutesConfig({docsData = [], pagesData = []}) {
return (
`import React from 'react';\n` +
`import Loading from '@theme/Loading';\n` +
`import Loadable from 'react-loadable';\n` +
`import Docs from '@theme/Docs';\n` +
`import NotFound from '@theme/NotFound';\n` +
`${pagesData.map(genPagesImport).join('\n')}\n` +
`${docsData.map(genDocsImport).join('\n')}\n` +
`const routes = [${docsData.map(genDocsRoute).join(',')},${pagesData
.map(genPagesRoute)
.join(',')}${notFoundRoute}\n];\n` +

15
lib/theme/Loading.js Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
export default props => {
if (props.error) {
return (
<div>
Error!{' '}
<button type="button" onClick={props.retry}>
Retry
</button>
</div>
);
}
return <div>Loading...</div>;
};

View file

@ -4,7 +4,7 @@ const path = require('path');
const mdLoader = require.resolve('./loader/markdown');
module.exports = function createBaseConfig(props) {
module.exports = function createBaseConfig(props, isServer) {
const {
siteConfig,
outDir,
@ -34,6 +34,7 @@ module.exports = function createBaseConfig(props) {
.set('@site', siteDir)
.set('@docs', docsDir)
.set('@pages', pagesDir)
.set('@build', outDir)
.set('@generated', path.resolve(__dirname, '../core/generated'))
.set('@core', path.resolve(__dirname, '../core'))
.end();
@ -44,7 +45,8 @@ module.exports = function createBaseConfig(props) {
.loader('babel-loader')
.options({
babelrc: false,
presets: ['env', 'react']
presets: ['env', 'react'],
plugins: [isServer ? 'dynamic-import-node' : 'syntax-dynamic-import']
});
}

20
lib/webpack/client.js Normal file
View file

@ -0,0 +1,20 @@
const path = require('path');
const webpackNiceLog = require('webpack-nicelog');
const {StatsWriterPlugin} = require('webpack-stats-plugin');
const createBaseConfig = require('./base');
module.exports = function createClientConfig(props) {
const config = createBaseConfig(props);
config.entry('main').add(path.resolve(__dirname, '../core/clientEntry.js'));
// write webpack stats object to a file so we can
// programmatically refer to the correct bundle path in Node.js server.
config
.plugin('stats')
.use(StatsWriterPlugin, [{filename: 'client.stats.json'}]);
config.plugin('niceLog').use(webpackNiceLog, [{name: 'Client'}]);
return config;
};

View file

@ -1,24 +0,0 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpackNiceLog = require('webpack-nicelog');
const createBaseConfig = require('./base');
module.exports = function createDevConfig(props) {
const config = createBaseConfig(props);
config.entry('main').add(path.resolve(__dirname, '../core/devEntry.js'));
const {siteConfig} = props;
config.plugin('html-webpack-plugin').use(HtmlWebpackPlugin, [
{
inject: false,
hash: true,
template: path.resolve(__dirname, '../core/devTemplate.ejs'),
filename: 'index.html',
title: siteConfig.title
}
]);
config.plugin('niceLog').use(webpackNiceLog, [{name: 'Development'}]);
return config;
};

View file

@ -1,15 +1,16 @@
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
const staticSiteGenerator = require('static-site-generator-webpack-plugin');
const webpackNiceLog = require('webpack-nicelog');
const createBaseConfig = require('./base');
module.exports = function createProdConfig(props) {
const config = createBaseConfig(props);
const config = createBaseConfig(props, true);
config.entry('main').add(path.resolve(__dirname, '../core/prodEntry.js'));
config.output.libraryTarget('umd');
config.entry('main').add(path.resolve(__dirname, '../core/serverEntry.js'));
config.target('node');
config.output.filename('server.bundle.js').libraryTarget('commonjs2');
// Workaround for Webpack 4 Bug (https://github.com/webpack/webpack/issues/6522)
config.output.globalObject('this');
@ -18,25 +19,19 @@ module.exports = function createProdConfig(props) {
// static site generator webpack plugin
const paths = [...docsData, ...pagesData].map(data => data.path);
const template = ejs.compile(
fs.readFileSync(
path.resolve(__dirname, '../core/prodTemplate.ejs'),
'utf-8'
)
);
config.plugin('siteGenerator').use(staticSiteGenerator, [
{
entry: 'main',
locals: {
title: siteConfig.title || 'Munseo',
baseUrl: siteConfig.baseUrl,
template
baseUrl: siteConfig.baseUrl
},
paths
}
]);
// show compilation progress bar and build time
config.plugin('niceLog').use(webpackNiceLog, [{name: 'Production'}]);
config
.plugin('niceLog')
.use(webpackNiceLog, [{name: 'Server', color: 'yellow'}]);
return config;
};

View file

@ -42,6 +42,8 @@
"dependencies": {
"babel-core": "^6.26.3",
"babel-loader": "^7.1.5",
"babel-plugin-dynamic-import-node": "^2.0.0",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"chalk": "^2.4.1",
@ -49,7 +51,6 @@
"commander": "^2.16.0",
"connect-history-api-fallback": "^1.5.0",
"css-loader": "^1.0.0",
"ejs": "^2.6.1",
"front-matter": "^2.3.0",
"fs-extra": "^7.0.0",
"globby": "^8.0.1",
@ -65,6 +66,8 @@
"prismjs": "^1.15.0",
"react": "^16.4.1",
"react-dom": "^16.4.1",
"react-helmet": "^5.2.0",
"react-loadable": "^5.5.0",
"react-router-config": "^1.0.0-beta.4",
"react-router-dom": "^4.3.1",
"remarkable": "^1.7.1",
@ -74,7 +77,8 @@
"webpack": "^4.16.3",
"webpack-chain": "^4.8.0",
"webpack-nicelog": "^2.2.1",
"webpack-serve": "^2.0.2"
"webpack-serve": "^2.0.2",
"webpack-stats-plugin": "^0.2.1"
},
"engines": {
"node": ">=8"

View file

@ -1,5 +1,5 @@
import React from 'react';
import TicTacToe from './tictactoe';
import Helmet from 'react-helmet';
import {Link} from 'react-router-dom';
export default class Home extends React.Component {
@ -12,10 +12,9 @@ export default class Home extends React.Component {
));
return (
<div>
<Helmet title="Homepage" />
<h2> Available Urls </h2>
<ul>{routeLinks}</ul>
<h2> Play some TicTacToe </h2>
<TicTacToe />
</div>
);
}

View file

@ -1,4 +1,5 @@
import React from 'react';
import Helmet from 'react-helmet';
import style from './tictactoe.css';
function Square(props) {
@ -125,6 +126,7 @@ class Game extends React.Component {
return (
<div className={style.game}>
<Helmet title="Tic Tac Toe" />
<div className={style.gameBoard}>
<Board squares={current.squares} onClick={i => this.handleClick(i)} />
</div>

View file

@ -723,6 +723,13 @@ babel-plugin-check-es2015-constants@^6.22.0:
dependencies:
babel-runtime "^6.22.0"
babel-plugin-dynamic-import-node@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.0.0.tgz#d6fc3f6c5e3bdc34e49c15faca7ce069755c0a57"
dependencies:
babel-plugin-syntax-dynamic-import "^6.18.0"
object.assign "^4.1.0"
babel-plugin-istanbul@^4.1.6:
version "4.1.6"
resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz#36c59b2192efce81c5b378321b74175add1c9a45"
@ -740,6 +747,10 @@ babel-plugin-syntax-async-functions@^6.8.0:
version "6.13.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95"
babel-plugin-syntax-dynamic-import@^6.18.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz#8d6a26229c83745a9982a441051572caa179b1da"
babel-plugin-syntax-exponentiation-operator@^6.8.0:
version "6.13.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de"
@ -1900,7 +1911,7 @@ decode-uri-component@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
deep-equal@~1.0.1:
deep-equal@^1.0.1, deep-equal@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
@ -2136,10 +2147,6 @@ ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
ejs@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.6.1.tgz#498ec0d495655abc6f23cd61868d926464071aa0"
electron-to-chromium@^1.3.47:
version "1.3.52"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.52.tgz#d2d9f1270ba4a3b967b831c40ef71fb4d9ab5ce0"
@ -2490,6 +2497,10 @@ execa@^0.8.0:
signal-exit "^3.0.0"
strip-eof "^1.0.0"
exenv@^1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
exit@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@ -5401,7 +5412,7 @@ prompts@^0.1.9:
kleur "^2.0.1"
sisteransi "^0.1.1"
prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2:
prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2:
version "15.6.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
dependencies:
@ -5547,6 +5558,21 @@ react-error-overlay@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4"
react-helmet@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-5.2.0.tgz#a81811df21313a6d55c5f058c4aeba5d6f3d97a7"
dependencies:
deep-equal "^1.0.1"
object-assign "^4.1.1"
prop-types "^15.5.4"
react-side-effect "^1.1.0"
react-loadable@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/react-loadable/-/react-loadable-5.5.0.tgz#582251679d3da86c32aae2c8e689c59f1196d8c4"
dependencies:
prop-types "^15.5.0"
react-router-config@^1.0.0-beta.4:
version "1.0.0-beta.4"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-1.0.0-beta.4.tgz#d202496dd0eabdf06cf24eb0793031f6891eef01"
@ -5574,6 +5600,13 @@ react-router@^4.3.1:
prop-types "^15.6.1"
warning "^4.0.1"
react-side-effect@^1.1.0:
version "1.1.5"
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.1.5.tgz#f26059e50ed9c626d91d661b9f3c8bb38cd0ff2d"
dependencies:
exenv "^1.2.1"
shallowequal "^1.0.1"
react@^16.4.1:
version "16.4.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32"
@ -6055,6 +6088,10 @@ sha.js@^2.4.0, sha.js@^2.4.8:
inherits "^2.0.1"
safe-buffer "^5.0.1"
shallowequal@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
shebang-command@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@ -6970,6 +7007,10 @@ webpack-sources@^1.0.1, webpack-sources@^1.1.0:
source-list-map "^2.0.0"
source-map "~0.6.1"
webpack-stats-plugin@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/webpack-stats-plugin/-/webpack-stats-plugin-0.2.1.tgz#1f5bac13fc25d62cbb5fd0ff646757dc802b8595"
webpack@^4.16.3:
version "4.16.3"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.16.3.tgz#861be3176d81e7e3d71c66c8acc9bba35588b525"