3-11 构建同构应用

同构应用是指写一份代码但可同时在浏览器和服务器中运行的应用。

认识同构应用

现在大多数单页应用的视图都是通过 JavaScript 代码在浏览器端渲染出来的,但在浏览器端渲染的坏处有:

  • 搜索引擎无法收录你的网页,因为展示出的数据都是在浏览器端异步渲染出来的,大部分爬虫无法获取到这些数据。
  • 对于复杂的单页应用,渲染过程计算量大,对低端移动设备来说可能会有性能问题,用户能明显感知到首屏的渲染延迟。

为了解决以上问题,有人提出能否将原本只运行在浏览器中的 JavaScript 渲染代码也在服务器端运行,在服务器端渲染出带内容的 HTML 后再返回。 这样就能让搜索引擎爬虫直接抓取到带数据的 HTML,同时也能降低首屏渲染时间。 由于 Node.js 的流行和成熟,以及虚拟 DOM 提出与实现,使这个假设成为可能。

实际上现在主流的前端框架都支持同构,包括 React、Vue2、Angular2,其中最先支持也是最成熟的同构方案是 React。 由于 React 使用者更多,它们之间又很相似,本节只介绍如何用 Webpack 构建 React 同构应用。

同构应用运行原理的核心在于虚拟 DOM,虚拟 DOM 的意思是不直接操作 DOM 而是通过 JavaScript Object 去描述原本的 DOM 结构。 在需要更新 DOM 时不直接操作 DOM 树,而是通过更新 JavaScript Object 后再映射成 DOM 操作。

虚拟 DOM 的优点在于:

  • 因为操作 DOM 树是高耗时的操作,尽量减少 DOM 树操作能优化网页性能。而 DOM Diff 算法能找出2个不同 Object 的最小差异,得出最小 DOM 操作;
  • 虚拟 DOM 的在渲染的时候不仅仅可以通过操作 DOM 树来表示出结果,也能有其它的表示方式,例如把虚拟 DOM 渲染成字符串(服务器端渲染),或者渲染成手机 App 原生的 UI 组件( React Native)。

以 React 为例,核心模块 react 负责管理 React 组件的生命周期,而具体的渲染工作可以交给 react-dom 模块来负责。

react-dom 在渲染虚拟 DOM 树时有2中方式可选:

  • 通过 render() 函数去操作浏览器 DOM 树来展示出结果。
  • 通过 renderToString() 计算出表示虚拟 DOM 的 HTML 形式的字符串。

构建同构应用的最终目的是从一份项目源码中构建出2份 JavaScript 代码,一份用于在浏览器端运行,一份用于在 Node.js 环境中运行渲染出 HTML。 其中用于在 Node.js 环境中运行的 JavaScript 代码需要注意以下几点:

  • 不能包含浏览器环境提供的 API,例如使用 document 进行 DOM 操作,  因为 Node.js 不支持这些 API;
  • 不能包含 CSS 代码,因为服务端渲染的目的是渲染出 HTML 内容,渲染出 CSS 代码会增加额外的计算量,影响服务端渲染性能;
  • 不能像用于浏览器环境的输出代码那样把 node_modules 里的第三方模块和 Node.js 原生模块(例如 fs 模块)打包进去,而是需要通过 CommonJS 规范去引入这些模块。
  • 需要通过 CommonJS 规范导出一个渲染函数,以用于在 HTTP 服务器中去执行这个渲染函数,渲染出 HTML 内容返回。

解决方案

接下来改造在3-6使用 React 框架中介绍的 React 项目,为它增加构建同构应用的功能。

由于要从一份源码构建出2份不同的代码,需要有2份 Webpack 配置文件分别与之对应。 构建用于浏览器环境的配置和前面讲的没有差别,本节侧重于讲如何构建用于服务端渲染的代码。

用于构建浏览器环境代码的 webpack.config.js 配置文件保留不变,新建一个专门用于构建服务端渲染代码的配置文件 webpack_server.config.js,内容如下:

const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  // JS 执行入口文件
  entry: './main_server.js',
  // 为了不把 Node.js 内置的模块打包进输出文件中,例如 fs net 模块等
  target: 'node',
  // 为了不把 node_modules 目录下的第三方模块打包进输出文件中
  externals: [nodeExternals()],
  output: {
    // 为了以 CommonJS2 规范导出渲染函数,以给采用 Node.js 编写的 HTTP 服务调用
    libraryTarget: 'commonjs2',
    // 把最终可在 Node.js 中运行的代码输出到一个 bundle_server.js 文件
    filename: 'bundle_server.js',
    // 输出文件都放到 dist 目录下
    path: path.resolve(__dirname, './dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: path.resolve(__dirname, 'node_modules'),
      },
      {
        // CSS 代码不能被打包进用于服务端的代码中去,忽略掉 CSS 文件
        test: /\.css/,
        use: ['ignore-loader'],
      },
    ]
  },
  devtool: 'source-map' // 输出 source-map 方便直接调试 ES6 源码
};

以上代码有几个关键的地方,分别是:

  • target: 'node' 由于输出代码的运行环境是 Node.js,源码中依赖的 Node.js 原生模块没必要打包进去;
  • externals: [nodeExternals()] webpack-node-externals 的目的是为了防止 node_modules 目录下的第三方模块被打包进去,因为 Node.js 默认会去 node_modules 目录下寻找和使用第三方模块;
  • {test: /\.css/, use: ['ignore-loader']} 忽略掉依赖的 CSS 文件,CSS 会影响服务端渲染性能,又是做服务端渲不重要的部分;
  • libraryTarget: 'commonjs2' 以 CommonJS2 规范导出渲染函数,以供给采用 Node.js 编写的 HTTP 服务器代码调用。

为了最大限度的复用代码,需要调整下目录结构:

把页面的根组件放到一个单独的文件 AppComponent.js,该文件只能包含根组件的代码,不能包含渲染入口的代码,而且需要导出根组件以供给渲染入口调用,AppComponent.js 内容如下:

import React, { Component } from 'react';
import './main.css';

export class AppComponent extends Component {
  render() {
    return <h1>Hello,Webpack</h1>
  }
}

分别为不同环境的渲染入口写两份不同的文件,分别是用于浏览器端渲染 DOM 的 main_browser.js 文件,和用于服务端渲染 HTML 字符串的 main_server.js 文件。

main_browser.js 文件内容如下:

import React from 'react';
import { render } from 'react-dom';
import { AppComponent } from './AppComponent';

// 把根组件渲染到 DOM 树上
render(<AppComponent/>, window.document.getElementById('app'));

main_server.js 文件内容如下:

import React from 'react';
import { renderToString } from 'react-dom/server';
import { AppComponent } from './AppComponent';

// 导出渲染函数,以给采用 Node.js 编写的 HTTP 服务器代码调用
export function render() {
  // 把根组件渲染成 HTML 字符串
  return renderToString(<AppComponent/>)
}

为了能把渲染的完整 HTML 文件通过 HTTP 服务返回给请求端,还需要通过用 Node.js 编写一个 HTTP 服务器。 由于本节不专注于将 HTTP 服务器的实现,就采用了 ExpressJS 来实现,http_server.js 文件内容如下:

const express = require('express');
const { render } = require('./dist/bundle_server');
const app = express();

// 调用构建出的 bundle_server.js 中暴露出的渲染函数,再拼接下 HTML 模版,形成完整的 HTML 文件
app.get('/', function (req, res) {
  res.send(`
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<div id="app">${render()}</div>
<!--导入 Webpack 输出的用于浏览器端渲染的 JS 文件-->
<script src="./dist/bundle_browser.js"></script>
</body>
</html>
  `);
});

// 其它请求路径返回对应的本地文件
app.use(express.static('.'));

app.listen(3000, function () {
  console.log('app listening on port 3000!')
});

再安装新引入的第三方依赖:

# 安装 Webpack 构建依赖
npm i -D css-loader style-loader ignore-loader webpack-node-externals
# 安装 HTTP 服务器依赖
npm i -S express

以上所有准备工作已经完成,接下来执行构建,编译出目标文件:

  • 执行命令 webpack --config webpack_server.config.js 构建出用于服务端渲染的 ./dist/bundle_server.js 文件。
  • 执行命令 webpack 构建出用于浏览器环境运行的 ./dist/bundle_browser.js 文件,默认的配置文件为 webpack.config.js

构建执行完成后,执行 node ./http_server.js 启动 HTTP 服务器后,再用浏览器去访问 http://localhost:3000 就能看到 Hello,Webpack 了。 但是为了验证服务端渲染的结果,你需要打开浏览器的开发工具中的网络抓包一栏,再重新刷新浏览器后,就能抓到请求 HTML 的包了,抓包效果图如下:

图3.9.1 服务端渲染抓包

可以看到服务器返回的是渲染出内容后的 HTML 而不是 HTML 模版,这说明同构应用的改造完成。

本实例提供项目完整代码

results matching ""

    No results matching ""