目录和约定
demo源代码。在开始之前,首先约定一下目录和文件。
- project
|- config # 配置文件
|- utils.js # 通用函数
|- webpack.config.js # webpack通用配置
|- webpack.development.config.js # 开发环境下的webpack配置
|- webpack.production.config.js # 生产环境下的webpack配置
|- webpack.ssr.config.js # webpack ssr 配置
|- server # 服务端
|- devServer.js # 开发环境服务端
|- proServer.js # 生产环境服务端
|- utils.js # 通用函数
|- pages # react代码
|- document.ejs # html模板
|- Index # index模块
|- Entry.js # 入口文件
|- server.js # ssr入口文件
|- List # list模块
|- Entry.js # 入口文件
|- server.js # ssr入口文件
|- dist # 编译输出目录
|- dist-server # ssr编译输出目录
webpack配置
为了编译代码,首先要配置webpack编译。
使用一个通用函数来获取pages文件下的入口文件。我们需要根据路径生成name,比如/pages/index/Entry.js的入口文件生成的name为index。
/* === config/utils.js === */
const path = require('path');
const glob = require('glob');
const _ = require('lodash');
const HtmlWebpackPlugin = require('html-webpack-plugin');
/**
* 获取webpack入口文件
* @param { string } entryFileName: 入口文件名称
*/
exports.getEntry = function getEntry(entryFileName) {
const files = glob.sync(`pages/**/${ entryFileName }.js`); // 获取入口文件的路径
// 将路径转换成object
return _.transform(files, function(result, value, index) {
const res = path.parse(value);
// 根据路径生成name
const name = res
.dir
.replace(/^pages[\\/]/i, '')
.replace(/[\\/]/g, '_')
.toLocaleLowerCase();
result[name] = [path.join(__dirname, '..', value)];
}, {});
};
/**
* html-webpack-plugin插件
* @param { object } entry: 入口
*/
exports.htmlPlugins = function(entry) {
const template = path.join(__dirname, '../pages/document.ejs');
const keys = Object.keys(entry);
return _.transform(entry, function(result, value, key) {
result.push(new HtmlWebpackPlugin({
inject: true,
template,
filename: `${ key }.html`,
excludeChunks: _.without(keys, key)
}));
}, []);
};
项目使用了html-webpack-plugin来生成html、注入js文件。因为项目是多入口,所以还要配置excludeChunks来保证每个html文件只注入一个入口文件。
然后webpack配置如下。
/* === config/webpack.config.js === */
const path = require('path');
const { getEntry, htmlPlugins } = require('./utils');
function config() {
const entry = getEntry('Entry'); // 获取入口文件
return {
entry,
output: {
publicPath: '/',
path: path.join(__dirname, '../dist'),
globalObject: 'this',
filename: '[name].js',
chunkFilename: '[name].js'
},
module: {
rules: [
{
test: /^.*\.jsx?$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
targets: {
chrome: 70
},
debug: true,
modules: false,
useBuiltIns: false
}
],
'@babel/preset-react'
],
plugins: ['react-hot-loader/babel']
}
}
]
},
{
test: /^.*\.(jpe?g|png|gif|webp)$/,
use: [
{
loader: 'url-loader',
options: {
name: '[name]_[hash:5].[ext]',
limit: 0,
emitFile: true
}
}
]
}
]
},
plugins: htmlPlugins(entry)
};
}
module.exports = config;
/* === config/webpack.development.config.js === */
const merge = require('webpack-merge');
const config = require('./webpack.config');
function devConfig() {
return {
mode: 'development',
module: {
rules: [
{
test: /^.*\.css?$/,
use: [
{
loader: 'style-loader'
},
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[path][name]__[local]___[hash:base64:6]'
},
onlyLocals: false
}
}
]
}
]
}
};
}
module.exports = merge(config(), devConfig());
/* === config/webpack.production.config.js === */
const merge = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const config = require('./webpack.config');
function devConfig() {
return {
mode: 'production',
module: {
rules: [
{
test: /^.*\.css?$/,
use: [
{
loader: MiniCssExtractPlugin.loader
},
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[path][name]__[local]___[hash:base64:6]'
},
onlyLocals: false
}
}
]
}
]
},
plugins: [new MiniCssExtractPlugin()]
};
}
module.exports = merge(config(), devConfig());
webpack SSR配置
服务端的SSR代码也需要配置webpack编译。
/* === webpack.ssr.config.js === */
const path = require('path');
const process = require('process');
const { getEntry } = require('./utils');
function config() {
const entry = getEntry('server');
return {
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
target: 'async-node', // 配置为async-node或node以保证代码可以在node的环境中运行
node: {
__filename: true,
__dirname: true
},
entry,
output: {
path: path.join(__dirname, '../dist-server'),
globalObject: 'this',
filename: '[name].js',
chunkFilename: '[name].js',
libraryTarget: 'umd' // 文件输出为umd模块,保证node环境内能通过require函数加载模块
},
module: {
rules: [
{
test: /^.*\.jsx?$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-env',
{
targets: {
chrome: 70
},
debug: true,
modules: 'commonjs', // 配置为commonjs,保证依赖是通过node的require函数来加载
useBuiltIns: false
}
],
'@babel/preset-react'
]
}
}
]
},
{
test: /^.*\.css?$/,
use: [
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[path][name]__[local]___[hash:base64:6]'
},
onlyLocals: true // 配置为true,不生成css文件
}
}
]
},
{
test: /^.*\.(jpe?g|png|gif|webp)$/,
use: [
{
loader: 'url-loader',
options: {
name: '[name]_[hash:5].[ext]',
limit: 0,
emitFile: false
}
}
]
}
]
}
};
}
module.exports = config();
在配置项中,css-loader的onlyLocals选项可以只导出className,不生成css文件。
url-loader和file-loader的emitFile选项可以只生成文件路径,不生成资源。
这两个选项对于预渲染来说是很有用处的,因为不需要服务端的代码编译出静态资源。
配置开发环境服务
在开发环境,我们需要实现热更新、热替换的功能。所以需要我们使用中间件koa-webpack,来用于服务的开发。因为ejs被html-webpack-plugin作为模板解析,所以我们使用nunjucks作为服务端的模板。
/* === server/devServer.js === */
const path = require('path');
const fs = require('fs');
const http = require('http');
const Koa = require('koa');
const Router = require('@koa/router');
const koaWebpack = require('koa-webpack');
const webpack = require('webpack');
const mime = require('mime-types');
const nunjucks = require('nunjucks');
const _ = require('lodash');
const webpackDevConfig = require('../config/webpack.development');
const webpackSSRDevConfig = require('../config/webpack.ssr.config');
const { cleanRequireCache, requireModule } = require('./utils');
const app = new Koa();
const router = new Router();
const distSSR = path.join(__dirname, '../dist-server');
nunjucks.configure({
autoescape: false
});
async function main() {
/* webpack ssr */
const compiler = webpack(webpackSSRDevConfig);
compiler.watch({
aggregateTimeout: 500
}, function callback(err, stats) {
if (err) {
console.error(err);
} else {
console.log(stats.toString({
colors: true
}));
}
});
/* router */
app.use(router.routes())
.use(router.allowedMethods());
/* webpack中间件配置 */
const koaWebpackMiddleware = await koaWebpack({
compiler: webpack(webpackDevConfig),
hotClient: {
host: {
client: '*',
server: '0.0.0.0'
},
allEntries: true // 这个配置保证所有入口都能够热更新
},
devMiddleware: {
serverSideRender: true
}
});
app.use(koaWebpackMiddleware);
/* index路由 */
router.get('/*', async (ctx, next) => {
try {
// 因为koa中间件只能获取到静态文件,所以需要处理
// 获取path
const ctxPath = ctx.path;
const formatPath = ctx.path === '/' ? '/Index' : ctx.path; // 默认路由
const mimeType = mime.lookup(ctxPath);
ctx.routePath = ctxPath; // 保存旧的path
// 根据path解析name
const name = formatPath
.replace(/^\//, '')
.replace(/[\\/]/g, '_')
.toLocaleLowerCase();
// 根据path,将path修改成html文件的地址,获取html
if (mimeType === false) {
ctx.path = `/${ name }.html`;
}
await next();
if (ctx.type === 'text/html') {
// ssr
const modulePath = path.join(distSSR, `${ name }.js`); // 加载ssr模块
// 判断模块是否存在
if (fs.existsSync(modulePath)) {
cleanRequireCache(modulePath); // 清除模块缓存
const module = requireModule(modulePath); // 运行模块
const body = await module();
// 模板渲染
ctx.body = nunjucks.renderString(ctx.body.toString(), {
render: body.toString()
});
}
}
} catch (err) {
ctx.status = 500;
ctx.body = err.toString();
}
});
http.createServer(app.callback())
.listen(5050);
}
main();
server端的模块入口写成一个函数。
/* === pages/**/server.js === */
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';
function server() {
return renderToString(<App />);
}
export default server;
由于模块是umd,需要一个能够兼容的require函数来加载模块。开发环境还需要每次加载模块时清除模块的缓存。
/* === server/utils.js === */
/* 清除模块缓存(只用于开发环境) */
exports.cleanRequireCache = function cleanRequireCache(id) {
const modulePath = require.resolve(id);
if (module.parent) {
module.parent.children.splice(module.parent.children.indexOf(id), 1);
}
delete require.cache[modulePath];
};
/* 模块导入 */
exports.requireModule = function requireModule(id) {
const module = require(id);
return 'default' in module ? module.default : module;
};
html模板。
<!-- pages/document.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<div id="app">{{ render }}</div>
</body>
</html>
生产环境服务
生产环境可以使用静态资源的中间件koa-static-cache,不过过滤了html文件。
const path = require('path');
const fs = require('fs');
const http = require('http');
const Koa = require('koa');
const Router = require('@koa/router');
const staticCache = require('koa-static-cache');
const nunjucks = require('nunjucks');
const _ = require('lodash');
const { requireModule } = require('./utils');
const app = new Koa();
const router = new Router();
const dist = path.join(__dirname, '../dist');
const distSSR = path.join(__dirname, '../dist-server');
nunjucks.configure({
autoescape: false
});
function main() {
/* 缓存 */
app.use(staticCache(dist, {
maxAge: 0,
filter: (file) => !/^.*\.html$/.test(file)
}));
/* router */
app.use(router.routes())
.use(router.allowedMethods());
/* index路由 */
router.get('/*', async (ctx, next) => {
try {
// 获取path
const ctxPath = ctx.path;
const formatPath = ctx.path === '/' ? '/Index' : ctx.path;
ctx.routePath = ctxPath;
// 根据path解析name
const name = formatPath
.replace(/^\//, '')
.replace(/[\\/]/g, '_')
.toLocaleLowerCase();
await next();
if (ctx.type === '' && _.isNil(ctx.body)) {
// ssr
const html = await fs.promises.readFile(path.join(dist, `${ name }.html`));
const modulePath = path.join(distSSR, `${ name }.js`);
const module = requireModule(modulePath);
const body = await module();
if (fs.existsSync(modulePath)) {
ctx.body = nunjucks.renderString(html.toString(), {
render: body.toString()
});
} else {
ctx.body = html.toString();
}
ctx.status = 200;
ctx.type = 'text/html';
}
} catch (err) {
ctx.status = 500;
ctx.body = err.toString();
}
});
http.createServer(app.callback())
.listen(5050);
}
main();