github地址:github.com/bbwlfx/ts-b…
配置完成之后,接下来就要考虑打包启动以及前后端同构的架构方面的问题了。
webpack打包
首先我的整体思路是:根据webpack.ssr.config.js配置文件,将前端代码打包进node层供node做SSR使用,然后前端正常启动webpack-dev-server服务器即可。
package.json
"startfe": "run-p client ssr",
"client": "BABEL_ENV=client NODE_ENV=development webpack-dev-server --config public/webpack.dev.config.js",
"ssr": "BABEL_ENV=ssr NODE_ENV=development webpack --watch --config public/webpack.ssr.config.js",
将前端代码打包进node之后,在正常启动node服务器即可:
package.json
"start": "BABEL_ENV=server NODE_ENV=development nodemon src/app.ts --exec babel-node --extensions '.ts,.tsx'",
这样基本上webpack整体的打包思路就清晰了。
最终生产模式中,我们只需要将整个前端代码通过webpack打包进src
目录,然后将整个src
目录经过babel转义之后输出到output
目录,最终我们的生产模式只需要启动output/app.js
即可。
package.json
"buildfe": "run-p client:prod ssr:prod",
"build": "BABEL_ENV=server NODE_ENV=production babel src -D -d output/src --extensions '.ts,.tsx'",
"ssr:prod": "BABEL_ENV=ssr NODE_ENV=production webpack --config public/webpack.ssr.config.js",
"client:prod": "BABEL_ENV=client NODE_ENV=production webpack --progess --config public/webpack.prod.config.js",
$ node output/app.js // 启动生产模式
webpack配置
在客户端的打包中,我们需要使用webpack-manifest-plugin
插件。这个插件可以将webpack打包之后所有文件的路径写入一个manifest.json
的文件中,我们只需要读取这个文件就可以找到所有资源的正确路径了。
部分webpack.client.config.js
const ManifestPlugin = require("webpack-manifest-plugin");
module.exports = merge(baseConfig, {
// ...
plugins: [
new ManifestPlugin(),
// ...
]
});
Mapping loaded modules to bundles
In order to make sure that the client loads all the modules that were rendered server-side, we'll need to map them to the bundles that Webpack created.
我们的客户端渲染使用了react-loadable
,需要知道该模块是否提前经过了服务端渲染,否则会出现重复加载的问题。因此需要将webpack打包后的bundles
生成一个map文件,然后在ssr的时候传入react-loadable
。这里我们使用react-loadable/webpack
插件即可。
部分webpack.client.config.js
import { ReactLoadablePlugin } from 'react-loadable/webpack';
const outputDir = path.resolve(__dirname, "../src/public/buildPublic");
plugins: [
// ...
new ReactLoadablePlugin({
filename: path.resolve(outputDir, "react-loadable.json")
})
// ...
],
接下来是webpack打包产物的资源路径问题。
生产模式一般都是将输出的文件上传到cdn上,因此我们只需要在pubicPath的地方使用cdn地址即可。
部分webpack.prod.config.js
mode: "production",
output: {
filename: "[name].[chunkhash].js",
publicPath: "//cdn.address.com",
chunkFilename: "chunk.[name].[chunkhash].js"
},
开发环境中我们只需要读取manifest.json
文件中相对应模块的地址即可。
manifest.json
{
"home.js": "http://127.0.0.1:4999/static/home.js",
"home.css": "http://127.0.0.1:4999/static/home.css",
"home.js.map": "http://127.0.0.1:4999/static/home.js.map",
"home.css.map": "http://127.0.0.1:4999/static/home.css.map"
}
SSR代码
解决了打包问题之后,我们需要考虑ssr的问题了。
其实整体思路比较简单:我们通过打包,已经有了manifest.json
文件储存静态资源路径,有react-loadable.json
文件储存打包输出的各个模块的信息,只需要在ssr的地方读出js、css路径,然后将被<Loadable.Capture />
包裹的组件renderToString
一下,填入pug模板中即可。
src/utils/bundle.ts
function getScript(src) {
return `<script type="text/javascript" src="${src}"></script>`;
}
function getStyle(src) {
return `<link rel="stylesheet" href="${src}" />`;
}
export { getScript, getStyle };
src/utils/getPage.ts
import { getBundles } from "react-loadable/webpack";
import React from "react";
import { getScript, getStyle } from "./bundle";
import { renderToString } from "react-dom/server";
import Loadable from "react-loadable";
export default async function getPage({
store,
url,
Component,
page
}) {
const manifest = require("../public/buildPublic/manifest.json");
const mainjs = getScript(manifest[`${page}.js`]);
const maincss = getStyle(manifest[`${page}.css`]);
let modules: string[] = [];
const dom = (
<Loadable.Capture
report={moduleName => {
modules.push(moduleName);
}}
>
<Component url={url} store={store} />
</Loadable.Capture>
);
const html = renderToString(dom);
const stats = require("../public/buildPublic/react-loadable.json");
let bundles: any[] = getBundles(stats, modules);
const _styles = bundles
.filter(bundle => bundle && bundle.file.endsWith(".css"))
.map(bundle => getStyle(bundle.publicPath))
.concat(maincss);
const styles = [...new Set(_styles)].join("\n");
const _scripts = bundles
.filter(bundle => bundle && bundle.file.endsWith(".js"))
.map(bundle => getScript(bundle.publicPath))
.concat(mainjs);
const scripts = [...new Set(_scripts)].join("\n");
return {
html,
__INIT_STATES__: JSON.stringify(store.getState()),
scripts,
styles
};
}
路径说明:
src/public
目录存放所有前端打包过来的文件,src/public/buildPublic
存放webpack.client.config.js
打包的前端代码,src/public/buildServer
存放webpack.ssr.config.js
打包的服务端渲染的代码。
这样服务端渲染的部分就基本完成了。
其他node层启动代码可以直接查看src/server.ts
文件即可。
前后端同构
接下来就要编写前端的业务代码来测试一下服务端渲染是否生效。
这里我们要保证使用最少的代码完成前后端同构的功能。
首先我们需要在webpack中定义个变量IS_NODE
,在代码中根据这个变量就可以区分ssr部分的代码和客户端部分的代码了。
webpack.client.config.js
plugins: [
// ...
new webpack.DefinePlugin({
IS_NODE: false
})
// ...
]
接下来编写前端页面的入口文件,入口这里要对ssr和client做区别渲染:
public/js/decorators/entry.tsx
import React, { Component } from "react";
import { Provider } from "react-redux";
import ReactDOM from "react-dom";
import Loadable from "react-loadable";
import { BrowserRouter, StaticRouter } from "react-router-dom";
// server side render
const SSR = App =>
class SSR extends Component<{
store: any;
url: string;
}> {
render() {
const context = {};
return (
<Provider store={this.props.store} context={context}>
<StaticRouter location={this.props.url}>
<App />
</StaticRouter>
</Provider>
);
}
};
// client side render
const CLIENT = configureState => Component => {
const initStates = window.__INIT_STATES__;
const store = configureState(initStates);
Loadable.preloadReady().then(() => {
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<Component />
</BrowserRouter>
</Provider>,
document.getElementById("root")
);
});
};
export default function entry(configureState) {
return IS_NODE ? SSR : CLIENT(configureState);
}
这里entry参数中的configureState
是我们store的声明文件。
public/js/models/configure.ts
import { init } from "@rematch/core";
import immerPlugin from "@rematch/immer";
import * as models from "./index";
const immer = immerPlugin();
export default function configure(initStates) {
const store = init({
models,
plugins: [immer]
});
for (const model of Object.keys(models)) {
store.dispatch({
type: `${model}/@init`,
payload: initStates[model]
});
}
return store;
}
这样就万事俱备了,接下来只需要约定我们单页的入口即可。
这里我将单页的入口都统一放到public/js/entry
目录下面,每一个单页都是一个目录,比如我的项目中只有一个单页,因此我只创建了一个home
目录。
每一个目录下面都有一个index.tsx
文件和一个routes.tsx
文件,分为是单页的整体入口代码,已经路由定义代码。
例如:
/entry/home/routes.tsx
import Loadable from "react-loadable";
import * as Path from "constants/path";
import Loading from "components/loading";
export default [
{
name: "demo",
path: Path.Demo,
component: Loadable({
loader: () => import("containers/demo"),
loading: Loading
}),
exact: true
},
{
name: "todolist",
path: Path.Todolist,
component: Loadable({
loader: () => import("containers/todolist"),
loading: Loading
}),
exact: true
}
];
/entry/home.index.tsx
import React, { Component } from "react";
import configureStore from "models/configure";
import entry from "decorators/entry";
import { Route } from "react-router-dom";
import Layout from "components/layout";
import routes from "./routes";
class Home extends Component {
render() {
return (
<Layout>
{routes.map(({ path, component: Component, exact = true }) => {
return (
<Route path={path} component={Component} key={path} exact={exact} />
);
})}
</Layout>
);
}
}
const Entry = entry(configureStore)(Home);
export { Entry as default, Entry, configureStore };
Layout
组件是存放所有页面的公共部分,比如Nav导航条、Footer等。
这样所有的准备工作就已经做完了,剩下的工作就只有编写组件代码以及首屏数据加载了。
系列文章: