一开始想学学服务端渲染,脑海中第一个浮现出来的就是next.js这种成熟的方案。看了一两天,有趣,优雅,可是封装好了,原理不甚清楚,也感受没法灵活嵌合到老项目上去。因而看各类资料,想整理出同构的线索,一步一步地实现本身的同构模板。相关代码可查看个人GitHub。感谢阅读!!javascript
正常的网页运行,须要生成dom,在dom树loaded以后由js绑定相关的dom事件,监听页面的交互。服务端并不具有dom的执行环境,于是全部的服务端渲染其实都是返回了一个填充了初始数据的静态文本。在react中,除了经常使用的render
这个用于生成dom的方法,还提供了renderToString
,renderToStaticMarkup
方法用来生成字符串,因为VitualDOM的存在,结合这些方法就能够像之前的字符串模板那样生成普通的字符串,返回给客户端接管,再接着进行事件相关的绑定。最新的React v16+使用hydrate
和ssr
配套,能让客户端把服务端的VitualDOM渲染出来后得以复用,客户端加载js后不会重刷一边,减少了开销,也避免浏览器重刷dom时带来的闪屏体验。而react的组件,仍是和往常写spa同样编写,先后端共享。不一样的只是入口的渲染方法换了名字,且客户端会挂载dom而已。css
// clinet.js
ReactDom.hydrate(<App />, document.getElementById('app')) // server.js const html = ReactDom.renderToString(<App />) 复制代码
盗用一张图,来自阿里前端。乍一看,ssr
与csr
的区别就在于2 3 4 5
,spa
模式简单粗暴地返回一个空白的html页面,而后在11
里才去加载数据进行页面填充,在此以前,页面都处于空白状态。而ssr
则会根据路由信息,提早获取该路由页面的初始数据,返回页面时已经有了初步的内容,不至于空白,也便于搜索引擎收录。html
浏览器端的路由匹配仍是照着spa
来作应该无需费心。略过了...前端
服务端的路由须要关注的,一个是后端服务的路由(如koa-router
)匹配的问题,一个是匹配到react应用后react-router
路由表的匹配问题。java
/react
前缀来和api接口
等其余区别开来,这种路由匹配方式甚至能让服务端渲染能同时支持老项目诸如ejs
等的模板渲染方式,在系统升级改造方面可实现渐进式地升级。// app.js文件(后端入口)
import reactController from './controllers/react-controller'
// API路由
app.use(apiController.routes())
// ejs页面路由
app.use(ejsController.routes())
// react页面路由
app.use(reactController.routes())
// react-controller.js文件
import Router from 'koa-router'
const router = new Router({
prefix: '/react'
})
router.all('/', async (ctx, next) => {
const html = await render(ctx)
ctx.body = html
})
export default router
复制代码
react-router
react-router
专供了给ssr
使用的StaticRouter
接口,称之为静态的路由。诚然,服务端不像客户端,对应于一次网络请求,路由就是当前的请求url
,是惟一的,不变的。在返回ssr直出的页面后,页面交互形成地址栏的变化,只要用的是react-router
提供的方法,不管是hash
方式,仍是history
方式,都属于浏览器端react-router
的工做了,因而完美继承了spa
的优点。只有在输入栏敲击Enter
,才会发起新一轮的后台请求。import { StaticRouter } from 'react-router-dom'
const App = () => {
return (
<Provider store={store}> <StaticRouter location={ctx.url} context={context}> <Layout /> </StaticRouter> </Provider>
)
}
复制代码
以往的服务端渲染,须要在客户端网页下载后立刻能看到的数据就放在服务器提早准备好,可延迟展现,经过ajax
请求的数据的交互逻辑放在页面加载的js
文件中去。node
换成了react
,其实套路也是同样同样的。可是区别在于:react
传统的字符串模板,组件模板是彼此分离的,可各自单独引入数据,再拼装起来造成一份
html
。而在react
的ssr
里,页面只能经过defaultValue
和defaultProps
一次性render
,没法rerender
。webpack
不能写死defaultValude
,因此只能使用props
的数据方案。在执行renderToString
以前,提早准备好整个应用状态的全部数据。全局的数据管理方案可考虑redux
和mobx
等。git
须要准备初始渲染数据,因此要精准获取当前地址将要渲染哪些组件。react-router-config
和react-router
同源配套,是个支持静态路由表配置的工具,提供了matchRoutes
方法,可得到匹配的路由数组。github
import { matchRoutes } from 'react-router-config'
import loadable from '@loadable/component'
const Root = loadable((props) => import('./pages/Root'))
const Index = loadable(() => import("./pages/Index"))
const Home = loadable(() => import("./pages/Home"))
const routes = [
{
path: '/',
component: Root,
routes: [
{
path: '/index',
component: Index,
},
{
path: '/home',
component: Home,
syncData () => {}
routes: []
}
]
}
]
router.all('/', async (url, next) => {
const branch = matchRoutes(routes, url)
})
复制代码
组件的初始数据接口请求,最美的办法固然是定义在各自的class组件的静态方法中去,可是前提是组件不能被懒加载,否则获取不到组件class,固然也没法获取class static method
了,不少使用@loadable/component
(一个code split方案)库的开发者屡次提issue,做者也明示没法支持。不支持懒加载是绝对不可能的了。因此委屈一下代码了,在须要的route对象中定义一个asyncData方法。
// routes.js
{
path: '/home',
component: Home,
asyncData (store, query) {
const city = (query || '').split('=')[1]
let promise = store.dispatch(fetchCityListAndTemperature(city || undefined))
let promise2 = store.dispatch(setRefetchFlag(false))
return Promise.all([promise, promise2])
return promise
}
}
// render.js
import { matchRoutes } from 'react-router-config'
import createStore from '../store/redux/index'
const store = createStore()
const branch = matchRoutes(routes, url)
const promises = branch.map(({ route }) => {
// 遍历全部匹配路由,预加载数据
return route.asyncData
? route.asyncData(store, query)
: Promise.resolve(null)
})
// 完成store的预加载数据初始化工做
await Promise.all(promises)
// 获取最新的store
const preloadedState = store.getState()
const App = (props) => {
return (
<Provider store={store}> <StaticRouter location={ctx.url} context={context}> <Layout /> </StaticRouter> </Provider>
)
}
// 数据准备好后,render整个应用
const html = renderToString(<App />) // 把预加载的数据挂载在`window`下返回,客户端本身去取 return ` <html> <head></head> <body> <div id="app">${html}</div> <script> window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)}; </script> </body> </html> ` 复制代码
dom/数据
不一致,致使浏览器接管的时候dom从新生成了一次,在开发模式下的时候,控制台会输出错误信息,开发体验完美。后续ajax
的数据,在componentDidMount
和事件中去执行,和服务端的逻辑自然剥离。// 获取服务端提供的初始化数据
const preloadedState = window.__PRELOADED_STATE__ || undefined
delete window.__PRELOADED_STATE__
// 客户端store初始化
const store = createStore(preloadedState)
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<Layout />
</BrowserRouter>
</Provider>
)
}
// loadableReady由@loadabel/component提供,在code split模式下使用
loadableReady().then(() => {
ReactDom.hydrate(<App />, document.getElementById('app'))
})
复制代码
componentDidMount
方法只执行一次,若是服务器已经请求的数据带有一个标识,就能够根据这个标识决定是否在客户端须要发起一个新的请求了,须要注意的是判断完成后重置该标识。import { connect } from 'react-redux'
@connect(
state => ({
refetchFlag: state.weather.refetchFlag,
quality: state.weather.quality
}),
dispatch => ({
fetchCityListAndQuality: () => dispatch(fetchCityListAndQuality()),
setRefetchFlag : () => dispatch(setRefetchFlag(true))
})
)
export default class Quality extends Component {
componentDidMount () {
const {
location: { search },
refetchFlag,
fetchCityListAndQuality,
setRefetchFlag
} = this.props
const { location: city } = queryString.parse(search)
refetchFlag
? fetchCityListAndQuality(city || undefined)
: setRefetchFlag()
}
}
复制代码
我想说的是“照旧”。由于在浏览器端运行的仍是spa
。入门级的具体见github,至于如何配置得赏心悦目,用起来驾轻就熟,根据项目要求各显神通吧。
和客户端的异同:
同:
webpack v4+/babel v7+ ... 真香
异:
这里既能够把整个服务端入口
app.js
做为打包入口,也能够把react路由
的起点文件做为打包入口,配置输出为umd
模块,再由app.js
去require
。之后者为例(好处在于升级改造项目时尽量地下降对原系统的影响,排查问题也方便,断点调试什么的也方便):
// webpack.server.js
const webpackConfig = {
entry: {
server: './src/server/index.js'
},
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js',
libraryTarget: 'umd'
}
}
// app.js
const reactKoaRouter = require('./build/server').default
app.use(reactKoaRouter.routes())
复制代码
偷懒,还没开始研究,占个坑
const serverConfig = { ... target: 'node' }
const serverConfig = { ... externals: [ require('webpack-node-externals')() ]
服务端输出html
时,须要定义好css
资源、js
资源,让客户端接管后下载使用。若是没啥追求,能够直接把客户端的输出文件全加上去,暴力稳妥,简单方便。可是上面提到的@loadable/component
库,实现了路由组件懒加载/code split功能后,也提供了全套服务,配套套装的webpack工具,ssr工具,帮助咱们作搜集资源的工做。
// webpack.base.js
const webpackConfig = {
plugins: [ ..., new LoadablePlugin() ]
}
// render.js
import { ChunkExtractor } from '@loadable/server'
const App = () => {
return (
<Provider store={store}> <StaticRouter location={ctx.url} context={context}> <Layout /> </StaticRouter> </Provider>
)
}
const webStats = path.resolve(
__dirname,
'../public/loadable-stats.json', // 该文件由webpack插件自动生成
)
const webExtractor = new ChunkExtractor({
entrypoints: ['client'], // 为入口文件名
statsFile: webStats
})
const jsx = webExtractor.collectChunks(<App />) const html = renderToString(jsx) const scriptTags = webExtractor.getScriptTags() const linkTags = webExtractor.getLinkTags() const styleTags = webExtractor.getStyleTags() const preloadedState = store.getState() const helmet = Helmet.renderStatic() return ` <html> <head> ${helmet.title.toString()} ${helmet.meta.toString()} ${linkTags} ${styleTags} </head> <body> <div id="app">${html}</div> <script> window.STORE = 'love'; window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)}; </script> ${scriptTags} </body> </html> ` 复制代码
上面已经透露了。使用了一个react-helmet
库。具体用法可查看官方仓库,信息可直接写在组件上,最后根据优先级提高到head
头部。