一开始想学学服务端渲染,脑海中第一个浮现出来的就是next.js这种成熟的方案。看了一两天,有趣,优雅,但是封装好了,原理不甚清楚,也感觉无法灵活嵌合到老项目上去。于是看各种资料,想整理出同构的线索,一步一步地实现自己的同构模板。相关代码可查看我的GitHub。感谢阅读!!
TODO List
同构的基础
正常的网页运行,需要生成dom,在dom树loaded之后由js绑定相关的dom事件,监听页面的交互。服务端并不具备dom的执行环境,因而所有的服务端渲染其实都是返回了一个填充了初始数据的静态文本。在react中,除了常用的render这个用于生成dom的方法,还提供了renderToString,renderToStaticMarkup方法用来生成字符串,由于VitualDOM的存在,结合这些方法就可以像以前的字符串模板那样生成普通的字符串,返回给客户端接管,再接着进行事件相关的绑定。最新的React v16+使用hydrate和ssr配套,能让客户端把服务端的VitualDOM渲染出来后得以复用,客户端加载js后不会重刷一边,减小了开销,也避免浏览器重刷dom时带来的闪屏体验。而react的组件,还是和往常写spa一样编写,前后端共享。不同的只是入口的渲染方法换了名字,且客户端会挂载dom而已。
// 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则会根据路由信息,提前获取该路由页面的初始数据,返回页面时已经有了初步的内容,不至于空白,也便于搜索引擎收录。
路由匹配
浏览器端的路由匹配还是照着spa来做应该无需费心。略过了...
服务端的路由需要关注的,一个是后端服务的路由(如koa-router)匹配的问题,一个是匹配到react应用后react-router路由表的匹配问题。
服务端路由,可通过/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专供了给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文件中去。
换成了react,其实套路也是一样一样的。但是区别在于:
传统的字符串模板,组件模板是彼此分离的,可各自单独引入数据,再拼装起来形成一份html。而在react的ssr里,页面只能通过defaultValue和defaultProps一次性render,无法rerender。
不能写死defaultValude,所以只能使用props的数据方案。在执行renderToString之前,提前准备好整个应用状态的所有数据。全局的数据管理方案可考虑redux和mobx等。
需要准备初始渲染数据,所以要精准获取当前地址将要渲染哪些组件。react-router-config和react-router同源配套,是个支持静态路由表配置的工具,提供了matchRoutes方法,可获得匹配的路由数组。
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>
客户端
为保证两端的应用数据一致,客户端也要使用同一份数据初始化一次redux的store,再生成应用。如果两者的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,至于如何配置得赏心悦目,用起来得心应手,根据项目要求各显神通吧。
服务端打包
和客户端的异同:
同:
需要bable兼容不同版本的js语法
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())
css、image资源正常来说服务端无需处理,如何绕开
偷懒,还没开始研究,占个坑
require的是node自带的模块时避免被webpack打包
const serverConfig = { ... target: 'node' }
require第三方模块时如何避免被打包
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> `
SEO信息
上面已经透露了。使用了一个react-helmet库。具体用法可查看官方仓库,信息可直接写在组件上,最后根据优先级提升到head头部。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持小牛知识库。
我有一个特点,它是由同一个结构以不同的方式实现的。为了实现这一点,我有不同的结构,它们有不同的实现。对于抽象,我将这些结构称为A-Z: 有没有其他方法来实现这一点,以便结构的每个实例都有一个不同的trait实现,或者创建一个新结构是最好的方法? 我不熟悉编译语言。我所做的大部分工作都是使用Python和TypeScript。
本文向大家介绍浅谈react 同构之样式直出,包括了浅谈react 同构之样式直出的使用技巧和注意事项,需要的朋友参考一下 前言 上文讲到通过同构服务端渲染,可以直出html结构,虽然讲解了样式,图片等静态资源在服务端引入问题的解决方案,但是并没有实际进行相关操作,这篇文章就讲解一下如何让样式像html一样直出。 PS: 直出,我的理解就是输入url发起get请求访问服务端,直接得到完整响应结果,
例如当前数据结构是:{ "name": "mike" } 业务迭代后变成了:{ "name": "mike", "age" : 20 } 那么如何将前者也都变成例如:{ "name": "mike", "age" : 0 } 来实现类似关系型数据库的效果?还是说做不到只能用回关系型数据库
同步模块 这里的同步模块指的是运行时,该模块是同步载入的(类似 NodeJS 里的 require)。 require('module1'); // 运行到这里,module1 载入 require('module2'); // 运行到这里,module2 载入 demo 初始化目录 新建coolie-demo5,目录结构为: coolie-demo5 └── src 1 directo
本文向大家介绍Maven profile实现不同环境的配置管理实践,包括了Maven profile实现不同环境的配置管理实践的使用技巧和注意事项,需要的朋友参考一下 前言 目前,企业项目的开发过程中,往往会使用配置文件来做一些配置项来实现项目部署的灵活性,避免硬编码的方式在环境变化时需要对代码进行重新编译。但是往往在项目周期中存在着各种环境:如开发环境、测试环境以及生产环境等,而且在不同的运行环
本文向大家介绍用 Composer构建自己的 PHP 框架之构建路由,包括了用 Composer构建自己的 PHP 框架之构建路由的使用技巧和注意事项,需要的朋友参考一下 上一篇中我们已经建立了一个空的 Composer 项目,本篇将讲述如何构建路由。 久负盛名的 CodeIgniter 框架是很多人的 PHP 开发入门框架,同样也是我开始学习如何从头构建一个网站的框架。在 CI 中我学到了很多,