教你如何搭建一个超完美的服务端渲染开发环境

Github地址: https://github.com/chikara-chan/react-isomorphic-boilerplate

目录

  • 前言
  • 服务端渲染好处
  • 思考
  • 原理
  • 同构方案
  • 状态管理方案
  • 路由方案
  • 静态资源处理方案
  • 动态加载方案
  • 优化方案
  • 部署方案
  • 其它
  • 结尾

前言

前段时间公司有一个产品需求要求使用Node.js中间层来做服务端渲染,于是翻遍了整个技术社区,没有找到一个特别合适的脚手架,作为一个有追求的前端攻城狮,决定自己去搭建一套最完美的服务端渲染开发环境,期间踩过无数的坑,前前后后差不多折腾了三周时间。

服务端渲染好处

  1. SEO,让搜索引擎更容易读取页面内容
  2. 首屏渲染速度更快(重点),无需等待js文件下载执行的过程
  3. 更易于维护,服务端和客户端可以共享某些代码

思考

  1. 如何实现组件同构?
  2. 如何保持前后端应用状态一致?
  3. 如何解决前后端路由匹配问题?
  4. 如何处理服务端对静态资源的依赖?
  5. 如何配置两套不同的环境(开发环境和产品环境)?
  6. 如何划分更合理的项目目录结构?

由于服务端渲染配置的复杂性,大部分人望而止步,而本文的目的就在于教你如何搭建一套优雅的服务端渲染开发环境,从开发打包部署优化到上线。

原理

一个服务端渲染的同构web应用架构图大致如上图所示,得力于Node.js的发展与流行,Javascript成为了一门同构语言,这意味着我们只需写一套代码,可以同时在客户端与服务端执行。

同构方案

这里我们采用React技术体系做同构,由于React本身的设计特点,它是以Virtual DOM的形式保存在内存中,这是服务端渲染的前提。

对于客户端,通过调用ReactDOM.render方法把Virtual DOM转换成真实DOM最后渲染到界面。

import { render } from 'react-dom'
import App from './App'

render(<App />, document.getElementById('root'))

对于服务端,通过调用ReactDOMServer.renderToString方法把Virtual DOM转换成HTML字符串返回给客户端,从而达到服务端渲染的目的。

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

async function(ctx) {
    await ctx.render('index', {
        root: renderToString(<App />)
    })
}

状态管理方案

我们选择Redux来管理React组件的非私有组件状态,并配合社区中强大的中间件Devtools、Thunk、Promise等等来扩充应用。当进行服务端渲染时,创建store实例后,还必须把初始状态回传给客户端,客户端拿到初始状态后把它作为预加载状态来创建store实例,否则,客户端上生成的markup与服务端生成的markup不匹配,客户端将不得不再次加载数据,造成没必要的性能消耗。

服务端
import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import App from './App'
import rootReducer from './reducers'

const store = createStore(rootReducer)

async function(ctx) {
    await ctx.render('index', {
        root: renderToString(
            <Provider store={store}>
                <App />
            </Provider>
        ),
        state: store.getState()
    })
}
HTML
<body>
    <div id="root"><%- root %></div>
    <script>
        window.REDUX_STATE = <%- JSON.stringify(state) %>
    </script>
</body>
客户端
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import App from './App'
import rootReducer from './reducers'

const store = createStore(rootReducer, window.REDUX_STATE)

render(
    <Provider store={store}>
        <App />
    </Provider>, 
    document.getElementById('root')
)

路由方案

�客户端路由的好处就不必多说了,�客户端可以不依赖服务端,根据hash方式或者调用history API,不同的URL渲染不同的视图,实现无缝的页面切换,用户体验极佳。但服务端渲染不同的地方在于,在渲染之前,必须根据URL正确找到相匹配的组件返回给客户端。
React Router为服务端渲染提供了两个API:

  • match 在渲染之前根据URL匹配路由组件
  • RoutingContext 以同步的方式渲染路由组件
服务端
import { renderToString } from 'react-dom/server'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { match, RouterContext } from 'react-router'
import rootReducer from './reducers'
import routes from './routes'

const store = createStore(rootReducer)

async function clientRoute(ctx, next) {
    let _renderProps

    match({routes, location: ctx.url}, (error, redirectLocation, renderProps) => {
        _renderProps = renderProps
    })

    if (_renderProps) {
        await ctx.render('index', {
            root: renderToString(
                <Provider store={store}>
                    <RouterContext {..._renderProps} />
                </Provider>
            ),
            state: store.getState()
        })
    } else {
        await next()
    }
}
客户端
import { Route, IndexRoute } from 'react-router'
import Common from './Common'
import Home from './Home'
import Explore from './Explore'
import About from './About'

const routes = (
    <Route path="/" component={Common}>
        <IndexRoute component={Home} />
        <Route path="explore" component={Explore} />
        <Route path="about" component={About} />
    </Route>
)

export default routes

静态资源处理方案

在客户端中,我们使用了大量的ES6/7语法,jsx语法,css资源,图片资源,最终通过webpack配合各种loader打包成一个文件最后运行在浏览器环境中。但是在服务端,不支持import、jsx这种语法,并且无法识别对css、image资源后缀的模块引用,那么要怎么处理这些静态资源呢?我们需要借助相关的工具、插件来使得Node.js解析器能够加载并执行这类代码,下面分别为开发环境和产品环境配置两套不同的解决方案。

开发环境
  1. 首先引入babel-polyfill这个库来提供regenerator运行时和core-js来模拟全功能ES6环境。
  2. 引入babel-register,这是一个require钩子,会自动对require命令所加载的js文件进行实时转码,需要注意的是,这个库只适用于开发环境。
  3. 引入css-modules-require-hook,同样是钩子,只针对样式文件,由于我们采用的是CSS Modules方案,并且使用SASS来书写代码,所以需要node-sass这个前置编译器来识别扩展名为.scss的文件,当然你也可以采用LESS的方式,通过这个钩子,自动提取className哈希字符注入到服务端的React组件中。
  4. 引入asset-require-hook,来识别图片资源,对小于8K的图片转换成base64字符串,大于8k的图片转换成路径引用。
// Provide custom regenerator runtime and core-js
require('babel-polyfill')

// Javascript required hook
require('babel-register')({presets: ['es2015', 'react', 'stage-0']})

// Css required hook
require('css-modules-require-hook')({
    extensions: ['.scss'],
    preprocessCss: (data, filename) =>
        require('node-sass').renderSync({
            data,
            file: filename
        }).css,
    camelCase: true,
    generateScopedName: '[name]__[local]__[hash:base64:8]'
})

// Image required hook
require('asset-require-hook')({
    extensions: ['jpg', 'png', 'gif', 'webp'],
    limit: 8000
})
产品环境

�对于产品环境,我们的做法是使用webpack�分别对客户端和服务端代码进行打包。客户端代码打包这里不多说,对于服务端代码,需要指定运行环境为node,并且提供polyfill,设置__filename和__dirname为true,由于是采用CSS Modules,服务端只需获取className,而无需加载样式代码,所以要使用css-loader/locals替代css-loader加载样式文件

// webpack.config.js
{
    target: 'node',
    node: {
        __filename: true,
        __dirname: true
    },
    module: {
        loaders: [{
            test: /\.js$/,
            exclude: /node_modules/,
            loader: 'babel',
            query: {presets: ['es2015', 'react', 'stage-0']}
        }, {
            test: /\.scss$/,
            loaders: [
                'css/locals?modules&camelCase&importLoaders=1&localIdentName=[hash:base64:8]',
                'sass'
            ]
        }, {
            test: /\.(jpg|png|gif|webp)$/,
            loader: 'url?limit=8000'
        }]
    }
}

动态加载方案

对于大型Web应用程序来说,将所有代码�打包成一个文件不是一种优雅的做法,特别是�对于单页面应用,用户有时候并不想得到其余路由模块的内容,加载全部模块内容,不仅增加用户等待时间,而且会增加服务器负荷。Webpack提供一个功能可以拆分模块,每一个模块称为chunk,这个功能叫做Code Splitting。你可以在你的代码库中定义分割点,调用require.ensure,实现按需加载,而对于服务端渲染,require.ensure是不存在的,因此需要判断运行环境,提供钩子函数。

重构后的路由模块为

// Hook for server
if (typeof require.ensure !== 'function') {
    require.ensure = function(dependencies, callback) {
        callback(require)
    }
}

const routes = {
    childRoutes: [{
        path: '/',
        component: require('./common/containers/Root').default,
        indexRoute: {
            getComponent(nextState, callback) {
                require.ensure([], require => {
                    callback(null, require('./home/containers/App').default)
                }, 'home')
            }
        },
        childRoutes: [{
            path: 'explore',
            getComponent(nextState, callback) {
                require.ensure([], require => {
                    callback(null, require('./explore/containers/App').default)
                }, 'explore')
            }
        }, {
            path: 'about',
            getComponent(nextState, callback) {
                require.ensure([], require => {
                    callback(null, require('./about/containers/App').default)
                }, 'about')
            }
        }]
    }]
}

export default routes

优化方案

提取第三方库,命名为vendor

vendor: ['react', 'react-dom', 'redux', 'react-redux']

所有js模块以chunkhash方式命名

output: {
    filename: '[name].[chunkhash:8].js',
    chunkFilename: 'chunk.[name].[chunkhash:8].js',
}

提取公共模块,manifest文件起过渡作用

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

提取css文件,以contenthash方式命名

new ExtractTextPlugin('[name].[contenthash:8].css')

模块排序、去重、压缩

new webpack.optimize.OccurrenceOrderPlugin(), // webpack2 已移除
new webpack.optimize.DedupePlugin(), // webpack2 已移除
new webpack.optimize.UglifyJsPlugin({
    compress: {warnings: false},
    comments: false
})

使用babel-plugin-transform-runtime取代babel-polyfill,可节省大量文件体积
需要注意的是,你不能使用最新的内置实例方法,例如数组的includes方法

{
    presets: ['es2015', 'react', 'stage-0'],
    plugins: ['transform-runtime']
}

最终打包结果

Paste_Image.png

部署方案

对于客户端代码,将全部的静态资源上传至CDN服务器
对于服务端代码,则采用pm2部署,这是一个带有负载均衡功能的Node应用的进程管理器,支持监控、日志、0秒重载,并可以根据有效CPU数目以cluster的方式启动最大进程数目

pm2 start ./server.js -i 0
Paste_Image.png

其它

提升开发体验

对于客户端代码,可以使用Hot Module Replacement技术,并配合koa-webpack-dev-middleware,koa-webpack-hot-middleware两个中间件,与传统的BrowserSync不同的是,它可以使我们不用通过刷新浏览器的方式,让js和css改动实时更新反馈至浏览器界面中。

app.use(convert(devMiddleware(compiler, {
    noInfo: true,
    publicPath: config.output.publicPath
})))
app.use(convert(hotMiddleware(compiler)))

对于服务端代码,则使用nodemon监听代码改动,来自动重启node服务器,相比supervisor,更加灵活轻量,内存占用更少,可配置性更高。

nodemon ./server.js --watch server

对于React组件状态管理,使用Redux DevTools这个中间件,它可以跟踪每一个状态和action,监控数据流,由于采用纯函数的编程思想,还具备状态回溯的能力。需要注意的是,React组件在服务端生命周期只执行到componentWillMount,因此要把该中间件挂载到componentDidMount方法上,避免在服务端渲染而报错。

class Root extends Component {
    constructor() {
        super()
        this.state = {isMounted: false}
    }
    componentDidMount() {
        this.setState({isMounted: true})
    }
    render() {
        const {isMounted} = this.state
        return (
            <div>
                {isMounted && <DevTools/>}
            </div>
        )
    }
}

代码风格约束

推荐使用时下最为流行的ESLint,相比其它QA工具,拥有更多,更灵活,更容易扩展的配置,无论是对个人还是团队协作,引入代码风格检查工具,百益而无一害,建议你花个一天时间尝试一遍ESLint每一项配置,再决定需要哪些配置,舍弃哪些配置,而不是直接去使用Airbnb规范,Google规范等等。

Tips: 使用fix参数可快速修复一些常见错误,在某种程度上,可以取代编辑器格式化工具

eslint test.js --fix

�开发环境演示

Youtubee视频,自备梯子
https://www.youtube.com/watch?v=h3n3-v81PqY

结尾

时至今日,开源社区中并没有一个完美的服务端渲染解决方案,而当初搭建这个脚手架的目的就是从易用性出发,以最清晰的配置,用最流行的栈,组最合理的目录结构,给开发者带来最完美的开发体验,从开发打包部署优化到上线,一气呵成。即使你毫无经验,也可轻松入门服务端渲染开发。

附上源码: https://github.com/chikara-chan/react-isomorphic-boilerplate

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,265评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,274评论 1 288
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,087评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,479评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,782评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,218评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,594评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,316评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,955评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,274评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,803评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,177评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,732评论 3 229
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,953评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,687评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,263评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,189评论 2 258

推荐阅读更多精彩内容