配置完成之后, 接下来就要考虑打包启动以及前后端同构的架构方面的问题了.
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 = Windows.__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 等.
这样所有的准备工作就已经做完了, 剩下的工作就只有编写组件代码以及首屏数据加载了.
来源: https://juejin.im/post/5bc697b46fb9a05cf52af27a