一.什麼是服務器渲染
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.csdn.net/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代碼