一.什么是服务器渲染
1.1不同于客户端渲染,以之前的React开发的小项目为例,使用客户端渲染SPA应用时,在输入url后,dns解析成ip,浏览器发送http请求到对应ip的指定端口下,服务器接收到http请求,返回的是一个打包好的bundle.js,浏览器解析js,动态创建Dom,我们用的ReactDOM.render也就是这样的一个方法
1.2服务器渲染返回是有内容的html,我们启动client和server,分别在8888端口和3333端口,我们访问3333端口,看到
我们看到在response中,html中有head,里面包含了一些meta信息,title信息,这样可以用于SEO,也包含了CSS信息,在body中,我们挂载了一个root根div。然后其中是有完整的DOM结构的,浏览器会先解析html文件,解析DOM树,解析CSS,然后合并成渲染树,然后我们看到在下方的script标签中,有一个js文件,浏览器解析js,为DOM绑定事件。这不就是我们的Web页面了吗~
1.3服务器渲染使用的React基本上和客户端渲染没有什么不同,只是有几个包使用的Api不同,我们还是正常的用React开发页面
1.4服务器渲染使用的是Webpack进行打包,然后这里用的是Mobx做React的数据管理,没有使用redux,Mobx是一个更简单上手的数据管理库,它不具备redux的时间回溯功能,也不具备redux的严格数据流,除了用action改变state,也可以直接访问state进行改变,redux只能通过严格的定义action,dispatch不同的aciton来通知reducer改变state,还可以使用中间件对action进行拦截和包装(网络数据请求),扯远了~
1.5为什么要渲染一次client还要渲染一次server呢,因为server我们是在node环境中开发的,node环境下,我们没法调用可以被node识别的浏览器的事件绑定等api,我们需要起一个webpack-dev-server(开发模式下,生产模式直接打包放到cdn上)来生成一个app.js,这个app.js是客户端渲染打包的代码,我们在server渲染的时候,需要用React提供的服务器渲染的api,用bundle,templete进行渲染,这个templete就是客户端生成的ejs HTML模板,这个模板中的script标签引入了我们在客户端渲染的时候生成的app.js,vendor.js manfeist.js,这样,一个http请求发送到服务器上,我们返回的html的body和head部分是我们server渲染出的string在浏览器直接解析的,script标签中则引入了client渲染的app.js等js文件,浏览器解析js,然后进行混合渲染标记,为DOM绑定事件,完成同构。
1.6首屏渲染好了以后,接下来每个数据获取的http请求都被服务器代理,也避免的浏览器的跨域问题,这里解析出http的req的url中的path req.path再拼接一个baseURL,这里就是cnode的公开api接口,然后用axios库发送过去,返回一个Promise我们解析它并且
1.7路由同构问题https://blog.youkuaiyun.com/sinat_17775997/article/details/83151136
1.8在这个小项目里,我们使用了mobx而不是redux,mobx和redux的区别。mobx不是单向的,
mobx定义不同的state类,然后使用provider给app注入state
mobx推荐使用action修饰的函数来改变state,使用的时候,用inject给React组件注入store就可以再React组件中的props中拿到了
export class TopicStore {
@observable topics
@observable details
@observable createdTopics
@observable syncing = false
@observable tab = undefined
constructor(
{ syncing = false, topics = [], tab = null, details = [] } = {},
) {
this.syncing = syncing
this.topics = topics.map(topic => new Topic(createTopic(topic)))
this.details = details.map(detail => new Topic(createTopic(detail)))
this.tab = tab
}
@computed get topicMap() {
return this.topics.reduce((result, topic) => {
result[topic.id] = topic
return result
}, {})
}
@computed get detailsMap() {
return this.details.reduce((result, topic) => {
result[topic.id] = topic
return result
}, {})
}
@action addTopic(topic) {
this.topics.push(new Topic(createTopic(topic)))
}
@action fetchTopics(tab) {
return new Promise((resolve, reject) => {
if (tab === this.tab && this.topics.length > 0) {
resolve()
} else {
this.tab = tab
this.topics = []
this.syncing = true
get('/topics', {
mdrender: false,
tab,
}).then(resp => {
if (resp.success) {
const topics = resp.data.map(topic => {
return new Topic(createTopic(topic))
})
this.topics = topics
this.syncing = false
resolve()
} else {
this.syncing = false
reject()
}
}).catch(err => {
reject(err)
})
}
})
}
@action createTopic(title, tab, content) {
return new Promise((resolve, reject) => {
post('/topics', {
title, tab, content,
})
.then(data => {
if (data.success) {
const topic = {
title,
tab,
content,
id: data.topic_id,
create_at: Date.now(),
}
this.createdTopics.push(new Topic(createTopic(topic)))
resolve(topic)
} else {
reject(new Error(data.error_msg || '未知错误'))
}
})
.catch((err) => {
if (err.response) {
reject(new Error(err.response.data.error_msg || '未知错误'))
} else {
reject(new Error('未知错误'))
}
})
})
}
@action getTopicDetail(id) {
console.log('get topic id:', id) // eslint-disable-line
return new Promise((resolve, reject) => {
if (this.detailsMap[id]) {
resolve(this.detailsMap[id])
} else {
get(`/topic/${id}`, {
mdrender: false,
}).then(resp => {
if (resp.success) {
const topic = new Topic(createTopic(resp.data), true)
this.details.push(topic)
resolve(topic)
} else {
reject()
}
}).catch(err => {
reject(err)
})
}
})
}
toJson() {
return {
page: this.page,
topics: toJS(this.topics),
syncing: toJS(this.syncing),
details: toJS(this.details),
tab: this.tab,
}
}
}
二.项目流程
这个小项目我想分成几个不同的部分总结下:
1.如何使用Webpack配置服务器渲染的环境
都在代码里注释了,这里就不做过多详解。列举几个常见的问题
分别介绍bundle,chunk,module是什么
bundle:是由webpack打包出来的文件,
chunk:代码块,一个chunk由多个模块组合而成,用于代码的合并和分割。
module:是开发中的单个模块,在webpack的世界,一切皆模块,一个模块对应一个文件,webpack会从配置的entry中递归开始找出所有依赖的模块。
分别介绍什么是loader?什么是plugin?
loader:模块转换器,用于将模块的原内容按照需要转成你想要的内容
plugin:在webpack构建流程中的特定时机注入扩展逻辑,来改变构建结果,是用来自定义webpack打包过程的方式,一个插件是含有apply方法的一个对象,通过这个方法可以参与到整个webpack打包的各个流程(生命周期)。
什么是模块化,不同的模块化标准有哪些
https://segmentfault.com/a/1190000015991869?utm_source=tag-newest
2.如何搭建服务器渲染的node代理服务器
我们先看一下server.js也就是服务器的启动入口文件:
是一个express服务器,调动常见的bodyparser中间件和session中间件,使用app.use('/api/user' ,require('./util/user-api')),传入对应的http函数(req,res,next) => {},开发和生产模式我们用两套配置,通过判断cross-env提供的NODE_ENV定义来通过process.env这个node全局环境变量来判断
const express = require('express')
const app = express()
const fs = require('fs')
const path = require('path')
const bodyParser = require('body-parser')
const session = require('express-session')
const favicon = require('serve-favicon')
const serverRender = require('./util/server-render')
const isDev = process.env.NODE_ENV === 'development'
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: false }))
app.use(session({
maxAge: 10 * 60 * 1000,
name: 'tid',
resave: false,
saveUninitialized: false,
secret: 'I will teach you'
}))
app.use('/api/user', require('./util/user-api'))
app.use('/api', require('./util/inject-token'))
app.use(favicon(path.join(__dirname, '../favicon.ico')))
if (!isDev) {
app.use('/public', express.static(path.join(__dirname, '../dist')))
const serverEntry = require('../dist/server-entry')
const template = fs.readFileSync(path.join(__dirname, '../dist/server.ejs'), 'utf8')
app.get('*', function (req, res, next) {
serverRender(serverEntry, template, req, res).catch(next)
})
} else {
const devStatic = require('./util/dev-static')
devStatic(app)
}
app.use(function (error, req, res, next) {
console.error(error)
})
const host = process.env.HOST || '0.0.0.0'
const port = process.env.PORT || 3333
app.listen(port, host, function () {
console.log(`server is listening on ${host}:${port}`)
})
const axios = require('axios')
const path = require('path')
const MemoryFileSystem = require('memory-fs')
const proxy = require('http-proxy-middleware')
const serverRender = require('./server-render')
const webpack = require('webpack')
const webpackServerConfig = require('../../build/webpack.config.server')
const mfs = new MemoryFileSystem()
const serverCompiler = webpack(webpackServerConfig)
//nodejs module模块
const NativeModule = require('module')
//
const vm = require('vm')
let serverBundle
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(warn => console.warn(warn))
const bundlePath = path.join(
webpackServerConfig.output.path,
webpackServerConfig.output.filename
)
// mobx会有多个模块存在的问题,所以把mobx作为exteneral使用
// 让bundle引用mobx的时候从node_modules下面引入
// 保持mobx实例在运行环境中只存在一份
//node的vm模块,
const m = { exports: {} }
try {
//自己
const bundle = mfs.readFileSync(bundlePath, 'utf-8')
const wrapper = NativeModule.wrap(bundle)
const script = new vm.Script(wrapper, {
filename: 'server-bundle.js',
displayErrors: true
})
const result = script.runInThisContext()
result.call(m.exports, m.exports, require, m)
serverBundle = m.exports
} catch (err) {
console.log(err.stack)
}
})
const getTemplate = () => {
return new Promise((resolve, reject) => {
axios.get('http://localhost:8888/public/server.ejs')
.then(res => {
resolve(res.data)
})
.catch(err => {
console.error('get template error', err)
})
})
}
const getStoreState = (stores) => {
return Object.keys(stores).reduce((result, storeName) => {
result[storeName] = stores[storeName].toJson()
return result
}, {})
}
module.exports = function handleDevSSR(app) {
app.use('/public', proxy({
target: 'http://127.0.0.1:8888'
}))
app.get('*', function (req, res, next) {
if (!serverBundle) {
return res.send('waiting for compile')
}
getTemplate().then(template => {
return serverRender(serverBundle, template, req, res)
}).catch(err => {
next(err)
})
})
}
我们以开发模式为例,我们需要一个自定的模块,打包我们生成的服务端渲染所需的代码,也就是serverbundle.js,
然后把serverBundle和templete HTML模板作为参数,我们调用我们打包的serverbundle对象的
const Helmet = require('react-helmet').default
const ReactDomServer = require('react-dom/server')
const ejs = require('ejs')
const serialize = require('serialize-javascript')
const SheetsRegistry = require('react-jss').SheetsRegistry
const colors = require('material-ui/colors')
const createMuiTheme = require('material-ui/styles').createMuiTheme
const create = require('jss').create
const preset = require('jss-preset-default').default
const asyncBootstrapper = require('react-async-bootstrapper').default
//store数组化
const getStoreState = (stores) => {
return Object.keys(stores).reduce((result, storeName) => {
result[storeName] = stores[storeName].toJson()
return result
}, {})
}
module.exports = (bundle, template, req, res) => {
const user = req.session.user
const createApp = bundle.default
const createStoreMap = bundle.createStoreMap
const routerContext = {}
const stores = createStoreMap()
//如果已经加载了user,之前加载了会放到session中,这里直接const user = req.seesion.user,如果存在,直接赋值
//if(user) store.appState.user.info = user
if (user) {
stores.appState.user.isLogin = true
stores.appState.user.info = user
}
const theme = createMuiTheme({
palette: {
primary: colors.pink,
accent: colors.lightBlue,
type: 'light',
},
})
//jss是可以用js写css的meterialui的方式,知道就行
const sheetsRegistry = new SheetsRegistry()
const jss = create(preset())
const app = createApp(stores, routerContext, sheetsRegistry, jss, theme, req.url)
return new Promise((resolve, reject) => {
asyncBootstrapper(app).then(() => {
if (routerContext.url) {
res.status(302).setHeader('Location', routerContext.url)
res.end()
return
}
const appString = ReactDomServer.renderToString(app)
const helmet = Helmet.rewind()
//首屏SEO,用helmet组件
const html = ejs.render(template, {
meta: helmet.meta.toString(),
link: helmet.link.toString(),
style: helmet.style.toString(),
title: helmet.title.toString(),
appString,
initalState: serialize(getStoreState(stores)),
materialCss: sheetsRegistry.toString()
})
res.send(html)
resolve()
}).catch(reject)
})
}
关于路由前端路由重定向,如果前端路由有Redirect组件,那么通过服务端的StaticRouter,会把url参数放到routerContext中,我们在后端可以通过判断routerContext中是否有url属性来跳转页面,要用StaticRouter包裹一下App
3.业务逻辑
业务部分包括首页,和详情页和新建页和个人中心页,布局用的是meterialUi,这也石第一次用UI库,需要用JSSPRoveder包裹一下,css也是定义成js对象然后解析,webpack打包的时候会解析它们,这里webpack在打包的时候,会从server-entry开始解析依赖,并且在同构的时候,app.js会给这个入口注入它所需的这几个参数,数据获取老生常谈了,在componentDidMount的时候调用fetchData,通过服务器代理发送到cnode,在fetchData中,return的promise也不需要resovle,只需要传给封装了axios的get请求所需的url参数,然后从返回的promise对象的then方法把数据存储到state中,这里用的mobx。
@action fetchTopics(tab) {
return new Promise((resolve, reject) => {
if (tab === this.tab && this.topics.length > 0) {
resolve()
} else {
this.tab = tab
this.topics = []
this.syncing = true
get('/topics', {
mdrender: false,
tab,
}).then(resp => {
if (resp.success) {
const topics = resp.data.map(topic => {
return new Topic(createTopic(topic))
})
this.topics = topics
this.syncing = false
resolve()
} else {
this.syncing = false
reject()
}
}).catch(err => {
reject(err)
})
}
})
}
export default (stores, routerContext, sheetsRegistry, jss, theme, url) => {
jss.options.createGenerateClassName = createGenerateClassName
return (
<Provider {...stores}>
<StaticRouter context={routerContext} location={url}>
<JssProvider registry={sheetsRegistry} jss={jss}>
<MuiThemeProvider theme={theme} sheetsManager={new Map()}>
<App />
</MuiThemeProvider>
</JssProvider>
</StaticRouter>
</Provider>
)
}
4.项目上线的其他配置
1.了解一下cdn是什么,可以提高下载的速度
把client打包的代码放到cdn上,从cdn请求资源会更快,
2.总结一下webpack的优化:
(1)第三方包不要打包到app.js中,单独打包到vendor中,可以尽可能利用缓存
(2)使用CommonsChunkPlugin对webpacl每次打包都会自动变更的一部分单独打包,不让它在app中
(3)使用UglifyJsPlugin压缩Js代码