React服务端渲染(ssr)之Next.js框架
Nextjs是 React生态中非常受欢迎的SSR(server side render——服务端渲染)框架,只需要几个步骤就可以搭建一个支持SSR的工程(_Nextjs_的快速搭建见 Next.js入门)。 本文的案例代码来自于 前端标准模板项目。
服务端组织数据
Nextjs
提供了便捷强大的服务端渲染功能—— getInitialProps(),通过这个方法可以简单为服务端和前端同时处理异步请求数据:
const load = async () =>{
return new Promise((res, rej)=>{
res('Success')
})
}
class Simple extends React.Component{
static async getInitialProps({req, query}) {
const data = await load();
return {data}
}
render() {
return( {this.props.data}
)
}
}
复制代码
Next的强大之一体现在就这么几行代码就解决了SSR中最麻烦的前后端异步数据组装功能。再复杂的异步数据组装过程都可以放置到代码中的Promise对象中。
页面与内页
在继续述说本文内容之前还需要强化两个概念—— 内页与 页面。
通过浏览器输入一个地址获取到的内容称之为 页面。
而在单页面应用中也会有通过导航栏或菜单控制的内容切换效果,我们将这些切换的内容称之为 内页。单页面应用中一般会先打开一个页面,然后通过Dom的增删改模拟页面切换的效果。
Nextjs中SSR渲染的局限性
getInitialProps()
方法虽然强大好用,但是现在还存在一个问题—— 只能在“内页”中使用。
Nextjs_规定了所有放置到 ./pages
中的文件(通常是*.js_文件,也可以引入
.ts*文件)都视为一个内页,这些文件中被导出的React组件可以直接输入地址上访问。例如现在有[
./pages/about.js
Nextjs
后在浏览输入 http://localhost:3000/about
就可以看到这个组件,而[
./pages/async/simple.js
但是在其他路径(比如 ./component
)的组件是无法使用 getInitialProps()
方法的。乍一看这样似乎没多大问题,但是某些应用又需要这些组件不能放置到 ./pages
中暴露到_url_中,又需要异步加载数据。看下面的例子。
按需加载菜单的例子
如上图。在企业级应用中(例如OA系统)通常不太需要实现SSR,这个时候可以根据角色权限在组件的 componentDidMount()
方法中异步加载菜单,但是在某些时候(例如一个可配置菜单的内容网站,或者对企业级应用进行服务端缓存)也会有菜单异步加载并且实现SSR的需要,这个时候需要在_Nextjs_框架的基础上扩展。
看到这里可能你会想可以把菜单的组装像下面放到每个内页的 getInitialProps()
方法中去:
const Comp = props =>( {props.pageData});
Comp.getInitialProps = async ({req})=>{
//load Menu Promise
const menus = await getMenus();
//load Page Data Promise
const pageData = await getPageData();
return {menus, pageData}
}
复制代码
这样做在实现上没问题,但是在架构设计上是颇为糟糕的。以下三个原因:
- 对于React有各种各样的描述,比如单向数据流、组件化等等。但是他的核心思想其实是 分而治之。在Jquery“统治”的年代可以使用_selector_(比如
$('#id')
)轻易获取到页面上的任何元素。一个项目如果没有很好的规范化管理(长久的人工规范化管理是需要投入不少成本的),久而久之会发现各个板块之间耦合性越来越强、坑越来越多(代码腐烂)。而React的单向数据流让组件与组件之间没有直接的沟通方式,规范化从技术层面就被强化,进而才会产生了_Redux_、_Flux_这一类按照“分-总-分”的模式(实际上就是一个消息总线模式)去控制模块间沟通的。所以将业务逻辑相关性并不强的页面和菜单放置在一个地方处理并不合理。 - 绝大多数项目都不是一个人开发的,一个架构设计者要考虑到未来参与项目的开发者水平参差不齐。如果让框架级的结构直接暴露到业务开发者的面前,保不准某个负责业务开发的小伙伴忽略或修改了什么代码导致框架级的坑出现。
- 按照上面的代码,实际上要求每个内页都保留
const menus = await getMenus();
、这一类的代码(每个内页都复制粘贴)。在架构上这叫“样板式代码”,架构设计者应当尽量将这些代码通过“分层”的方式放到一个地方去处理。
所以有理由为_Nextjs_的 ./pages
之外的组件实现ssr数据异步加载。
组件ssr异步数据实现
为了实现本文的需求——让所有组件实现类似于 getInitialProps()
的方法,我们先要理清_Nextjs_前后端渲染的过程。
渲染过程
_Nextjs_为使用者提供了 ./pages/_app.js
和 ./pages/_document.js
在内页处理之前执行某些任务,后者用于构建整个HTML的结构。并且 ./pages/_document.js
只会在服务端执行。本文将开发者自行实现的内页称为_page,现在对于_Nextjs_就有三个类型的构建——_
document
、_
app_和_component
,每个构建都可以包含 static getInitialProps()
、 constructor()
和 render()
方法,他们的执行过程如下。
服务端执行过程
- _document getInitialProps()
- _app getInitialProps()
- _page getInitialProps()
- _app constructor()
- _app render()
- _page constructor()
- _page render()
- _document constructor()
- _document render()
以上的过程分解如下:
-
组装异步数据(1~3):服务端会先开始执行
_document.getInitialProps()
这个静态方法,方法中会执行_app.getInitialProps()
再遍历所有的_page.getInitialProps()
执行到这里所有的异步数据完成组装。 -
渲染React组件(4~7):有了数据之后开始渲染页面,会使用
ReactDOMServer
执行产生一个HTML格式的字符串。 -
构建静态HTML(8~9):有了
ReactDOMServer
产生的字符串剩下的工作就是将其组装为一个标准的HTML文档返回给客户端。
客户端执行过程
初始化页面时(首次打开页面):
- _app constructor()
- _app render()
- _page constructor()
- _page render()
客户端在首次打开页面时(或刷新页面)服务端已经提供了完整的HTML文档可以立即显示。此时React的组件依然执行一次虚拟Dom渲染,所以所有的组件都会执行。然后_Nextjs_利用类似于_React_服务端渲染的_checksum_的机制防止虚拟Dom对真实Dom进行渲染,关于_React_服务端渲染的_checksum_机制可以到 React 前后端同构防止重复渲染一文了解。
内页跳转时(通过 next/link
跳转):
- _app getInitialProps()
- _page getInitialProps()
- _app render()
- _page constructor()
- _page render()
客户端跳转到一个新的内页和服务端渲染就没有什么关系了。__app和_page_的 getInitialProps()
先组装数据,然后通过 props
将组装好的数据传递给组件去渲染。需要注意的是_app的构造方法在内页跳转的时候并不会执行,因为它只在整个页面渲染的时候实例化一次。
实现
在了解_Nextjs_解执行过程之后实现需求就很简单了——先通过_document或_app的 getInitialProps()
方法完成数据组装,然后将数据传递给对应的组件即可。当然按照分而治之的思想不能直接在框架去完成业务的事,需要为组件提供一个注册接口然后由_document或_app使用注册的方法去构建业务数据。
数据加载方法注册
首先需要为我们组件提供一个注册异步加载数据的接口,组件可以利用这个接口注册异步加载数据的方法让框架统一去 getInitialProps()
执行。 ./util/serverInitProps.js
提供了这个功能:
const FooDict = {};
//注册方法
export const registerAsyncFoo = (key, foo, params = {}) => {
FooDict[key] = {foo, params};
};
//获取方法
export const executeAsyncFoo = async () => {
const valueDict = {};
const keys = Object.keys(FooDict);
for (let key of keys) {
const dict = FooDict[key];
valueDict[key] = await dict.foo(dict.params);
}
return valueDict;
};
复制代码
然后我们在 menu
组件中注册异步获取数据的方法:
registerAsyncFoo('menus', getMenus);
复制代码
getMenus
模拟异步获取数据的过程:
import {Menus} from "../../../../data/menuData";
export const getMenus = () => {
//可以将这个promise修改为一个net方法实现异步动态装菜菜单
return new Promise((resolve, reject) => {
resolve(Menus)
})
};
复制代码
注册完成后再 _app
中执行异步加载:
import {executeAsyncFoo} from "../util/serverInitProps";
class ExpressApp extends App {
static async getInitialProps({Component, router, ctx}) {
info('Execute _App getInitialProps()!', 'executeReport');
/**
* app的getInitialProps会在服务端被调用一次,在前端每次切换页面时被调用。
*/
let pageProps = {}, appProps = {};
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx);
}
if (ctx && !ctx.req) {//客户端执行
appProps = window.__NEXT_DATA__.props.appProps;
} else {//服务端执行
appProps = await executeAsyncFoo();
}
return {pageProps, appProps}
}
//other function
}
复制代码
在服务端获取到数据之后会返回给 _ducoment
,_Nextjs_会将这些数据写到HTML的 window.__NEXT_DATA__
对象上而后在客户端可以从这个对象获取到已经在服务端加载的数据。 最后用React的Context特性传递数据,有需要用到这些数据的组件可以从 ApplicationContext
中获取这些数据:
//_app import ApplicationContext from '../components/app/applicationContext' class ExpressApp extends App { //other function render() { info('Execute _App render()!', 'executeReport'); const {Component, pageProps, appProps} = this.props; return ( ) } //other function } //menu import ApplicationContext from '../applicationContext' const Menu = props => { return ( {appProps => { const {menus} = appProps; return menus.map(menu => ( )) }} ); }; 复制代码
./util/serverInitProps.js
可以在任何组件中使用,_app
会逐一执行方法获取数据按照kev-value的方式设置到ApplicationContext
中,而任意组件要做的仅仅是从ApplicationContext
拿到目标数据。当然传递数据的方式不仅仅局限于React的Context特性,换成Redux或全局管理数据的方法都是可行的。
原文转载自 @随风溜达的向日葵