Skip to content
HMbird-ssr是一个基于React16+ ReactRouter4.0 koa2.0搭建的一个node服务端渲染框架;
Branch: master
Clone or download
Latest commit 0c9f56f Mar 11, 2019
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
server
src
.babelrc
.eslintignore
.eslintrc
.gitignore
LICENSE
README.md
package.json
webpack.config.base.js
webpack.config.js
webpack.config_build.js
webpack.config_server.js

README.md

更新记录:

2018-12-26

  • 新增本地导出服务端渲染模板功能
 npm run output  //导出所有项目服务端渲染模板
 npm run output pagename //导出某一个项目服务端渲染模板
  • 解决服务端渲染路由页面刷新时无法匹配到相应的路由问题

2018-12-11

  • 解决windows环境下项目构建异常(loader配置include绝对路径导致)

2018-11-29

  • 解决页面进入router之后 刷新页面事路由页面404问题;
  • 服务端项目可构建编译npm run build:server

hmbird-ssr是一个基于React16+ ReactRouter4.0 koa2.0搭建的一个node服务端渲染框架;目前已经在二手车业务线中进行项目开发和部署上线,支持SEO,提升首屏加载速度

框架特性:

1、静态单页面应用无配置支持ssr方式

2、路由搭配React-Router4.0自由选择服务端渲染和客户端渲染

3、静态资源(js,css,images)版本号更新,部署方式支持内置cdn和服务端部署 (默认服务端)

4、导出静态页面(构建服务端渲染后的模板)     

5、css编译支持less,scss,postcss自动补全autoprefixer

6、搭配eslint pre-commit格式化校验代码

7、服务端渲染启动预加载 && 异常降级客户端渲染

8、服务端渲染模板缓存  

服务端渲染原理

react服务端为了支持服务端渲染,在react-dom模块中发布了server模块,其中主要api是:renderToString和renderToStaticMarkup

  • renderToString:将一个react组件渲染成html字符串,react@15版本中html中会输出data-reactid标识组件
  • renderToStaticMarkup 功能和前者类似,不带data-reactid标识,节省服务端流量

在react@15版本ssr方案中,renderToStaticMarkup因为不带data-reactid标识,实际上在客户端渲染的时候,react是没法diff组件虚拟dom;react是会重新通过客户端渲染的dom覆盖掉服务端吐出来的html,会闪一下。

16版本推出新的ssr方法。

react@16+向下兼容,之前ssr项目在15上能运行,使用react@16后可以直接使用.

  • renderToNodeStream 对标 renderToString
  • renderToStaticNodeStream 对标 renderToStaticMarkup ,此方法无论服务端有没有渲染,客户端都会重新渲染,在存静态页面时使用可得到好的渲染速度

这两个新的api返回值是utf-8编码的字节流

服务端

// use koa
const getStream = require('get-stream');
import ReactDOMServer from 'react-dom/server';
let Html = '';
let Htmlstream = ReactDOMServer.renderToNodeStream(<App/>);
try {
        Html = await getStream(Htmlstream);
    } catch (error) {
        console.log('流转化字符串异常,降级使用客户端渲染!');
    }
// 把渲染后的 React HTML 插入到 div 中
let document = data.replace(/<div id="app"><\/div>/, `<div id="app">${Html}</div>`);
// 把响应传回给客户端
ctx.response.body = document; 

客户端

React 16现在有两种不同的客户端渲染方法:在客户端呈现内容时,使用render() 方法,如果你在服务端渲染结果之上再次渲染则使用hydrate()方法。 因为向下兼容,可以在16中继续使用render,但如果服务端渲染后再次调用客户端渲染时会出现警告⚠️ react-dom.development.js:10376 Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.

所以在服务端渲染时我们使用hydrate代替render

import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import ReactDom from 'react-dom';
import App from './app';

let inBrowser = typeof window !== 'undefined';//服务端渲染时node环境不支持window document等浏览器宿主环境全局变量
let ReactRender = process.env.NODE_ENV == 'development' ? ReactDom.render : ReactDom.hydrate;
inBrowser &&
    ReactRender(
        <Router basename="/hmbird_router/with-react-router">
            <App />
        </Router>,
        document.getElementById('app')
    );
module.exports = App;

在客户端我们通过NODE_ENV控制使用render和hydrate,因为在和路由搭配的过程中,使用hydrate出现了一个警告Warning: Expected server HTML to contain a matching<div>in<div>

⚠️ :使用hydrate时,需要保证服务端渲染和客户端渲染的内容保持一致,详情可参考React填坑记(四):render !== hydrate

同构方案

同构方案下,我们编写的js代码将会在服务端和客服端两种环境下运行,即在通过服务端渲染优化打开首屏的速度,然后再将交互,路由交给客户端控制,这和之前用jsp、php、Velocity类似,不同的是我们只需要维护一套js代码,不用单独编写供服务端渲染的模板。

关键点:

在node环境server端使用import等es6语法

在最新版node版本中,基本实现了大部分es6的语法,但对于import这样的引入模块方式依然是没有得到支持的。 解决方案:

  • 使用node-bable代替node命令
  • 引入babel-regisiter 忽略掉css,image等
  • 先构建代码 然后执行服务端渲染

静态资源处理方案

代码中我们import了图片,svg,css等非js资源,在客户端webpack的各种loader帮我们处理了这些资源,在node环境中单纯的依靠babel-regisiter是不行的,执行renderToString()会报错,非js资源没法处理

webpack编译方案:

1、通过extract-text-webpack-plugin插件单独打包css,
2、通过url-loader处理image,图片小于8k的直接编译成base64,大于8k则构建生成路径方式
3、通过HTMLWebpackPlugin自动生成原始模板
最后我们得到一个目录结构:
├── dist //构建编译目录
│   ├── favicon.ico 
│   ├── images
│   │   └── fd4f415c.addressIcon.jpg
│   ├── vendor //copy form src
│   │   ├── 15
│   │   ├── 16.0.0
│   │   ├── 16.6.0
│   ├── with-react //编译后项目 可直接静态部署
│   │   ├── with-react.css
│   │   ├── with-react.html
│   │   └── with-react.js
├── offline  //node沙箱环境配置
├── online //node线上环境配置
├── package.json
├── server
│   ├── app.js //项目基础信息
│   ├── config_ssr.js //服务端渲染相关配置
│   ├── pageInit.js //服务端入口文件
│   ├── router.js //路由
│   └── start.js  // 服务端启动文件
├── src
│   ├── components  //组件
│   ├── favicon.ico
│   ├── images  //图片静态资源
│   │   └── with-react
│   ├── index.html //首页
│   ├── mock
│   │   └── test.json 
│   ├── page //多入口项目文件
│   │   ├── with-react  //项目1
│   │   └── with-react-router  //项目2
│   ├── skin //基础样式
│   │   ├── base.scss
│   │   └── mixins.scss
│   ├── template.html //html模板 如果项目文件夹中没有找到项目同名html则使用默认模板
│   ├── utils //一些工具类
│   │   ├── cookie.js
│   │   └── util.js
│   └── vendor
│       ├── 15
│       ├── 16.0.0
│       ├── 16.6.0
├── webpack.config.base.js
├── webpack.config.js //开发环境配置
└── webpack.config_build.js //服务端渲染&&生产环境配置

如何打造前后端渲染使用同一个入口文件

在入口文件中,通过判断当前环境选择渲染方式

//客户端 with-react
'use strict';
import './with-react.scss';
import React from 'react';
import ReactDom from 'react-dom';
import App from './app';

var inBrowser = typeof window !== 'undefined';//node环境中没有window对象
inBrowser && ReactDom.hydrate(<App />, document.getElementById('app'));
module.exports = App;

//构建后,在dist目录下生成构建后的with-react项目文件
--dist
   --with-react
        --with-react.js
        --with-react.html
        --with-react.css
//服务端
import WithReact from '.dist/with-react/with-react.js';//初始化ssr页面入口文件导入配置
import ReactDOMServer from 'react-dom/server';
let  SsrHtml = ReactDOMServer.renderToString(<WithReact/>);
//读取with-react.html
let data = await render( 'with-react.html' )
// 将SsrHtml注入到data中 模板中路径
let document = data.replace(/<div id="app"><\/div>/, `<div id="app">${SsrHtml}</div>`);
// 返回客户端
ctx.response.body = document; 
  • 服务端渲染路由自动分配

构建每一个项目入口文件夹都统一命名,所以可以通过读取dist文件夹自动分配路由

//pageInit.js  
/* * @Author: zhang dajia * @Date: 2018-11-05 14:58:28 
 * @Last Modified by: zhang dajia
 * @Last Modified time: 2018-12-26 16:32:59
* @Last  description: 服务端启动时初始化page入口文件 */
const fs = require('fs');
const path = require('path');
const targetDistPath = path.join(__dirname+'./../dist');
const {ssrPageFilter} = require('./config_ssr');
let pageComponent = {};
/**
 * 读取dist目录下入口文件夹路径 require引入存放到PageCompoent中
 */
let pageInit = ()=>{
    return new Promise((resolve,reject)=>{
        try {
            fs.readdir(targetDistPath,function(err,files){
                if(err){
                    reject(error);  
                }else{
                    for (const cateName of files) {
                        console.log(`初始化导入${cateName}`);
                        if (cateName != "index.html"&&cateName!=".DS_Store"&&ssrPageFilter.indexOf(cateName)=="-1"){
                            var component= require(targetDistPath+"/"+cateName+"/"+cateName);
                           pageComponent[cateName] = component;
                           console.log(`import导入模块${cateName}`);
                        }else{
                            console.log("过滤页面---"+cateName);
                        }
                    }
                    console.log('end...'); 
                    resolve(pageComponent);
                }
            }) 
        } catch (error) {
            reject(error);
        }
        
    })
}
(async function(){
    pageComponent = await pageInit();
    console.log(`初始化服务端dist目录下所有的入口文件:${JSON.stringify(pageComponent)}`);
}());
module.exports = pageComponent;

//router.js

/* * @Author: zhang dajia * @Date: 2018-11-05 14:16:25 
 * @Last Modified by: zhang dajia
 * @Last Modified time: 2018-11-23 14:00:37
* @Last  description: undefined */
const React =require('react');
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
const router = require('koa-router')();
import fs from 'fs';
const getStream = require('get-stream');
import PageComponent from './pageInit';//初始化ssr页面入口文件导入配置 node启动时执行
/**
 * 用Promise封装异步读取文件方法
 * @param  {string} page html文件名称
 * @return {promise}      
 */
function render( pagename ) {
    return new Promise(( resolve, reject ) => {
      let viewUrl = `./dist/${pagename}/${pagename}.html`
      fs.readFile(viewUrl, "utf8", ( err, data ) => {
        if ( err ) {
          reject( err )
        } else {
          resolve( data )
        }
      })
    })
}

router.get('/hmbird/:pagename', async (ctx, next) => {
    let pagename = ctx.params.pagename;
    let App = PageComponent[pagename];
    let Htmlstream = '';
    let Html = '';
    try {
        Htmlstream = ReactDOMServer.renderToNodeStream(<App/>);
    } catch (error) {
        console.log('服务端渲染异常,降级使用客户端渲染!');
    }
    // 加载 index.html 的内容
    let data = await render( pagename );
    try {
        Html = await getStream(Htmlstream);
    } catch (error) {
        console.log('流转化字符串异常,降级使用客户端渲染!');
    }
    // 把渲染后的 React HTML 插入到 div 中
    let document = data.replace(/<div id="app"><\/div>/, `<div id="app">${Html}</div>`);
    // 把响应传回给客户端
    ctx.response.body = document; 
});
module.exports = router;
  • 服务端渲染入口文件过滤

dist目录下生成的images,vendor等静态资源不需要导入到PageComponent中,通过配置文件进行过滤

// config_ssr.js
module.exports = {
    ssrPageFilter:['favicon.ico','vendor','images'] // 过滤掉不需要服务端渲染的页面 默认favicon.ico vendor images不要动  
}

搭配React-Router4.0

服务端渲染与客户端渲染的不同之处在于其路由是没有状态的,所以我们需要通过一个无状态的router组件 来包裹APP,通过服务端请求的url来匹配到具体的路由数组和其相关属性。 所以我们在客户端使用 BrowserRouter,服务端则使用无状态的 StaticRouter

推荐一篇基础讲解RP4的博客文章初探 React Router 4.0

const React =require('react');
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
const router = require('koa-router')();
import fs from 'fs';
const getStream = require('get-stream');
import PageComponent from './pageInit';//初始化ssr页面入口文件导入配置
router.get('/hmbird_router/:pagename',async(ctx,next)=>{
    const context = {}
    var pagename = ctx.params.pagename;
    let App = PageComponent[pagename];
    let Html = '';
    let Htmlstream = '';
    try {
        Htmlstream = ReactDOMServer.renderToNodeStream(
            <StaticRouter
            location={ctx.request.url}
            context={context}
            >
            <App/>
            </StaticRouter>
        );
    } catch (error) {
        console.log('服务端渲染异常,降级使用客户端渲染!');
    }
    if (context.url) {
        ctx.response.writeHead(301, {
        Location: context.url
        })
        ctx.response.end()
    } else {
        // 加载 index.html 的内容
        let data = await render( pagename );
        try {
            Html = await getStream(Htmlstream);
        } catch (error) {
            console.log('流转化字符串异常,降级使用客户端渲染!');
        }
        // 把渲染后的 React HTML 插入到 div 中
        let document = data.replace(/<div id="app"><\/div>/, `<div id="app">${Html}</div>`);
        // 把响应传回给客户端
        ctx.response.body = document; 
    }
});

使用BrowserRouter还是HashRouter

两者区别:

  • BrowserRouter使用HTML5 history API,保证UI界面和URL保存同步

采用这种方式需要后端或者Nginx配置通配路由,比如在某个路径下重定向到模板首页 否则路由刷新页面时会404

服务端配合BrowserRouter配置动态路由

服务端渲染,建议采用BrowserRouter,当然这需要服务端或者运维Nginx进行配合,否则页面路由刷新后会访问真正的服务端请求,会直接404;我们使用koa-router路由嵌套方案。

const Router = require('koa-router');
const router_static = new Router();
const router_dynamic = new Router();
//主入口文件路由
router_static.get('/', async (ctx, next) => {
   let document = await renderServerStatic(ctx,next);
   ctx.response.body = document; 
});
//router主入口文件路由
router_dynamic.get('/',async(ctx,next)=>{
    console.log('匹配到页面'+ctx.params.pagename)
    let document = await renderServerDynamic(ctx,next);
    ctx.response.body = document; 
});
//router页面router路由 防止刷新路由页面404
router_dynamic.get('/:pagepath',async(ctx,next)=>{
    console.log('匹配到页面路由'+ctx.params.pagepath)
    let document = await renderServerDynamic(ctx,next);
    ctx.response.body = document; });
forums.use('/hmbird/:pagename',router_static.routes(),router_static.allowedMethods());//可以匹配到hmbird/xxx请求
forums.use('/hmbird_router/:pagename',router_dynamic.routes(),router_dynamic.allowedMethods());//可以匹配到hmbird_router/xxx 或者 hmbird_router/xxx/sss
//如果项目有更深层目录 再进行调整router_dynamic

服务端渲染开发注意事项

DOM保持一致性

保持客户端渲染和服务端渲染输出一致的DOM结构

服务端渲染组件生命周期差异

服务端上 Component 生命周期只会到 componentWillMount,客户端则是完整的。如果项目中使用到window,location等node不支持的属性放到componentDidMount时间中通过state更新组件

浏览器支持支持

IE11 和所有的现代浏览器使用了@babel/preset-env。为了支持 IE11,需要全局添加Promise的 polyfill。有时你的代码或引入的其他 NPM 包的部分功能现代浏览器不支持,则需要用 polyfills 去实现。

效果

image

可以看见使用服务的渲染后,首屏加载速度得到了很大的提升。

后续优化

  • 服务端渲染缓存(目前因为把客户端渲染执行时机放到了服务端,加重了服务端的压力。但不同于客户端多样性,服务端是统一的,所以给了我们利用缓存的机会,每一个服务端渲染项目可只执行一遍,后面都走缓存)
  • 服务端渲染静态资源导出 (目前项目可直接部署到服务器,但有的线上项目入口文件多,需要直接把静态资源发给RD覆盖已有模板)
  • node to java and more...

如何使用

安装

npm install -g hmbird-cli

hmbird init yourproject

开发启动

npm run dev 

打包

npm run build //打包所有
npm run build pageXXX //打包某个page入口

服务端启动

npm start

服务端访问 (路由一级路径可自行替换)

  • 普通静态页面 localhost:8001/hmbird/pagexxx
  • router页面 localhost:8001/hmbird_router/pagexxx

欢迎各位试用和交流 附上项目地址hmbird-ssr

参考资料

You can’t perform that action at this time.