React服务端渲染原理解析与实践


Posted in Javascript onMarch 04, 2021

关于服务端渲染也就是我们说的SSR大多数人都听过这个概念,很多同学或许在公司中已经做过服务端渲染的项目了,主流的单页面应用比如说Vue或者React开发的项目采用的一般都是客户端渲染的模式也就是我们说的CSR。

但是这种模式会带来明显的两个问题,第一个就是TTFP时间比较长,TTFP指的就是首屏展示时间,同时不具备SEO排名的条件,搜索引擎上排名不是很好。所以我们可以借助一些工具来进行改良我们的项目,将单页面应用编程服务器端渲染项目,这样就可以解决掉这些问题了。

目前主流的服务器端渲染框架也就是SSR框架有针对于Vue的Nuxt.js和针对React的Next.js这两个。这里我们并不使用这些SSR框架,而是从零开始完整搭建一套SSR框架,来熟悉他的底层原理。

服务器端编写 React 组件

如果是客户端渲染,浏览器首先会向浏览器发送请求,服务器返回页面的html文件,然后html中再向服务器发送请求,服务器返回js文件,js文件在浏览器中执行绘制出页面结构渲染到浏览器完成页面渲染。

如果是服务器端渲染这个流程就不同了,浏览器发送请求,服务器端运行React代码生成页面,然后服务器将生成好的页面返回给浏览器,浏览器进行渲染。这种情况下React代码就是服务器的一部分而不是前端部分了。

这里我们进行代码的演示,首选需要npm init初始化项目,然后安装react,express,webpack,webpack-cli,webpack-node-externals。

我们首先编写一个React的组件。 .src/components/Home/index.js, 因为我们这个js是在node环境执行的所以我们要遵循CommonJS规范,使用require和module.exports进行导入导出。

const React = require('react');

const Home = () => {
  return <div>home</div>
}

module.exports = {
  default: Home
};

我们这里开发的Home组件是不能直接在node中运行的,需要借助webpack工具将jsx语法打包编译成js语法,让nodejs可以争取的识别,我们需要创建一个webpack.server.js文件。

在服务器端使用webpack需要添加一个target为node的键值对。我们知道在服务器端如果使用path路径是不需要打包到js中的,如果在浏览器端使用了path是需要打包到js中的,所以在服务器端和在浏览器端需要编译出来的js是完全不同的。所以我们在打包的时候要告诉webpack打包的是服务器端的代码还是浏览器端的代码。

entry入口文件就是我们node的启动文件,这里我们写成./src/index.js,输出的output文件名称为bundle,目录在跟目录的build文件夹中。

const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服务端运行webpack需要运行NodeExternals, 他的作用是将express这类node模块不被打包到js里。

module.exports = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: Path.resolve(__dirname, 'build')
  },
  externals: [NodeExternals()],
  module: {
    rules: [
      {
        test: /.js?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}

安装依赖模块

npm install babel-loader babel-core babel-preset-react babel-preset-stage-0 babel-preset-env --save

接着我们这里基于express模块来编写一个简单的服务。./src/server/index.js

var express = require('express');
var app = express();
const Home = require('../Components/Home');
app.get('*', function(req, res) {
  res.send(`<h1>hello</h1>`);
})

var server = app.listen(3000);

运行webpack使用webpack.server.js配置文件来执行。

webpack --config webpack.server.js

打包之后在我们的目录下会出现一个bundle.js,这个js就是我们打包生成的最终可以运行的代码。我们可以使用node运行这个文件, 就启动了一个3000端口的服务器。我们访问127.0.0.1:3000可以访问这个服务,看到浏览器输出Hello。

node ./build/bundile.js

上面的代码我们运行前会使用webpack进行编译,所以也就支持了ES Modules规范,不再强制使用CommonJS了。

src/components/Home/index.js

import React from 'react';

const Home = () => {
  return <div>home</div>
}

export default Home;

/src/server/index.js中我们可以使用Home组件,这里我们首先需要安装react-dom,借助renderToString将Home组件转换为标签字符串,当然这里需要依赖React所以我们需要引入React。

import express from 'express';
import Home from '../Components/Home';
import React from 'react';
import { renderToString } from 'react-dom/server';

const app = express();
const content = renderToString(<Home />);
app.get('*', function(req, res) {
  res.send(`
    <html>
      <body>${content}</body>
    </html>
  `);
})

var server = app.listen(3000);
# 重新打包
webpack --config webpack.server.js
# 运行服务
node ./build/bundile.js

这时候页面就显示出了我们React组件的代码。

React的服务端渲染是建立在虚拟DOM上的服务器端渲染,而且服务端渲染会让页面的首屏渲染速度大大加快。不过服务端渲染也有弊端,客户端渲染React代码在浏览器端执行,他消耗的是用户浏览器端的性能,但是服务器端渲染消耗的是服务器端的性能,因为React代码在服务器上运行。极大的消耗了服务器的性能,因为React代码是很消耗计算性能的。

如果你的项目完全没有必要使用SEO优化并且你的项目访问速度已经很快了的情况下,建议还是不要使用SSR的技术了,因为他的成本开销还是比较大的。

上面我们的代码每次修改之后都需要重新执行webpack打包和启动服务器,这样调试起来太过麻烦,为了解决这个问题我们需要做一下webpack的自动打包和node的重启。我们在package.json中加入build命令,并且通过--watch监听文件变化进行自动打包。

{
  ...
  "scripts": {
    "build": "webpack --config webpack.server.js --watch"
  }
  ...
}

只是重新打包还不够,我们还需要重启node服务器,这里我们需要借助nodemon模块,这里我们使用全局安装nodemon, 在package.json文件中添加一个start命令来启动我们的node服务器。使用nodemon监听build文件并且发生改变之后重新exec运行"node ./build/bundile.js", 这里需要保留双引号,转译一下就好了。

{
  ...
  "scripts": {
    "start": "nodemon --watch build --exec node \"./build/bundile.js\"",
    "build": "webpack --config webpack.server.js --watch"
  }
  ...
}

这时我们启动服务器,这里需要在两个窗口运行下面的命令,因为build后不允许再输入其他命令了。

npm run build
npm run start

这个时候我们修改代码之后页面就会自动更新了。

但是上面的流程还是有些麻烦,我们需要两个窗口来执行命令,我们想要一个窗口将两个命令执行完毕,我们需要借助一个第三方模块npm-run-all,可以全局安装这个模块。然后再package.json中来修改一下。

我们在打包和调试应该是在开发环境,我们创建一个dev命令, 里面执行npm-run-all, --parallel表示并行执行, 执行dev:开头的所有命令。我们将start和build前面追加一个dev:,这个时候我想启动服务器同时监听文件改变运行npm run dev就可以了。

{
  ...
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon --watch build --exec node \"./build/bundile.js\"",
    "dev:build": "webpack --config webpack.server.js --watch"
  }
  ...
}

什么叫做同构

比如下面的代码,我们给div绑定一个click事件,希望点击的时候可以弹出click提示。但是运行之后我们会发现这个事件并没有被绑定上,因为服务器端没办法绑定事件。

src/components/Home/index.js

import React from 'react';

const Home = () => {
  return <div onClick={() => { alert('click'); }}>home</div>
}

export default Home;

一般我们的做法是先将页面渲染出来,然后将相同的代码在浏览器端像传统的React项目一样再去运行一遍,这样的话这个点击事件就有了。

这就衍生出一个同构的概念,我的理解是一套React代码在服务器端执行一次,在客户端再执行一次。

同构就可以解决点击事件无效的问题,首先服务器端执行一次能够正常的展示页面,客户端再执行一次就可以绑定上事件。

我们可以在页面渲染的时候加载一个index.js, 使用app.use创建静态文件的访问路径, 这样访问的index.js就会请求到/public/index.js文件中。

app.use(express.static('public'));

app.get('/', function(req, res) {
  res.send(`
    <html>
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `);
})

public/index.js

console.log('public');

基于这种情况我们就可以将React代码在浏览器中执行一次,我们这里新建一个/src/client/index.js。将客户端执行的代码帖进去。这里我们同构代码使用hydrate代替render。

import React from 'react';
import ReactDOM from 'react-dom';

import Home from '../Components/Home';

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

然后我们还需要在根目录创建一个webpack.client.js文件。入口文件为./src/client/index.js,出口文件到public/index.js

const Path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'index.js',
    path: Path.resolve(__dirname, 'public')
  },
  module: {
    rules: [
      {
        test: /.js?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}

package.json文件中添加一条打包client目录的命令

{
  ...
  "scripts": {
    "dev": "npm-run-all --parallel dev:**",
    "dev:start": "nodemon --watch build --exec node \"./build/bundile.js\"",
    "dev:build": "webpack --config webpack.server.js --watch",
    "dev:build": "webpack --config webpack.client.js --watch",
  }
  ...
}

这样我们启动的时候会编译client运行的文件。再去访问页面的时候就可以绑定好事件了。

下面我们对上面工程的代码进行整理,上面webpack.server.js和webpack.client.js文件有很多重复的地方,我们可以使用webpack-merge插件对内容进行合并。

webpack.base.js

module.exports = {
  module: {
    rules: [
      {
        test: /.js?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}

webpack.server.js

const Path = require('path');
const NodeExternals = require('webpack-node-externals'); // 服务端运行webpack需要运行NodeExternals, 他的作用是将express这类node模块不被打包到js里。

const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const serverConfig = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'bundle.js',
    path: Path.resolve(__dirname, 'build')
  },
  externals: [NodeExternals()],
}

module.exports = merge(config, serverConfig);

webpack.client.js

const Path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');

const clientConfig = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'index.js',
    path: Path.resolve(__dirname, 'public')
  }
};

module.exports = merge(config, clientConfig);

src/server中放置的是服务端运行的代码,src/client放置的是浏览器端运行的js。

到此这篇关于React服务端渲染原理解析与实践的文章就介绍到这了,更多相关React服务端渲染内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Javascript 相关文章推荐
Ctrl+Enter提交内容信息
Jun 26 Javascript
javascript 学习之旅 (3)
Feb 05 Javascript
JavaScript 密码强度判断代码
Sep 05 Javascript
jQuery事件 delegate()使用方法介绍
Oct 30 Javascript
Jquery实现带动画效果的经典二级导航菜单
Mar 22 Javascript
谷歌浏览器不支持showModalDialog模态对话框的解决方法
Sep 22 Javascript
js Date()日期函数浏览器兼容问题解决方法
Sep 12 Javascript
JS实现十字坐标跟随鼠标效果
Dec 25 Javascript
vue的安装及element组件的安装方法
Mar 09 Javascript
vue 中swiper的使用教程
May 22 Javascript
vue-router history模式下的微信分享小结
Jul 05 Javascript
详解.vue文件中style标签的几个标识符
Jul 17 Javascript
vue打开新窗口并实现传参的图文实例
Mar 04 #Vue.js
Vue-router编程式导航的两种实现代码
Mar 04 #Vue.js
手写Vue2.0 数据劫持的示例
Mar 04 #Vue.js
vue3.0封装轮播图组件的步骤
Mar 04 #Vue.js
vue3.0 项目搭建和使用流程
Mar 04 #Vue.js
vue 数据双向绑定的实现方法
Mar 04 #Vue.js
JavaScript中跨域问题的深入理解
Mar 04 #Javascript
You might like
二次元帅气男生排行榜,只想悄悄收藏系列
2020/03/04 日漫
无数据库的详细域名查询程序PHP版(1)
2006/10/09 PHP
一个php作的文本留言本的例子(六)
2006/10/09 PHP
mysql 中InnoDB和MyISAM的区别分析小结
2008/04/15 PHP
php5 图片验证码实现代码
2009/12/11 PHP
php 函数中使用static的说明
2012/06/01 PHP
深入php数据采集的详解
2013/06/02 PHP
Codeigniter校验ip地址的方法
2015/03/21 PHP
php通过修改header强制图片下载的方法
2015/03/24 PHP
Yii中表单用法实例详解
2016/01/05 PHP
php编程实现简单的网页版计算器功能示例
2017/04/26 PHP
PHP实现UTF8二进制及明文字符串的转化功能示例
2017/11/20 PHP
HTTP状态代码以及定义(解释)
2007/02/02 Javascript
JavaScript判断变量是否为undefined的两种写法区别
2013/12/04 Javascript
JS中判断JSON数据是否存在某字段的方法
2014/03/07 Javascript
TypeScript 中接口详解
2015/06/19 Javascript
CSS图片响应式 垂直水平居中
2015/08/14 Javascript
解决拦截器对ajax请求的拦截实例详解
2016/12/21 Javascript
vue中页面跳转拦截器的实现方法
2017/08/23 Javascript
详解Angular调试技巧之报错404(not found)
2018/01/31 Javascript
JS隐藏号码中间4位代码实例
2019/04/09 Javascript
理解Python中的With语句
2016/03/18 Python
Python实现九宫格式的朋友圈功能内附“马云”朋友圈
2019/05/07 Python
python基于pygame实现飞机大作战小游戏
2020/11/19 Python
实例教程 HTML5 Canvas 超炫酷烟花绽放动画实现代码
2014/11/05 HTML / CSS
幼儿园秋游感想
2014/03/12 职场文书
《金孔雀轻轻跳》教学反思
2014/04/20 职场文书
活动宣传策划方案
2014/05/23 职场文书
环保口号大全
2014/06/12 职场文书
综合素质自我评价怎么写
2014/09/14 职场文书
银行业务授权委托书
2014/10/10 职场文书
2015年妇产科工作总结
2015/05/18 职场文书
男生贾里读书笔记
2015/06/30 职场文书
实验室安全管理制度
2015/08/05 职场文书
《抽屉原理》教学反思
2016/02/20 职场文书
解决Golang中goroutine执行速度的问题
2021/05/02 Golang