前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >SSR React同构渲染改造

SSR React同构渲染改造

原创
作者头像
lealc
修改2024-01-15 09:39:34
2920
修改2024-01-15 09:39:34

基于React等框架的前端页面在不太复杂的前提下,可以使用同构渲染来实现同时具备服务端渲染和客户端渲染两者的优点,在调研了一下SSR相关方案之后,采用基于egg.js的同构方案来进行改造尝试,主要使用到的是Egg.js+React+Antd+Less这几个库。

什么是SSR

SSR(Server Side Rendering),顾名思义英文单词翻译过来就是服务端渲染,约在十年前左右,服务端渲染主要是由后端人员来主持改造,前端提供页面模板,后端在模板中填充页面相关的数据然后直接以整个html的形式返回给用户浏览器进行展示,由于在填充数据时已经将原有javascript的功能直接在后端实现,所以在服务器性能比较稳定的前提下,用户侧可以很快看到整个完整页面加载出来,使用体验很好,加之搜索引擎都是基于爬虫来进行收录,服务端渲染对于SEO会有非常好的效果。

经过前端的一段时间发展,出现了Node语言,理论上来说Web侧可以维护SSR和CSR(Client Side Rendering,客户端渲染),但是由于SSR和CSR实现起来完全不同,需要一个页面维护两套代码,太过于蛋疼。

后来涌现了React、Vue等MVVM框架,这类框架是基于数据驱动的Web前端渲染框架,与服务端渲染的思想十分相似,做客户端渲染也比较合适,渐渐就开始了将React等应用于SSR和CSR且只需要维护一份代码,大大减少了人力需要,即现在的同构渲染。同构渲染,即一套代码前提下,可以随意切换服务端渲染和客户端渲染,彻底将前后端进行了分离。

注:随着智能手机的兴起,或许SEO也没有想象中的那么重要,不过了解SSR也是对Web侧学习非常重要的一个环节。

看不懂?

文字太抽象了,来看一下具体什么样的才是SSR。首屏加载完毕,在请求其他js、css之前,已经展示了部分内容的,就是SSR,反之白屏的则是CSR,现在大部分基于React、Vue等框架做出来的都是CSR。

CSR样例:
enter image description here
enter image description here

温习一下React等主流框架基本上都是会在入口js写上这么一句话:

代码语言:js
复制
***.render(   
    <>   
    , document.getElementById('root')
)

上述代码就是将整个React所有的逻辑以及界面装载入root节点,在下图中可以看到在第一个请求之后,没有装载React/Vue打包出来的入口js之前,html中的root节点都是空的,这就是典型的CSR渲染,这种渲染在日益发展的用户机器性能以及网络速度加快的前提下,性能也会十分好,且如果能够优化第一个js装载前的白屏时间,用户体验也会非常不错。

SSR样例

SSR与CSR相反,但是思想是类似的,首先用户请求不会直接通过Web服务器到达我们的静态资源文件,而是通过我们假设的Node服务,由Node服务负责将数据填充入我们事先准备好的代码框架中,所以在首个请求之后我们就可以直接可以看到带有数据的界面,但是由于此时还未加载js和css,所以将不会有样式和交互,所以SSR常规用途是用来优化搜索引擎。

在SSR首次请求之后,React打包出来的js将会完全接管后续的交互逻辑以及网络请求,这里就是同构渲染的奇妙之处,既有SSR优化搜索引擎的好处,又有现代Web框架的性能,维护起来也相当方便。

同构渲染还有一个好处就是,在Node服务处理SSR渲染失败时可以直接切换到CSR渲染模式,即提前生成好的静态文件直接返回,十分健壮。

SSR要怎么做呢?

笔者采用的方案是egg.js官方开源的基于egg.js库构建的同构渲染框架官方文档在这,框架的设计思路如下

引用原话:整个设计实现遵循插件化,可组装,可扩展,可替换思路进行设计实现,充分利用 Egg,React,Webpack 相关周边生态,不进行任何的深度封装,平时怎么写 Egg,React 代码就怎么写,同时又可以自由组合以及扩展;重点解决各种技术框架整合复杂性,开发流程与体验问题,可扩展性,稳定性以及性能等工程化问题。这样才有了整个 easy 的建设体系以及不断的进行技术演进。

本次博客改造中SSR框架里面package.json如下,供大家借鉴:(省略了不必要的信息)

代码语言:json
复制
{  
"scripts": {    
        "deploy": "NODE_ENV=production egg-scripts start --port=8080 --daemon --title=egg-server-lealf --env=prod",    
        "build": "easy build",   
        "dev": "egg-bin dev --port=8080",   
        "start": "egg-scripts start --port=8080",   
        "debug": "egg-bin debug",    
        "clean": "easy clean",    
        "kill-port": "sudo kill -9 $(lsof -i:8080 -t)",    
        "lint": "eslint .",   
        "fix": "eslint --fix .",   
        "ii": "npm install --registry https://registry.npm.taobao.org"
    },  
    "dependencies": {   
        "antd": "^3.0.3",  
        "axios": "^0.21.0", 
        "cross-env": "^5.0.0",   
        "egg": "^2.1.0",  
        "egg-cors": "^2.0.0",  
        "egg-logger": "^1.5.0",    
        "egg-scripts": "^2.10.0",   
        "egg-validate": "^1.0.0",   
        "egg-view-react-ssr": "^2.1.0",   
        "extend": "~3.0.0",  
        "history": "^4.7.2",    
        "lodash": "^4.17.4",    
        "mockjs": "^1.0.1-beta3",  
        "moment": "^2.17.1",   
        "react": "^16.0.0",    
        "react-dom": "^16.0.0",   
        "react-redux": "^5.0.6",   
        "react-router": "^4.2.0",    
        "react-router-config": "^1.0.0-beta.4",  
        "react-router-dom": "^4.2.2",   
        "react-router-redux": "^4.0.8",   
        "redux": "^3.7.2"  
    },  
    "devDependencies": {    
        "autod-egg": "^1.0.0",   
        "easywebpack-cli": "^4.0.0", 
        "easywebpack-react": "^4.0.0",  
        "egg-bin": "^4.9.0",  
        "egg-webpack": "^4.0.0",   
        "egg-webpack-react": "^2.0.0",   
        "eslint-config-egg": "^5.1.1",   
        "imagemin-webpack-plugin": "^1.5.2",  
        "ip": "^1.1.5",   
        "less": "^2.7.2",   
        "less-loader": "^4.1.0"
    },  
    "engines": {   
        "node": ">=6.0.0" 
    },
    "ci": {   
        "version": "6, 8, 9" 
    }
}

详细步骤不赘述了,按照官方文档来进行操作即可,需要注意以下几个要点:

1、npm install easywebpack-cli -g来下载全局的easy相关的命令,后面可以命令行来运行,可以不使用npm run ${script}来运行项目,否则则只能使用npm run来运行了。

2、根据自己需要来进行选用TypeScript、Ant、Redux、React Router等,我这里只使用了Antd。

3、本地开发只需要运行npm run dev即可。会占用9000、9001以及暴露对外访问的7001端口(此端口可以在script里面进行自定义,参考前文的package.json代码)。

本地开发启动 Webpack 构建, 默认配置文件为项目根目录 webpack.config.js 文件。 SSR 需要配置两份 Webpack 配置,所以构建会同时启动两个 Webpack 构建服务。web 表示构建 JSBundle 给前端用,构建后文件目录 public, 默认端口 9000; node 表示构建 JSBundle 给 Node 服务端渲染用,构建后文件目录 app/view 默认端口 9001. 本地构建是 Webpack 内存构建,文件不落地磁盘,所以 app/view 和 public 在本地开发时,是看不到文件的。 只有发布模式(npm run build)才能在这两个目录中看到构建后的文件内容。

4、本地开发没问题,在部署文件时,一定需要先运行build确保以下步骤均正常执行,生成了view文件夹和public文件夹中的文件,才能启动项目

1) 启动 Webpack 构建, 2) 文件落地磁盘服务端构建的文件放到 app/view 目录 3) 客户端构建的文件放到 public 目录 4) 生成的 manifest.json 放到 config 目录 5) 构建的文件都是 gitignore的,部署时请注意把这些文件打包进去

5、启动应用,默认给的npm run start不是后台启动项目,可以使用deamon参数来在服务器上后台启动,参考前文的package.json文件。(官方给的推荐命令:npm run backend(对应scripts命令:nohup egg-scripts start --port 7001 --workers 4 &))

6、项目文件结构做一下简单说明

代码语言:shell
复制
├── app                         // 根目录
│   ├── controller              // 控制器,类似于以前的MVC框架中的C,主要用户存储数据装载逻辑,处理请求
│   │   ├── test
│   │   │   └── test.js
│   ├── extend
│   ├── lib
│   ├── middleware            // 中间件目录,可自定义实现中间件,然后在洋葱模型基础上装载到此框架上
│   ├── mocks                   // mocks数据,用于在后端未能联调,自行mock数据来完成开发,可选用
│   ├── proxy                   // 代理相关设置,一般不用
│   ├── router.js               //  用户请求路由
│   ├── view                    // 存放build打包出来的文件,需要用.gitkeep保证空目录提交到代码线上
│   │   ├── home
│   │   │     └── home.js                 // 服务器编译的jsbundle文件
│   └── web                               // 前端工程目录
│       ├── asset                         // 存放公共js,css资源
│       ├── framework                     // 前端公共库和第三方库
│       │   └── entry                          
│       │       ├── loader.js              // 根据jsx文件自动生成entry入口文件loader
│       ├── page                           // 前端页面和webpack构建目录, 也就是webpack打包配置entryDir
│       │   ├── home                       // 每个页面遵循目录名, js文件名, scss文件名, jsx文件名相同
│       │   │   ├── home.scss
│       │   │   ├── index.jsx
│       └── component                         // 公共业务组件, 比如loading, toast等, 遵循目录名, js文件名, scss文件名, jsx文件名相同
│           ├── loading
│           │   ├── loading.scss
│           │   └── loading.jsx
│           ├── test
│           │   ├── test.jsx
│           │   └── test.scss
│           └── toast
│               ├── toast.scss
│               └── toast.jsx
├── config
│   ├── config.default.js               // 项目默认配置
│   ├── config.local.js
│   ├── config.prod.js
│   ├── config.test.js
│   └── plugin.js
├── doc
├── index.js
├── webpack.config.js                      // easywebpack-cli 构建配置
├── public                                 // webpack编译目录结构, render文件查找目录
│   ├── static
│   │   ├── css
│   │   │   ├── home
│   │   │   │   ├── home.07012d33.css
│   │   │   └── test
│   │   │       ├── test.4bbb32ce.css
│   │   ├── img
│   │   │   ├── change_top.4735c57.png
│   │   │   └── intro.0e66266.png
│   ├── test
│   │   └── test.js
│   └── vendor.js                         // 生成的公共打包库

7、${root}/config/config.local.js egg-webpack 插件本地配置,本地开发通过 egg-webpack 插件实现在 Egg 中进行 Webpack 构建

代码语言:js
复制
// ${root}/config/config.local.js
exports.webpack = { // 默认是如下配置,可不配置  
    browser: 'http://localhost:7001', // 配置 false 可以关闭自动打开浏览器  
    webpackConfigList: require('@easy-team/easywebpack-react').getWebpackConfig()
};

8、egg是根据web构建生成的manifest.json来进行依赖静态资源注入的。

SEO 实现

Egg + React SSR SEO 实现MVVM 服务端渲染相比前端渲染,支持 SEO,更快的首屏渲染,相比传统的模板引擎,更好的组件化,前后端模板共用。在 Egg + React 的方案里面, HTML head 里面 meta 信息也作为 React 服务端渲染的一部分, 和普通的数据绑定没有什么差别。

layout.jsx 组件实现
代码语言:jsx
复制
// framework/layout/layout.jsx 组件
import React, { Component } from 'react';
export default class Layout extends Component {
  render() {
    return <html>
    <head>      
        <title>{this.props.title}</title>   
        <meta charSet="utf-8"></meta>     
        <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"></meta>                   <meta name="keywords" content={this.props.keywords}></meta>       
        <meta name="description" content={this.props.description}></meta>     
        <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon"></link>  
    </head>    
    <body>
        <div id="app">
            {this.props.children}
        </div>
    </body> 
    </html>; 
    }
}
服务端统一入口 Webpack loader 实现

下面是一个简单的 Webpack SSR 渲染 Entry Loader 模板实现, 结合 layout.jsx, 通过统一入口实现 React 初始化。 具体页面无需关心 HTML, header, body 以及热更新之列的配置, 只需要编写组件自己的功能实现。 服务端渲染出来的是完整的 HTML 结构,所以这里需要 layout.jsx

代码语言:jsx
复制
// app/web/framework/entry/server-loader.js
module.exports = function() {
  this.cacheable();
  return `   
      import React, { Component } from 'react';  
      import Layout from 'framework/layout/layout.jsx';
      import Header from 'component/header/header.jsx';   
      import App from '${this.resourcePath.replace(/\\/g, '\\\\')}';  
      export default class Page extends Component {     
          render() {       
            return <Layout {...this.props}><App {...this.props} /></Layout>; 
          } 
      } 
  `;
};
客户端统一入口 Webpack loader 实现

下面是一个简单的 Webpack 前端渲染 Entry Loader 模板实现, 通过统一入口实现 React 初始化。 这里无需 layout.jsx, 因为SSR渲染时已经把 HTML 渲染好了。前端只需要渲染 <div id="app"></div>的内容。

代码语言:jsx
复制
// app/web/framework/entry/client-loader.js
module.exports = function() {
  this.cacheable();
  return `  
      import React from 'react';  
      import ReactDom from 'react-dom';    
      import { AppContainer } from 'react-hot-loader';  
      import Entry from '${this.resourcePath.replace(/\\/g, '\\\\')}';   
      const state = window.__INITIAL_STATE__;    
      const render = (App)=>{     
      ReactDom.hydrate(EASY_ENV_IS_DEV ? <AppContainer><App {...state} /></AppContainer> : <App {...state} />, document.getElementById('app'));   
      };   

      if (EASY_ENV_IS_DEV && module.hot) {  
      module.hot.accept('${this.resourcePath.replace(/\\/g, '\\\\')}', () => { render(Entry) });   
      }    
      render(Entry);  
  `;
};

遇到的一些问题

按照官网文档来,大致流程是没有问题的,大部分时候是可以正常跑起来。但是也会遇到某些问题,例如有些电脑上可能会因为9001/7001等端口被系统应用占用,导致代码无法正常运行并报错。

1、端口被占用,简单介绍一下两种情况下端口占用,

  • 一种是应用进程占用端口,cmd -> netstat -ano | findstr "${PORT}"

这种监听一般比较好解决,直接找到最后一个数字例如上图中的52724,即为应用的PID,打开任务管理器,找到对应的pid进程关掉即可。

  • 一种是系统进程占用端口,用上面方法找到端口占用进程PID为4,为系统进程

用程序,cmd -> netsh http show servicestate,找到对应端口里面描述的进程PID,关掉即可。

2、编译/运行失败,失败可能有多种原因,汇总一下笔者遇到的各种原因

  • 端口被占用 --- 按照第1点解决即可
  • public文件夹无法读写 --- linux上解决权限即可
  • view文件夹未创建 --- 在代码线上给view文件夹里加上.gitkeep文件

3、SSR首页加载时间过长,超过了3秒

在首页将所有文章都拉取到Node服务中,发现由于文章主体内容过多导致首页加载时间太慢

修改后台接口增加参数,支持不拉去文章主体内容即可,可以看到减少请求返回的数据时,效果十分明显

4、gzip配置在nginx层,相关gzip的配置如下:

代码语言:nginx
复制
gzip on;                 #决定是否开启gzip模块,on表示开启,off表示关闭;
gzip_min_length 1k;      #设置允许压缩的页面最小字节(从header头的Content-Length中获取) ,当返回内容大于此值时才会使用gzip进行压缩,以K为单位,当值为0时,所有页面都进行压缩。建议大于1k
gzip_buffers 4 16k;      #设置gzip申请内存的大小,其作用是按块大小的倍数申请内存空间,param2:int(k) 后面单位是k。这里设置以16k为单位,按照原始数据大小以16k为单位的4倍申请内存
gzip_http_version 1.1;   #识别http协议的版本,早起浏览器可能不支持gzip自解压,用户会看到乱码
gzip_comp_level 2;       #设置gzip压缩等级,等级越底压缩速度越快文件压缩比越小,反之速度越慢文件压缩比越大;等级1-9,最小的压缩最快 但是消耗cpu
gzip_types text/plain application/x-javascript text/css application/xml;    #设置需要压缩的MIME类型,非设置值不进行压缩,即匹配压缩类型
gzip_vary on;            #启用应答头"Vary: Accept-Encoding"
 
gzip_proxied off;
nginx做为反向代理时启用,off(关闭所有代理结果的数据的压缩),expired(启用压缩,如果header头中包括"Expires"头信息),no-cache(启用压缩,header头中包含"Cache-Control:no-cache"),
no-store(启用压缩,header头中包含"Cache-Control:no-store"),private(启用压缩,header头中包含"Cache-Control:private"),no_last_modefied(启用压缩,header头中不包含
  "Last-Modified"),no_etag(启用压缩,如果header头中不包含"Etag"头信息),auth(启用压缩,如果header头中包含"Authorization"头信息)
 
gzip_disable msie6;
(IE5.5和IE6 SP1使用msie6参数来禁止gzip压缩 )指定哪些不需要gzip压缩的浏览器(将和User-Agents进行匹配),依赖于PCRE库

5、webpack常用解决方案(作为参考,选用)

  • 自动构建HTML,可压缩空格,可给引用的js加版本号或随机数:html-webpack-plugin
  • 处理CSS:css-loader与style-loader
  • 处理LESS:less-loade与less
  • 提取css代码到css文件中: extract-text-webpack-plugin
  • 开发环境下的服务器搭建:webpack-dev-server
  • 解析ES6代码:babel-core babel-preset-env babel-loader
  • 解析ES6新增的对象函数:babel-polyfill
  • 解析react的jsx语法:babel-preset-react
  • 转换相对路径到绝度路径:nodejs的path模块
  • 给文件加上hash值:chunkhash,hash
  • 清空输出文件夹之前的输出文件:clean-webpack-plugin
  • 模块热替换:NamedModulesPlugin和HotModuleReplacementPlugin
  • 跨平台使用环境变量: cross-env
  • 处理图片路径: file-loader和html-loader
  • 图片压缩:image-webpack-loader
  • 定位源文件代码:source-map
  • 分离生产环境和开发环境的配置文件
  • webpack输出文件体积与交互关系的可视化:webpack-bundle-analyzer

6、引入webpack-bundle-analyzer之后,build会提示端口冲突

解决:将插件的分析方式改为static,就不会有冲突了,相应的配置如下:

代码语言:json
复制
new BundleAnalyzerPlugin(    {       
    //  可以是`server`,`static`或`disabled`。        
    //  在`server`模式下,分析器将启动HTTP服务器来显示软件包报告。       
    //  在“静态”模式下,会生成带有报告的单个HTML文件。       
    //  在`disabled`模式下,你可以使用这个插件来将`generateStatsFile`设置为`true`来生成Webpack Stats JSON文件。        
    analyzerMode: 'static',        
    //  将在“服务器”模式下使用的主机启动HTTP服务器。 
    analyzerHost: '127.0.0.1',       
    //  将在“服务器”模式下使用的端口启动HTTP服务器。        
    analyzerPort: 8888,        
    //  路径捆绑,将在`static`模式下生成的报告文件。        
    //  相对于捆绑输出目录。        
    reportFilename: 'report.html',        
    //  模块大小默认显示在报告中。        
    //  应该是`stat`,`parsed`或者`gzip`中的一个。       
    //  有关更多信息,请参见“定义”一节。       
    defaultSizes: 'parsed',       //  在默认浏览器中自动打开报告        
    openAnalyzer: true,       //  如果为true,则Webpack Stats JSON文件将在bundle输出目录中生成        
    generateStatsFile: false,        //  如果`generateStatsFile`为`true`,将会生成Webpack Stats JSON文件的名字。        
    //  相对于捆绑输出目录。        
    statsFilename: 'stats.json',        //  stats.toJson()方法的选项。        
    //  例如,您可以使用`source:false`选项排除统计文件中模块的来源。        
    //  在这里查看更多选项:https:  
    //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21        
    statsOptions: null,       
    logLevel: 'info' // 日志级别。可以是'信息','警告','错误'或'沉默'。    
    }
)

7、分析打包的文件,发现moment库很大

在打包出来里面,moment在gzip下也有50+K

仔细可以看到是引入了大部分语言包导致,考虑到后续语言包可能会引入,建议最好解决方案是在打包中排除moment,以script标签引入cdn上面的moment即可,无需改动代码。

代码语言:javascript
复制
// webpack.config.js修改如下
const webpack = require('webpack');

module.exports = {
    externals:  {   
        'moment': 'moment',   
        'moment/locale/zh-cn' : 'moment.locale'
    },
    plugins : [   
        new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
    ]
}

在index.html中引入cdn的moment即可。

代码语言:html
复制
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js">
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/locale/zh-cn.min.js">
</script>

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是SSR
    • 看不懂?
      • CSR样例:
      • SSR样例
  • SSR要怎么做呢?
    • SEO 实现
      • layout.jsx 组件实现
      • 服务端统一入口 Webpack loader 实现
      • 客户端统一入口 Webpack loader 实现
  • 遇到的一些问题
相关产品与服务
消息队列 TDMQ
消息队列 TDMQ (Tencent Distributed Message Queue)是腾讯基于 Apache Pulsar 自研的一个云原生消息中间件系列,其中包含兼容Pulsar、RabbitMQ、RocketMQ 等协议的消息队列子产品,得益于其底层计算与存储分离的架构,TDMQ 具备良好的弹性伸缩以及故障恢复能力。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档