构建用于生产的React静态化单页面服务
构建用于生产的React静态化单页面服务
随风溜达的向日葵 发表于6个月前
构建用于生产的React静态化单页面服务
  • 发表于 6个月前
  • 阅读 361
  • 收藏 4
  • 点赞 0
  • 评论 0

新睿云服务器60天免费使用,快来体验!>>>   

摘要: 这是一篇由零开始说明如何使用nodejs搭建react的静态资源服务的文章。服务整合react-route和redux开发一个单页面应用,实现前后端同构的服务端渲染、资源文件高效压缩、资源文件分片加载、异步组装数据等。除了说明如何搭建nodejs服务,相信阅读完之后还能解决关于react打包文件过大、单页面应用一次性加载资源过大、图片文件打包导致支援文件过大的问题。

前言

React 作为一项热门的前端开发技术,现在使用它的团队越来越多。之前也介绍了react 的所有的特性,但是仅仅了解怎么开发 react 只走了万里长征的第一步,将 react 投入到真实应用还会遇到各种各样的问题。

例如SEO需要静态化怎么办?单页面应用一次性加载的资源过大怎么办?样式代码直接写在.js中影响加载怎么办?

本文介绍如何将 react 整套技术投入到实际生产应用中,主要包括以下内容:

  1. 使用 react 实现单页面应用。
  2. 整合 react-route 在 nodejs 服务中实现页面静态化。
  3. 使用 require.ensure 对代码、资源文件进行分片。
  4. 按需从服务器异步加载不同的 react 组件。
  5. 解决 webpack 中使用 require.ensure 加载闪现的问题。
  6. 解决 react 服务端渲染在浏览器重新渲染的问题。
  7. 通过 redux 实现在服务端异步加载数据,并同步前后端数据。
  8. 单独提取样式文件。
  9. 最大化分解和压缩所有资源文件。

在阅读之前需要了解的:

React 整个生态发展的非常迅速(混乱)。昨天还感觉在用 webpack1.x ,现在都已经弄出3.0版本了。哥react-route3.x已经玩得贼溜,现在人家又推出了4.x版本了。而且这些关键组件或工具升级之后会导致之前已经写好的代码无法使用。

本文所使用的所有第三方开源工具都在开发项目时使用的是最新版本(webpack 官方已经升级到3.0,我们开发时最新版本还是2.6.1,不过配置上并没有多大改变)。

特别要说明的是react-route 。4.x版和3.x版在设计思路上发生了巨大的转变,4.x将dom部分独立出来 都以高阶组件的方式提供各种功能。服务端异步渲染仅仅提供了一个 <StaticRouter> 组件。代码分片异步加载组件需要开发者自己去实现,而在3.x时代 react-route 提供了可配置的分片功能。

本文将会从最简单的 react 静态化页面说起,到最后实现高效完整的 react nodejs 服务器。

在阅读之前务必将示例代码clone或下载到本地,本文的所有内容都是围绕示例代码说明的。如果你功力够,甚至可以不看本文直接通过代理理解。代码地址:https://github.com/chkui/react-server-demo。

$ git clone https://github.com/chkui/react-server-demo.git
$ npm install
$ #npm run demo

如果安装node-sass遇到问题,可以翻墙、cnpm或者用源码编译生成。请参考这里:node-sass无法安装的各种解决方案

本项目最开始使用的 nodejs 版本是6.9.2,后来升级到8.14~8.20。在这些版本上运行均没有遇到问题,但是不保证在其他版本的 nodejs 中可以顺利运行,尤其是6.9.2之前的版本。

所写的示例代码没有进行过任何代码检查、浏览器兼容性测试和单元测试,本人在Chrome50以上运行和调试暂未发现问题。

1,纯react组件服务端渲染

如果前端开发只有 react 组件(没有 reduxroute 等)且对性能也没有太高要求(无需分片、无需压缩、无需样式分离),实现服务端渲染是非常简单的,相关的介绍文档也多如繁星,基本都是围绕 react 提供的2个支持输出HTML字符串的渲染方法来说的—— ReactDOMServer.renderToStaticMarkup(element) ReactDOMServer.renderToString(element) 。为了实现前段不重复渲染,本项目使用后者生成HTML 

同步的关键是 checksum。服务端渲染返回HTML字符串后,会有一个 checksum 属性标记在根元素上,它是服务端生成HTML的指纹(hash计算)。到客户端进行 首屏渲染 时,会对这个 checksum 进行校验,如果校验一致仅仅生成虚拟DOM而不会发生真实的DOM渲染。  checksum如何确保不会重复渲染的原理可以看这里——React前后端同构防止重复渲染

我们先从一个简单的模型开始——1_simple_server_render(以下简称 示例1 )。示例1 仅用 react 组件实现了一个非常简单网站,他提供了三种启动方式:

  1. 仅用于前端开发的 webpack-dev 启动。
  2. 用于本地开发的 nodejs 开发模式启动。
  3. 用于发布生产的打包并用 nodejs 启动。

示例1 的文件结构:

--browserEntry.js  //浏览器端入口 

--app.js  //应用入口

--server.js //开发nodejs服务

--build.js //生产打包,用于生产nodejs服务

--koa.js //koa服务器启动代码

--middleware.js //服务端入口

--webpack //webpack相关的配置文件

----server-build.js //生产服务器打包

----server-dev.js //开发服务器运行

----static.js //使用webpack-dev运行React

--dist //打包后生成的文件

请各位留意这个文件结构,在后面实现各种功能的时候这个结构很有大意义。

首先,客户端展示分成了 browserEntry.js 和 app.js 两个文件。在 app.js 中实现了所有的页面效果,而 browserEntry.js 仅仅是使用 ReactDOM.render( element, container, [callback] ) 方法渲染App组件:

//index.js
import App from './app'
render(<App />, document.getElementById('root'))

其实完全可以将2个文件合并成一个文件,先留着这个问题继续往下看。

其次,服务端渲染关联了4个文件, server.js 、 koa.js 、 middleware 、 app.js 。他们的关系是: koa.js 提供了 koa 服务的基础功能( koaexpress 团队设计的新框架,没用过的可以理解 koa 就是一系列中间件,一个请求发送到服务器由这些中间件一个接一个的处理。 需要了解请看:http://koajs.com/), server.js 一开始就require了 koa.js 获取 koa 服务的实例,然后向 koa 实例中增加了 middleware.js 和其他中间件。

//server.js
const koa = require('./koa'),
    middleware = require('./middleware')
koa.use(middleware) //注册
koa.listen(8080) //启动监听8080端口

 middleware.js 中使用 ReactDOMServer.renderToString(element) 对App组件进行渲染生成HTML结构的字符串,然后调用ctx.render通过模板生成页面:

import React from 'react'
import {renderToString} from 'react-dom/server'
import App from './app'

//用于生成页面的中间件
async function middleware(ctx, next) {
    if(ctx.url === '/'){
        //使用renderToString渲染<App />组件得到一个字符串
        const dom = renderToString(<App/>)
        //使用之前view中间件生成的模板渲染引擎生成HTML文本
        await ctx.render('index', {
            root: dom
        })
    }else {//如果不是访问的根目录,则交由下一个中间件
        return next();
    }
}

在上面的过程, app.js 在服务端和客户端渲染都使用到了,所以这一块是可以前后端同构的。而 browserEntry.js middleware.js 分别负责将app在浏览器和服务端渲染出来,他们分别是浏览器端的入口和服务端的入口。

最后还剩下一个 build.js ,仔细观察会发现他的结构其实和 server.js 是一模一样的——都是先获取一个koa实例,然后添加中间件,只是去除了许多开发用的工具:

import serve from 'koa-static'
import path from 'path'
import views from 'koa-views'
import koa from './koa'
import middleware from './middleware'
const dir = eval('__dirname'),  //编译时不执行,运行时在打包之后的环境获取相对位置
    port = 8080,
    maxAge = 86000,
    gzip = true,
    viewsPath = path.resolve(dir, '../views'),
    staticPath = path.resolve(dir, '../client'),
    log = console.log

log('views path:', viewsPath)
log('static path:', staticPath)
log('static cache age:', maxAge, 'milliseconds')
log(gzip ? 'gzip able' : 'gzip disable')

//页面模板
koa.use(views(viewsPath, {map: {html: 'ejs'}}))
//静态资源管理, js、css等
koa.use(serve(staticPath, {
    maxage: maxAge,
    gzip: gzip
}))
koa.use(middleware)
koa.listen(port || 8080)
console.log(`\n Open up http://localhost:${port}/ in your browser.\n`)

 build.js 是用来打包生产服务器的,打包完成后可以直接使用node启动。webpack文件夹里就包含了打包用的webpack配置。

在工程根目录运行以下脚本 :

#-------------------
#使用webpack/static.js启动webpack-dev运行React组件。用于日常开发
npm run 1-static

#--------------------
#使用webpack/server-dev.js启动一个node服务器提供React渲染。用于需要服务端的开发。
npm run 1-dev

#---------------------
#使用webpack/server-build.js打包服务,会在dist文件中生成view、client、server三个目录
npm run 1-build
#运行打包之后的服务器,可以将dist中的文件部署到服务器运行
npm run 1-run 

分别运行上面的脚本后,在浏览器输入 http:// localhost:8080 均看到相同的页面,但是打开开发人员工具,可以看到许多有意思的东西。例如查看首屏传输的数据,服务端渲染的首屏已经包含了完成HTML文档以及用React用于校验文本一致性的 checksum ,而运行   $ npm run 1-static    的 webpack-dev 启动时什么都没有。

服务端渲染,从服务器传递而来的HTML中#root中已经包含了DOM:

服务端渲染

webpack-dev 启动,仅引入js文件,需要等 react 开始运行后,才会向#id元素中添加DOM:

客户端渲染

至此,我们已经实现了非常简单的单页面应用服务端渲染。但是距离投入生产远远不够。我们的 .css 文件还没有分离;服务器只实现了渲染简单的dom,更多的情况是我们需要在服务端使用异步请求组装数据;单页面应用一次性加载资源过大怎么处理?我们需要将资源文件分离,并且按页面加载;我们还没有整合react-routeredux 。如果你还有兴趣请接着往下看。

2,完整可用的单页面应用服务端渲染

为了能将我们开发的工程投入实际生产应用,需要引入 react-route 来为单页面应用提供路由功能、引入redux 统一管理数据、将样式抽取到独立 .css 文件、在服务端异步组织数据。2_route_redux_render在前面所介绍的示例1  的基础上实现了所有这些特性。

2_route_redux_render(以下简称 示例2 是一个非常简单的搜索网站,会针对 github.com 的内容进行搜索。示例2 只有2个页面,一个搜索首页、一个搜索的结果列表页,这样布局仅仅为了便于说明问题。示例2 示例1 的基础上增加了以下内容:

  1. 引入react-router,在config.js文件中配置路由列表(routes)。
  2. 引入react-redux,在config.js文件中配置reducer。
  3. 增加了样式。

运行   $ npm run 2-static    启动 webpack-dev 后在浏览器输入 http://localhost:8080/ 可以看到下图这样的静态页面的效果:

在搜索框输入要搜索的内容按回车会跳转到搜索的结果列表页。

首页提供了3个下拉菜单,前两项用于搜索而最后一个下拉菜单可以选择 前端跳转 还是通过 服务器跳转

现在我们停掉刚启动的 webpack-dev ,使用开发服务器启动。运行以下内容:

$ npm run 2-dev

启动成功后(输出类似“webpack built 8ab71feb1d9a410ffd00 in 4760ms”的内容)我们就可以分别尝试在浏览器端通过异步请求组装页面,以及在服务器端组装页面并以静态HTML页面的方式发送到浏览器。

在首页(localhost:8080)最右边的下拉菜单选择“前端”然后进行搜索,会发现 nodejs 服务器没有接收到任何请求,而浏览器会出现一个加载效果,等待十几秒之后完成数据组装。如果选择“服务器”,搜索时会发现 nodejs 服务器输出很多内容,等待十几秒后浏览器直接出现了结果页面而没有任何加载效果。

你也可以将代码打成生产包进行测试:

$ npm run render-build
#打包成功后
$ npm run render-run

浏览器渲染和服务端渲染最大的区别可以看HTML的源码。没有服务端渲染的浏览器HTML结构是这样的:

没有任何内容,只有要运行的 .js 文件,等待 react 向#root中添加DOM。

而通过服务器去渲染HTML源码是这样的:

HTML源码已经有了实质内容。下面那一堆BASE64编码是首页的图片,已经通过后台加载好了。

各位童鞋可以通过各种方式运行DEMO来验证效果。

如何实现?

首先,和示例1一样,将浏览器端渲染和服务端渲染分为2个入口。

 app.js 依然是仅仅使用 React 实现的页面组件, browserEntry.js 是用于浏览器端渲染的入口,而 middleware/entry.js 是服务端渲染的入口。与 示例1 相比 browserEntry.js  引入了 <Provider><BrowserRourer> 组件,他们分别用于 react-redux react-router

render(
    <Provider store={build(reducers, window.REDUX_STATE)}>
        <BrowserRouter><App/></BrowserRouter>
    </Provider>, document.getElementById('root'))

服务端的入口将 <BrowserRourer> 组件更换为 <StaticRouter>,原因参看 react-router官方关于服务端渲染的说明

其次,使用redux组装异步数据。

redux 在这里起到一个很核心的作用是同步前后端的数据。数据会在服务端渲染 react 组件之前就通过action 完成数据的组装,然后在渲染时传入携带数据的store进行渲染。

所以 示例2 将koa的中间件分为2个,一个用于组装redux的数据,一个用于完成渲染。middleware/store.js用于组装 redux 数据:

async function reduxStore(ctx, next) {
    ctx.isRoutes = isRoutes(ctx.url) //判断当前请求是否属于路由列表
    if (ctx.isRoutes) {
        try {
            ctx.store = await new Promise((resolve, reject) => {
                processStore(resolve, build(reducers), ctx.url) //使用process组装列表页面的数据
            })
        } catch (err) {
            console.error('process fluxStore error', err)
        }
        return next()
    } else {
        return next()
    }
}

const processStore = (resolve, store, url) => {
    const prefix = '/p/details/', //用于判断是否是列表页面
        paramStr = url.replace(prefix, '')
    url.startsWith(prefix) && '' !== paramStr ? (()=>{
        const params = paramStr.split('/')
        if(2 < params.length){
            store.subscribe(()=>{ //监听store的数据变更
                const list = store.getState().detailList
                list && 0 < list.length && resolve(store) //list获取数据后,执行next()
            })
            store.dispatch(requestList(params[0],params[1],params[2])) //调用action更新数据
        } else {
            resolve(store)
        }
    })():resolve(store)
}

reduxStore(ctx, next) 方法是 koa 中间件的常规入口,而组装数据的关键是 processStore(resolve, store, url) 方法,执行以下内容:

  1. 判断是否为列表页面。
  2. 获取传递的 restfull 参数。
  3. 使用 dispatch(action) 方法来更新 store 中的数据。
  4. 使用 subscribe(listener) 方法监听数据的变更,发现列表完成更新之后调用 promiseresolve方法执行下一个中间件。

当访问列表页时,通过以上过程会完成一次 store 的数据更新。然后在  middleware/entry.js   中会将这个更新之后的store直接传入<Provider> 用于组装组件。同时,store 中的数据也会通过页面模板写到页面上让前端也同时使用他初始化 store 数据。

最后,分离样式。

我们直接使用 webpackExtractTextPlugin (https://www.npmjs.com/package/extract-text-webpack-plugin)插件提取出独立的CSS文件:

{
            test: /\.scss$/,
            use: ExtractTextPlugin.extract({
                fallback: 'style-loader',
                use: [
                    'css-loader?modules&camelCase&importLoaders=1&localIdentName=[local][hash:base64:5]',
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: function () {
                                return [
                                    require('autoprefixer')()
                                ];
                            }
                        }
                    },
                    'sass-loader']
            })
        }

在转换成 .css 文件之前经过了4步处理:1)sass-loader 转换 sass;2)postcss-loader 生成浏览器兼容样式(生成 -ms--webkit- 这样的前缀);3)css-loader 生成命名规则;4)生成 css 样式并统一提取到一个 .css 文件中。如果工程使用其他工具(比如 less 等)需要适当修改这个过程。

3,代码分片、合并、压缩以及服务器管理

如果你的工程代码不多并且对性能要求不高,示例2 的介绍的方法基本够用了。但是如果代码量太大,单页面应用一次性加载所有的代码确实体验会比较差。最后这一部分会介绍如何再深入优化React单页面应用。

3_compress_ensure_render (以下简称 示例3 )在 示例2 的基础上进行了些许修改:

  1. 增加了一个新的页面—— localhost:8080/p/ext/page
  2. config.js 中的路由列表(routes)的 component 调整为使用 require.ensure 异步加载组件。
  3. 修改 webpack/server-build.js 的打包脚本配置,使之可以支持分片和压缩。
  4. 新增一个服务端中间件——page,用于在进行服务端渲染之前先生成当前页面的对象。
  5. 修改2个入口(browserEntry.jsmiddleware/entry.js)以及 app.js,以支持页面组件优先渲染功能。
  6. 实现了一个 bundle 高阶组件用于异步加载页面。
  7. fetch 方法调整为异步获取。

代码分片

示例3 是优化最终发布上线的版本,所以我们仅仅关注打包和运行。执行以下命令:

$ npm run 3-build

打包完成后 dist/client 目录中生成了以下文件:

整个工程的前端打包后就这么多内容,一共8个资源文件,不到600KB。实际上黄色警告部分的分片是永远不会传输到浏览器端的,node-fetch只会在服务端使用,所以整个工程只有280KB左右(包括所有第三方组件、所有第三方工具以及一张图片)有可能会传递到前端。我们将分片之后的资源文件和 示例2 未分片的资源文件进行比较:

示例2 将除了样式之外的所有的资源都打包到了bundle.js中,并且整个文件有1.83MB。如果是一个移动端应用,一次加载1.83MB的内容确实会影响体验(使用gzip之后可以缩小到600KB左右,但是示例代码本来就很少,对于一个庞大的工程,显然是不可接受的)。

对于单页面应用我们需要什么?

  1. 将所有的第三方组件单独打包到一个js文件中,因为这些组件几乎很少变动。浏览器可以长期缓存。
  2. 将自己工程中的公有组件单独打包到一个js文件中。虽然这些组件没有第三方组件稳定,但是相对业务代码还是比较稳定的,浏览器也可以长期缓存。
  3. 每一个页面的组件都按需加载,等待react-route打开这个页面时再加载对应的资源。因为按照产品的尿性理论80%的用户只会用到20%的功能,没必要一开始就加载TA根本用不到的资源。
  4. 将仅用于服务端的代码尽量隔离出去,没必要传输到浏览器。

根据以上需求,我们使用webpack的分片功能进行打包。

首先,使用  require.ensure  标记需要异步加载的组件。

require.ensure 是CMD异步加载的规范,webpack里实现了这个功能。我们在路由列表中用  require.ensure 异步加载每个页面:

{
    id:'details',
    name:'details',
    url:'/p/details/:text/:language/:order',
    component: (call)=> { //加载组件的回调
        require.ensure([], require => {
            call(require('./component/details'))
        }, 'details')
    }
}

修改 react-routeRoute 组件参数,用高阶组件 bundle 将原始组件包裹住:

//Route的component参数传递的是bundle组件
const Section = props =>
    <section>
        {routes.map(i => <Context key={i.id} path={i.url}
                                  component={bundle(props.id, props.component, i.component)}/>)}
    </section>
const Context = props =>
    <Route exact path={props.path} component={props.component}/>

在高阶组件 bundle 中实现组件异步加载:

const bundle = (initId, initComponent, getComponentFoo) => {
    return class extends React.Component {
        constructor(...props) {
            super(...props)
            this.state = {Comp: {}}
            this.async = this.async.bind(this)
        }

        async(Comp) {
            //组件获取成功后,将其设置到state中触发渲染
            this.setState({Comp: Comp})
        }

        componentWillMount() {
            //装载完成后,调用routes中配置的异步方法获取组件
            !this.state.Comp && getComponentFoo(this.async)
        }

        render() {
            const {Comp} = this.state
            //如果组件已加载则渲染组件,如果未加载则不添加任何Dom
            return Comp ? (<Comp {...this.props}/>) : null
        }
    }
}

bundle 第一次渲染时,会先装载一个 null 然后使用 routes 列表的component方法异步获取组件。

其次,通过 webpack 配置标记分片规则。

在webpack的打包配置文件中(./3_compress_ensure_render/webpack/server-build)先指定2个入口和输出规则:

//20行
   entry: {
        bundle: './browserEntry.js',
        vendor: [
            'react',
            'react-dom',
            'react-redux',
            'react-router',
            'react-router-dom',
            'redux',
            'redux-logger',
            'redux-thunk'
        ]
    },
    output: {
        path: path.resolve(__dirname, '..', './dist/client'),
        filename: '[name][hash:8].js',
        chunkFilename: '[name][chunkhash:8].js',
        publicPath: '/'
    },
//

bundle 是我们业务代码的入口、vendor 用来标记所有的第三方组件。输出部分增加了hash标记,这主要用于浏览器和CDN长期缓存。

使用  CommonsChunkPlugin 插件对代码进行分片:

[
//78行
new webpack.optimize.CommonsChunkPlugin({
    name: ['bundle', 'vendor'],
    filename: '[name][chunkhash:8].js'
}),
new webpack.optimize.CommonsChunkPlugin({
    name: 'manifest'
}),
//
]

这里引入了2次插件。前者用于生成所有的代码分片,而后者用于生成manifest文件。

为什么要生成manifest:webpack打包会通过编号来标记每个module,而打包编译之后的代码是通过编号来加载module的。这些编号即使代码没有修改每次打包也可能变动,因此引入一个 manifest 文件来保存变动部分,有利于缓存,详情可以 查看官网关于manifest的说明

最后,webpack 会根据  require.ensure  在代码中的标记以及配置生成上图中每一个分片

  • node-fetch.js:仅用于服务器端的fetch工具。我们在加载它的代码中特别使用了 require.ensure目的就是将其分离出来。
  • home.js:首页对应的组件。
  • extPage.js:/p/ext/page页面对应的组件。
  • vendor.js:entry中指定要单独打包的第三方工具,包括react、react-route、redux等。
  • manifext.js:webpack runtime部分的代码。
  • bundle.js:工程自己的公用组件。
  • bundle.css:示例2 中已经介绍的使用 ExtractTextPlugin 抽取的CSS文件。
  • index.html:全站通用的html页面。

除了仅用于服务端的 node-fetch.js(也不知道为啥这玩意能这么大),其他会传输到浏览器的资源文件都不大。结合gzip一起使用整个网站的资源的传输量不到100KB,如下图:

细心的人看到这里一定会问:怎么 details 页面没有分片?个人推测这和代码的复用率有关。仔细观察details/index.js 的代码,会发现几乎都是引用了外部组件,所以被一起合并到 bundle.js 里去了。还没有去考证,有人知道具体原因请告诉我。

webpack 分片的配置还有很多选项,有需要可以 查看官方分片插件的说明。每一个项目都有不同的特性,所以配置也不可能完全一致,想要达到最佳效果最好是在了解每个参数作用的前提下,根据项目特点进行配置。

首屏组件在渲染之前加载

在react-route4.x中使用 require.ensure 有一个问题,就是每次打开页面都会异步加载组件,导致页面闪现。在react-route3.x时代官方提供了一个配置解决方案,但是不知道为什么到4.x没了。官方给出的方法(官方原文)是直接用 bundle-loader 实现,但是我用它解决了纯浏览器的异步加载问题,但结合服务端渲染时出现页面闪现,所以才自己写了 ./3_compress_ensure_render/component/highOrder/bundle.js 这个高阶组件。

可以通过将配置文件 config.js 中的 isInitComponent 参数修改为false来复现这个问题:

//58行
/**
 * 初始化加载页面的开关,仅用于说明区别
 * @type {boolean}
 */
export const isInitComponent = false
//

然后启动开发nodejs服务器:

$ npm run 3-dev

启动后刷新首页可以看到在浏览器的console中(本人使用的chrome)输出以下警告:

意思就是服务器端渲染传递过来的HTML结构和浏览器端首次渲染的结构不一致。

由于打包脚本和运行脚本设定的是生产环境(NODE_ENV=production),所以不会输出警告。将环境设定为 test 一样输出以上内容。即使不修改,按F5刷新一样会感觉到差别。

观察警告输出的内容,在服务端已经渲染了 home 页面,但是到浏览器端首屏渲染的是一个"空元素"(<!--react-empty -->)。导致这个问题出现的原因是在 bundle 组件中需要异步加载组件,在加载之前会先返回一个 null,所以导致 react 首屏渲染这里获取的是一个"空组件"(首屏渲染的原理请看 这里 )。

解决这个问题并不算太麻烦,方法也很多,就是需要一些小技巧。

这里采用优先生成页面的方式。

首先,在服务端增加一个中间件——middleware/page.js用于在渲染之前生成当前页面组件。

//page.js
async function page(ctx, next) {
    if (ctx.isRoutes) {
        //matchRoute方法根据当前访问的url从路由列表中获取对应的route
        const {id, name, component} = matchRoute(ctx.url),
            Comp = await new Promise((res, rej) => {
                component((Comp) => { //异步获取组件
                    res(Comp)
                })
            })
        //将获取的结果赋值到请求的上下文交由下一个中间件处理
        ctx.page = {id, name, component: Comp}
        return next()
    } else {
        return next()
    }
}

获取到这个组件后接着到 middleware/entry.js 中间件通过参数传入到app.js,同时使用模板工具将页面对应的id写到浏览器端。

然后,在浏览器端的 browserEntry.js 也做同样的事,在渲染之前先加载页面组件。

//browserEntry.js
//获取当前页面对应的路由id
const id = window.Init_Page ? window.Init_Page.id : false
//id存在则从路由列表中找到对应的组件,id不存在则直接渲染
id ? (()=>{
    const route = routes.filter(i=>i.id === id)[0]
    route && route.component(cb=>{
        pageRender(id, cb)
    })
})(): pageRender()

//异步渲染,通过参数传入已经加载完成的组件
const pageRender = (id, component) =>{
    render(
        <Provider store={build(reducers, window.REDUX_STATE)}>
            <BrowserRouter>
                <App id={id} component={component}/>
            </BrowserRouter>
        </Provider>, document.getElementById('root'))
}

最后,App 组件会将初始化的数据传递到 bundle 这个高阶组件,bundle会进行初始化判断,如果组件已经存在则不会去执行异步加载而直接渲染。

//bundle.js 片段
const bundle = (initId, initComponent, getComponentFoo) => {
    return class extends React.Component {
        constructor(...props) {
            super(...props)
            //----------------
            //这一段用于初始化渲染,解决第一次打开网站时服务端完成渲染前端再异步加载闪现的问题
            //获取当前url对应的路由
            const route = matchRoute(this.props.match.url), 
            
            //根据判断规则获取初始化组件
            //规则1:initId是否存在
            //规则2:当前路由的id是否等于initId
            Comp = initId && route && initId === route.id ? initComponent : false
            
            //设定初始化组件,如果组件存在则不会进行异步加载
            this.state = {Comp: Comp}
            //----------------
        }

        componentWillMount() {
            //如果在构造函数中已经设定了组件,则不会去异步加载组件
            !this.state.Comp && getComponentFoo(this.async)
        }
    }
}

代码合并与压缩

代码合并压缩都是 webpack 提供的插件功能,下面是和合并、压缩相关的插件:

//webpack/server-build.js 片段
   [    //压缩js
        new webpack.optimize.UglifyJsPlugin({ 
            compress: {warnings: false},
            comments: false
        }),
        //压缩css
        new OptimizeCssAssetsPlugin({
            assetNameRegExp: /\.css$/g,
            cssProcessor: require('cssnano'),
            cssProcessorOptions: {discardComments: {removeAll: true}},
            canPrint: true
        }),
        //分片优化,开启后会根据设定来合并分片代码
        new webpack.optimize.AggressiveMergingPlugin(),
        //设定分片限制
        new webpack.optimize.LimitChunkCountPlugin({
            maxChunks: 35,
            minChunkSize: 1000
        }),
        //设定最小分片条件
        new webpack.optimize.MinChunkSizePlugin({
            minChunkSize: 10000
        })
   ]

相关的参数说明请到官网查看API说明。

服务器管理工具

最后介绍一下 nodejs 服务器管理工具。

我们使用的是 pm2,当然还有其他各种工具可供选择。

关于 pm2 的细节就不介绍了,有需要可以去 官网 了解。

pm2 的使用仅仅需要一个配置文件—— pm2.config.js

module.exports = {
    apps: [
        {
            //名称
            name: 'react-server-render',
            //要启动的文件路径
            script: './3_compress_ensure_render/dist/server/server.js',
            /**设定进程
             *0或'max'表示启用与cpu核心对应的进程.
             *-1表示启动比cpu核心少一个的进程
             *其他具体数字表示指定进程数目
             **/
            instances: 0,
            /**
             * 模式.
             * cluster:集群
             *
             */
            exec_mode: 'cluster',
            //环境配置
            env: {
                NODE_ENV: 'production'
            },
            //日志时间格式
            log_date_format: 'YYYY-MM-DD HH:mm Z',
            //指定日志输出位置,Linux下注意权限问题
            out_file: './logs/out.log',
            error_file: './logs/err.log'
        },
    ]
}

注意!这个配置文件的名称必须按照 *.config.js 的方式命名,否则无法生效。这真是个莫名奇妙的设定,官网的 #Process File 部分有说明——Note that using a Javascript configuration file requires to end the file name with .config.js 不知道这个原因的人,死活配置都不生效。

package.json 中已经写好 pm2 对应的启停脚本:

#启动,启动之前先npm run 3-build 打包
$ npm run 3-start

#停止
$ npm run 3-stop

启动成功之后会输出一下内容:

pm2 还提供了监控、统计、日志抽取等等实用工具。需要的可以去官网了解。以下是打开 pm2 监控的过程和效果。

$ npm install pm2 -g
$ pm2 monit

监控效果:

至此,已经将服务端渲染相关的所有内容介绍完毕,因为篇幅的原因有很多东西无法深入。有问题或者对本片内容有任何不满请留言或在 githubissue

在写本文之前已经完成了一个可以快速应用到不同项目的 nodejs 同构渲染服务。目前已经应用到团队的多个项目中,需要了解的请参看:

npmhttps://www.npmjs.com/package/pwfe-dom  https://www.npmjs.com/package/pwfe-server

github源码https://github.com/palmg/pwfe-dom  https://github.com/palmg/pwfe-server

  • 打赏
  • 点赞
  • 收藏
  • 分享
共有 人打赏支持
粉丝 157
博文 43
码字总数 107698
×
随风溜达的向日葵
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: