构建同构应用

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2021-02-26

浏览器渲染

由浏览器去解析执行JavaScript,接受到的index.html文件的DOM树上只有1个div,由JavaScript向文档中append innerHTML

优点:服务端只用第一次返回一个index.html,和后续的api数据,服务器压力减小;

缺点:

  • 首次加载白屏:返回的是一个空白HTML需要去下载cssjs 文件,如果文件过大或出现错误,用户会看到白屏。
  • 页面数据动态生成,不利于SEO (搜索引擎优化)
  • 对于复杂的单页应用,渲染过程的计算量大,对于低端移动设备可能会有性能问题,用户能明显感知首屏的渲染延迟。

服务器渲染

由服务器渲染出带内容的HTML后返回。 优缺点即相反,用户第一次拿到的HTML文档已经进行了初步的内容渲染,虽然请求的数量并没有改变,只是把部分请求转移到了服务端,但是在服务器上进行数据拉取的成本要远远小于浏览器端,而且传输更加高效。

同构应用

同构:服务端渲染应该表达出页面最主要、最核心、最基本的信息;而浏览器端则需要针对交互完成进一步的页面渲染、事件绑定等增强功能。所谓同构,就是指前后端共用一套代码或逻辑,而在这套代码或逻辑中,理想的状况是在浏览器端进一步渲染的过程中,判断已有的DOM结构和即将渲染出的结构是否相同,若相同,则不重新渲染DOM结构,只需要进行事件绑定即可。

优点:

  • SEO优化支持。服务端接收到请求后,会返回一个相对完整、包含了初始内容的HTML文档,所以更有利于搜索引擎爬虫获取信息,提高搜索结果展现排名。同时,更快的页面加载时间也有利于搜索结果展现排名的提升。

  • 实现更加灵活。服务端渲染只是直出页面的初始内容,浏览器端仍然需要做后续工作,以完成页面的最终展现。这样服务端渲染和浏览器端渲染仍可以平衡,在很大程度上也能实现代码复用。

  • 更好的用户体验。对于低端机型、恶劣的网络环境更加友好。因为内容的初步渲染是在服务端完成的,所以对于低端机型更加友好,不至于页面加载时出现白屏幕的状况。

缺点:

  • 服务端处理的逻辑增多,增加了复杂性。
  • 服务端无法完全复用浏览器端代码。
  • 增加了服务端的TTFB(Time To First Byte)时间。TTFB时间指的是从浏览器发起最初的网络请求,到从服务器接收到第一个字节的这段时间。它包含了TCP连接时间、发送HTTP请求的时间和获得响应消息的第一个字节的时间。因为对数据的获取和对页面初始内容的渲染,势必会降低服务端返回的速度。

同构应用的核心在于虚拟DOM,虚拟DOM的优点如下:

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

构建同构应用的最终目的是从一份项目源码中构建出两份 JavaScript代码,一份用于在浏览器端运行,一份用于在Node.js环境中运行并渲染出HTML

对于要在Node环境运行的JS代码需要注意:

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

关于 Webpack的配置有两份,一份针对原本常用浏览器渲染,一份用于服务器渲染;

用于浏览器端的 webpack.config.js

// webpack.config.js
const path = require('path');

module.exports = {
  // JS 执行入口文件
  entry: './main_browser.js',
  output: {
    // 把所有依赖的模块合并输出到一个 bundle.js 文件
    filename: 'bundle_browser.js',
    // 输出文件都放到 dist 目录下
    path: path.resolve(__dirname, './dist'),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
        exclude: path.resolve(__dirname, 'node_modules'),
      },
      {
        test: /\.css/,
        use: ['style-loader', 'css-loader'],
      },
    ]
  },
  devtool: 'source-map' // 输出 source-map 方便直接调试 ES6 源码
};

用于服务器端的webpack_server.config.js

// webpack_server.config.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  // JS 执行入口文件
  entry: './main_server.js',
  // 为了不打包进 Nodejs 内置的模块,例如 fs net 模块等
  target: 'node',
  // 为了不打包进 node_modules 目录下的第三方模块
  externals: [nodeExternals()],
  output: {
    // 为了以 CommonJS2 规范导出渲染函数,以给采用 Nodejs 编写的 HTTP 服务调用
    libraryTarget: 'commonjs2',
    // 把最终可在 Nodejs 中运行的代码输出到一个 bundle.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 源码
};

页面的根组件 AppComponent.js

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

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

不同环境的主入口文件,也是上面对应webpack设置的entry

用于浏览器端渲染的入口 main_browser.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

// main_server.js
import React from 'react';
// React提供的用于服务器端渲染的
import { renderToString } from 'react-dom/server';
import { AppComponent } from './AppComponent';

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

为了能够见更完整HTML文件通过HTTP服务返回给前段,还需要用Node.js写一个HTTP服务器。

// http_server.js 借助 express
const express = require('express');
const { render } = require('./dist/bundle_server'); // 就是从main_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 --config webpack_server.config.js构建出用于服务端渲染的./dist/bundle_server.js文件。

执行 webpack (默认的配置文件就是webpack.config.js)构建出用于浏览器环境运行的的./dist/bundle_browser.js文件。

构建之后,将HTTP服务器跑起来,node ./http_server.js,然后访问localhost:3000就能看到Hello,Webpack了。