文章思路来源于 慕课网: Webpack+React 全栈工程架构项目实战精讲

要做服务端渲染,首先我们要明白为什么做,它的优劣势如何?

随着前后分离项目普及,单页应用越来越流行,然而一些问题也随之而来:

  • 单页应用渲染由 js 完成(客户端渲染),浏览器最初获取的是一个空的 html。
    • SEO
  • 由于内容都是 js 生成的,这中间就存在了一个 js 获取数据渲染页面的过程,相较于后端模板渲染完 html 再发送给浏览器,存在首页白屏问题,用户体验差。
    • 首屏渲染白屏

所谓 ssr,和之前的传统的多页面网站服务器端渲染不是同一个层次,在现有架构不变的情况下,即后端依旧只是提供 api 服务,前端人员依旧通过 ajax 请求数据,同时要达到传统多页应用的首屏加载速度以及 seo 优化。

现在最常见的做法是引入一层中间层 node

难点在于:

  • 数据同步
  • 路由跳转
  • SEO 信息
  • 如何在开发时方便的进行服务端渲染的测试(模块热更新)

最近在接触 React 服务端渲染时看到了慕课上的这堂课,自己也看了一遍,以此总结一下,也发现自己在这部分内容的不足之处。想玩转服务端渲染,门槛相当来说也不低,需要有:

  • react 全家桶的一些基础知识(react-router、redux 或 mobx 等)
  • webpack 基础
  • node 基础
  • node 框架使用经验 express 或 koa
  • es6 基础

如果拥有以上技能,配出一个服务端渲染不会很难

工程开发环境搭建

工程目录

- build: webpack配置文件夹(客户端/服务端)
  - webpack.config.base.js 公共配置文件
  - webpack.config.client.js 客户端webpack配置文件
  - webpack.config.server.js 服务端webpack配置文件
- client: 源码文件夹
  - App.jsx 入口组件
  - app.js 客户端入口js文件
  - server-entry 服务端入口js文件
  - template html模板
- node_modules: 依赖模块
- server: 服务端文件
  - server.js: express启动文件
  - util 工具方法类文件
    - dev-static 服务端开发环境运行文件
- babelrc: babel配置文件
- .editorconfig:编辑器配置文件
- package.json

package.json 依赖:

{
  "dependencies": {
    "axios": "^0.17.1",
    "express": "^4.16.2",
    "react": "^16.2.0",
    "react-dom": "^16.2.0"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-es2015-loose": "^8.0.0",
    "babel-preset-react": "^6.24.1",
    "cross-env": "^5.1.3", //  用于命令行启动webpack设置dev或者prod环境
    "html-webpack-plugin": "^2.30.1", //  生成html文件并自动注入jswebpack插件
    "http-proxy-middleware": "^0.17.4", //  express端口转发中间件
    "memory-fs": "^0.4.1", //  第三方fs模块,内存读写]
    "react-hot-loader": "^4.0.0-beta.13", // react热替换插件 用于维持react组件开发状态
    "rimraf": "^2.6.2", // 删除文件夹用的npm包
    "webpack-dev-server": "^2.11.0", // 前端开发环境服务webpack插件
    "webpack": "^3.10.0",
    "webpack-merge": "^3.0.0"
  }
}

npm scripts:

"build:client": "webpack --config build/webpack.config.client.js",
"build:server": "webpack --config build/webpack.config.server.js",
"dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.config.client.js",
"dev:server": "cross-env NODE_ENV=development node server/server.js",
"clear": "rimraf dist",
"build": "npm run clear && npm run build:client && npm run build:server"

客户端相关

入口 js 文件

import React from 'react';
import ReactDOM from 'react-dom';
import { AppContainer } from 'react-hot-loader';
import App from './App.jsx';

// ReactDOM.hydrate(   <App />, document.getElementById('root'));

const root = document.getElementById('root');
const render = Component => {
  const renderMethod = module.hot ? ReactDOM.render : ReactDOM.hydrate;
  // ReactDOM.hydrate(
  renderMethod(
    <AppContainer>
      <Component />
    </AppContainer>,
    root
  );
};

render(App);

if (module.hot) {
  module.hot.accept('./App.jsx', () => {
    const NextApp = require('./App.jsx').default;
    // ReactDOM.hydrate(   <NextApp />, document.getElementById('root'));
    render(NextApp);
  });
}

客户端 webpack 配置

const path = require('path');
const webpack = require('webpack');
const HTMLWebpackPlugin = require('html-webpack-plugin'); // 生成html页面,同时可以将生成的js注入该html的插件
const isDev = process.env.NODE_ENV === 'development'; // 判断当前环境 根据环境不同采取不同的webpack配置 在命令行中设置当前环境 要通过cross-env这个包来保证linux mac win三个平台配置一致
const config = {
  entry: {
    app: path.join(__dirname, '../client/index.js')
  },
  output: {
    filename: '[name].[hash].js',
    path: path.join(__dirname, '../dist'),
    // 静态资源最终访问路径 = output.publicPath + 资源loader或插件等配置路径 距离:html中引用js 由 ‘/app.js’ 变为
    // '/public/app.js'
    publicPath: '/public/'
  },
  module: {
    rules: [
      // jsx文件通过babel-loader进行处理 核心库:babel-core 插件:babel-preset-es2015
      // babel-preset-es2015-loose babel-preset-react 配置文件在根目录.babelrc中
      {
        test: /.jsx$/,
        loader: 'babel-loader'
      },
      // node_modules下的js代码不可以用babel编译,所以我们js和jsx分开写
      {
        test: /.js$/,
        loader: 'babel-loader',
        exclude: [path.join(__dirname, '../node_modules')]
      }
    ]
  },
  plugins: [
    new HTMLWebpackPlugin({
      // 根据模板生成html文件
      template: path.join(__dirname, '../client/template.html')
    })
  ]
};

if (isDev) {
  config.entry = {
    app: ['react-hot-loader/patch', path.join(__dirname, '../client/index.js')]
  };
  config.devServer = {
    // '0,0,0,0'表示我们可以用任何方式进行访问 如localhost 127.0.0.1 以及外网ip 若配置为'localhost'
    // 或'127.0.0.1'别人无法从外网进行调试
    host: '0.0.0.0',
    // 起服务的端口
    port: '8888',
    // 起服务的目录,此处与output一致,此处有坑(要考虑publicPath,还要知晓webpack-dev-server生成的文件在内存中,若项目中已经有
    // 了dist则以项目中的dist为准,所以删掉dist吧)
    contentBase: path.join(__dirname, '../dist'),
    // 热更新 如果不配置webpack-dev-server会在文件修改后全局刷新而非局部替换
    hot: true,
    overlay: {
      // 如果打包过程中出现错误在浏览器中渲染一层overlay进行展示
      errors: true
    },
    // 在此处设置与output相同的publicPath,把静态资源文件放在public文件夹下
    // 使得output.publicPath得以正常运行,其实这里的publicPath更像是output.path
    publicPath: '/public/',
    // 解决刷新404问题(服务端没有前端路由指向的文件) 全都返回index.html
    historyApiFallback: {
      index: '/public/index.html'
    }
  };
  config.plugins.push(new webpack.HotModuleReplacementPlugin());
}

module.exports = config;

服务端相关

服务端渲染需要两个文件

  • html 模板
  • js 文件

将这两部分内容通过 react 服务端渲染方法结合为渲染好的 html 返回给浏览器,便完成了服务端渲染

入口 js 文件

import React from 'react';
import App from './App.jsx';

export default <App />;

服务端 webpack 配置

// 服务端渲染的webpack配置文件

const path = require('path');
module.exports = {
  // 打包出来的js代码执行在哪个环境
  target: 'node',
  entry: {
    app: path.join(__dirname, '../client/server-entry.js')
  },
  output: {
    // 服务端没有浏览器缓存 hash没必要,同时要自己手动引入js
    filename: 'server-entry.js',
    path: path.join(__dirname, '../dist'),
    publicPath: '/public',
    libraryTarget: 'commonjs2'
  },
  module: {
    rules: [
      {
        test: /.jsx$/,
        loader: 'babel-loader'
      },
      {
        test: /.js$/,
        loader: 'babel-loader',
        exclude: [path.join(__dirname, '../node_modules')]
      }
    ]
  }
};

express

有了 js,再去取到硬盘上的 html 我们就实现了一个基础的服务端渲染 demo,暂不考虑开发时的热更新等等

server/server.js

const express = require('express');
const ReactSSR = require('react-dom/server');
const fs = require('fs');
const path = require('path');

const isDev = process.env.NODE_ENV === 'development';

const app = express();

if (!isDev) {
  // production环境
  const serverEntry = require('../dist/server-entry').default;
  const template = fs.readFileSync(
    path.join(__dirname, '../dist/index.html'),
    'utf8'
  );

  // 将dist目录下的所有文件都托管在public文件夹下
  app.use('/public', express.static(path.join(__dirname, '../dist')));

  app.get('*', function(req, res) {
    // 服务端渲染
    const appString = ReactSSR.renderToString(serverEntry);
    // 替换模板并返回 为啥是<!-- app --> 因为template.html根节点里面的注释,用来占位的
    res.send(template.replace('<!-- app -->', appString));
  });
} else {
  // devlopment环境
  const devStatic = require('./util/dev-static');
  devStatic(app);
}
app.listen(3333, function() {
  console.log('====================================');
  console.log('server is listenging on 3333');
  console.log('====================================');
});

接下来,我们只需要:

  • webpack 打包(服务端)
  • 启动 express 服务

就可以在 localhost:3333 端口访问到服务端渲染好的页面了,但是这里有一个问题没解决:

每一次进行改动都需要进行打包,无法热更新

我们知道,和生产环境不一样,开发时,webpack-dev-server 是将打包文件放在内存之中而不是直接写文件在硬盘上,读写内存是比读写硬盘快得多的,这样我们修改源文件,反应速度就大大增加了,所以我们服务端也遵循这样的思路引入了 memory-fs

客户端开发的环境都搭建好了,我们就依赖客户端环境来进行热更新岂不妙哉?所以我们引入了 http-proxy-middleware 中间件,这样在客户端 webpack-dev-server 启动的条件下,我们的可以实时地获得起更新的内容。

服务端渲染需要的的 js 通过作为 node 模块的 webpack 的方式来进行监听打包(不能直接 require 进来,需要通过比较 hack 的方式读取为字符串转成 js),那需要的 html 呢,并没有直接写在硬盘上,但是客户端环境是已经启动了的,我们通过 axios 去拿到即可。

/server/util/dev-static.js

// 该文件为开发时服务端渲染的配置 可以理解为为了实现 快速 热更新 打包 的目的

const axios = require('axios');
// 在这里使用webpack,作为node的一个模块,而非命令行使用
const webpack = require('webpack');
const path = require('path');
// 第三方fs模块,api同node一致,不过是将内容写进内存 -快速
const MemoryFs = require('memory-fs');
// 静态文件代理 为了publicPath与热更新
const proxy = require('http-proxy-middleware');
const ReactDomServer = require('react-dom/server');

// 服务端wenpack配置文件
const serverConfig = require('../../build/webpack.config.server');

// 使用http请求去读取webpack-dev-server中的模板[所以依赖npm run dev:client 热更新也依赖 :p]
const getTemplate = () => {
  return new Promise((resolve, reject) => {
    axios
      .get('http://localhost:8888/public/index.html')
      .then(res => {
        resolve(res.data);
      })
      .catch(reject);
  });
};

// hack 将字符串转为模块 参考:http://www.ruanyifeng.com/blog/2015/05/require.html
// 获取module的构造函数
const Module = module.constructor;
let serverBundle;

const mfs = new MemoryFs();
// 启动webpack compiler
serverCompiler = webpack(serverConfig);
// webpack提供给我们的配置项,此处将其配置为 通过mfs进行读写(内存)
serverCompiler.outputFileSystem = mfs;
// 监听entry处的文件是否有变动 若有变动重新打包
serverCompiler.watch({}, (err, stats) => {
  if (err) throw err;
  stats = stats.toJson();
  // 打印错误和警告信息
  stats.errors.forEach(err => {
    console.error(err);
  });
  stats.warnings.forEach(warn => {
    console.warn(warn);
  });
  // 打包的文件所在路径
  const bundlePath = path.join(
    serverConfig.output.path,
    serverConfig.output.filename
  );
  // 获取打包完成的js文件(注:文件是在内存中而非硬盘中,类比webpack-dev-server的文件)
  // 此时获得的是字符串,并非可执行的js,我们需要进行转换
  const bundle = mfs.readFileSync(bundlePath, 'utf-8');
  // 创建一个空模块
  const m = new Module();
  // 编译字符串 要指定名字
  m._compile(bundle, 'server-entry.js');
  // 暴露出去 .default : require => es6 module
  serverBundle = m.exports.default;
});
module.exports = function(app) {
  // 将 `/public` 的请求全部代理到webpack-dev-server启动的服务 思考 express.static为啥不能用
  // 我们要借用webpack-dev=server的热更新 热更新就不是服务端渲染了 就第一次是
  app.use('/public', proxy({ target: 'http://localhost:8888' }));
  app.get('*', function(req, res) {
    getTemplate().then(template => {
      const content = ReactDomServer.renderToString(serverBundle);
      res.send(template.replace('<!-- app -->', content));
    });
  });
};

这样服务端的一个基础环境就搭建好了。