React最佳實踐嘗試(二)

Cryptolalia發表於2018-10-17

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等。

這樣所有的準備工作就已經做完了,剩下的工作就只有編寫元件程式碼以及首屏資料載入了。

系列文章:

  1. React最佳實踐嘗試(一)技術選型
  2. React最佳實踐嘗試(三)

相關文章