SSR 有两种模式,单页面和非单页面模式,第一种是后端首次渲染的单页面应用,第二种是彻底使用后端路由的后端模版渲染模式。他们区别在于使用后端路由的程度。javascript
为何说首次加载快呢。 一个普通的单页面应用,首次加载的时候须要把全部相关的静态资源加载完毕,而后核心 JS 才会开始执行,这个过程就会消耗必定的时间,接着还会请求网络接口,最终才能彻底渲染完成。html
注意:页面能很快的展现出来,可是因为当前返回的只是单纯展现的 DOM、CSS,其中的 JS 相关的事件等在客户端其实并无绑定,因此最终仍是须要 JS 加载完之后,对当前的页面再进行一次渲染,称为同构。 因此 SSR 就是更快的先展现出页面的内容,先让用户可以看到。前端
为何 SEO 友好呢,由于搜索引擎爬虫在爬取页面信息的时候,会发送 HTTP 请求来获取网页内容,而咱们服务端渲染首次的数据是后端返回的,返回的时候已是渲染好了 title,内容等信息,便于爬虫抓取内容。java
import Index from "../pages/index";
import List from "../pages/list";
const routers = [
{ exact: true, path: "/", component: Index },
{ exact: true, path: "/list", component: List }
];复制代码
咱们在 client 的 router 文件夹中创建两个 JS 文件 index 和 pages:node
pages 里配置路由路径和组件的映射,代码大体以下,使其能被客户端路由和服务端路由同时使用。react
import Index from "../pages/index";
import List from "../pages/list";
const routers = [
{ exact: true, path: "/", component: Index },
{ exact: true, path: "/list", component: List }
];
//注册页面和引入组件,存在对象中,server路由匹配后渲染
export const clientPages = (() => {
const pages = {};
routers.forEach(route => {
pages[route.path] = route.component;
});
return pages;
})();
export default routers;复制代码
在 server 路由中代码大体是这样的,在服务端获取到get请求之后,匹配路径,若是路径 path 是有映射页面组件的,获取到此组件并渲染,这就是咱们的第一步:后端拦截路由,根据路径找到须要渲染的 react 页面组件。 webpack
import { clientPages } from "./../../client/router/pages";
router.get("*", (ctx, next) => {
let component = clientPages[ctx.path];
if (component) {
const data = await component.getInitialProps();
//由于component是变量,因此须要create
const dom = renderToString(
React.createElement(component, {
ssrData: data
})
)
}
})复制代码
这一步比较重要,为何咱们须要一个静态方法,而不是直接把请求写在 willmount 中呢。 由于在服务端使用 renderToString 渲染组件时,生命周期只会执行到 willmount 以后第一次 render,在 willmount 内部,请求是异步的,第一次 render 完成的时候,异步的数据都没有获取到,这个时候 renderToString 就已经返回了。 那咱们页面的初始化数据就没有了,返回的 HTML 不是咱们所指望的。 所以定义了一个静态方法,在组件实例化以前获取到这个方法,同步执行,数据获取完成后,经过 props 把数据传入给组件进行渲染。 git
那么这个方法是如何实现的呢? 咱们根据代码截图来看 base.js:github
import React from "react";
export default class Base extends React.Component {
//override 获取须要服务端首次渲染的异步数据
static async getInitialProps() {
return null;
}
static title = "react ssr";
//page组件中不要重写constructor
constructor(props) {
super(props);
//若是定义了静态state,按照生命周期,state应该优先于ssrData
if (this.constructor.state) {
this.state = {
...this.constructor.state
};
}
//若是是首次渲染,会拿到ssrData
if (props.ssrData) {
if (this.state) {
this.state = {
...this.state,
...props.ssrData
};
} else {
this.state = {
...props.ssrData
};
}
}
}
async componentWillMount() {
//客户端运行时
if (typeof window != "undefined") {
if (!this.props.ssrData) {
//非首次渲染,也就是单页面路由状态改变,直接调用静态方法
//咱们不肯定有没有异步代码,若是getInitialProps直接返回一个初始化state,这样会形成自己应该同步执行的,由于await没有同步执行,形成状态混乱
//因此建议初始化state须要写在class属性中,用static静态方法定义,constructor时会将其合并到实例中。
//为何不直接写state属性而要加static,由于默认属性会执行在constructor以后,这样会覆盖constructor定义的state
const data = await this.constructor.getInitialProps(); //静态方法,经过构造函数获取
if (data) {
this.setState({ ...data });
}
}
//设置标题
document.title = this.constructor.title;
}
}
}复制代码
首先在 client 的 pages 里新建一个 base 组件,base 继承 React.Component,全部 pages 里的页面组件都须要继承这个 base,base 有一个静态方法 getInitialProps,此方法主要是返回组件初始化须要的异步数据。 若是有初始化的 ajax 请求,就应该重写在此方法里,而且 return 数据对象。 web
constructor 判断了页面组件是否有初始化定义的 state 静态属性,有的话传递给组件实例化的 state 对象,若是 props 有传入 ssrData,把 ssrData 传递值给组件 state 对象。
base 中的 componentWillMount 会判断是否还须要去执行 getInitialProps 方法,若是在服务端渲染的时候,数据已经在组件实例化以前同步获取并传入了 props,因此忽略。
若是在客户端环境,分两种状况。
第一种:用户第一次进到页面,这时候是服务端去请求的数据,服务端获取到数据后在服务端渲染组件,同时也会把数据存放在 HTML 的 script 代码中,定义一个全局变量 ssrData,以下图,react 在注册单页面应用而且同构的时候会把全局 ssrData 传递给页面组件,这个时候页面组件在客户端同构渲染的时候,就能够延续使用服务端以前的数据,这样也保持了同构的一致性,也避免了一次重复请求。
第二种状况:就是当前用户在单页面之中切换路由,这样就没有服务端渲染,那么就执行 getInitialProps 方法,把数据直接返回给 state,几乎等同于在 willmount 中执行请求。 这样封装咱们就能够用一套代码兼容服务端渲染和单页面渲染。
client/app.js
import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
render() {
return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
}
}
hydrate(
<App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
document.getElementById("root")
);复制代码
再看看如何写页面组件,下面是页面组件 Index 的截图,Index 继承 Base,定义了静态 state,组件 constructor 方法会把此对象传递给组件实例化的 state 对象中,之因此用静态方法来写默认数据,是想保证定义的默认 state 先传递给实例对象的 state,接口请求传递的props 数据后传递给实例对象的 state。
为何不直接写 state 属性而要加 static,由于 state 属性会执行在 constructor 以后,这样会覆盖 constructor 定义的 state,也就是会覆盖咱们 getInitialProps 返回的数据。
export default class Index extends Base {
//注意看看:base关于getInitialProps的注释
static state = {
desc: "Hello world~"
};
//替代componentWillMount
static async getInitialProps() {
let data;
const res = await request.get("/api/getData");
if (!res.errCode) data = res.data;
return {
data
};
}
}复制代码
注意:在服务端渲染环境下,执行 renderToString 的时候,组件会被实例化,而且返回字符串形式的 DOM,这个过程 react 组件的生命周期只会执行到 willmount 以后的 render。
3)咱们写好一个 HTML 文件,大体以下。 当前已经渲染出了相应的节点字符串,后端须要返回 HTML 文本,内容应该包含标题,节点和最后须要加载的打包好的 JS,依次去替换 HTML 占位部分。
<!DOCTYPE html>
<html lang="en"> <head> <title>/*title*/</title> </head> <body> <div id="root">$$$$</div> <script> /*getInitialProps*/ </script> <script src="/*app*/"></script> <script src="/*vendor*/"></script> </body> </html>复制代码
server/router.js
indexHtml = indexHtml.replace("/*title*/", component.title);
indexHtml = indexHtml.replace(
"/*getInitialProps*/",
`window.ssrData=${JSON.stringify(data)};window.ssrPath='${ctx.path}'`
);
indexHtml = indexHtml.replace("/*app*/", bundles.app);
indexHtml = indexHtml.replace("/*vendor*/", bundles.vendor);
ctx.response.body = indexHtml;
next();复制代码
4)最后客户端 JS 加载完成后,会运行 react,而且执行同构方法 ReactDOM.hydrate,而不是平时用的 ReactDOM.render。
import React from "react";
import { hydrate } from "react-dom";
import Router from "./router";
class App extends React.Component {
render() {
return <Router ssrData={this.props.ssrData} ssrPath={this.props.ssrPath} />;
}}
hydrate(
<App ssrData={window.ssrData} ssrPath={window.ssrPath} />,
document.getElementById("root")
);复制代码
如下是首次渲染过程大体流程图,点击查看大图
如今咱们已经完成了最核心的逻辑,可是有一个问题。 我发如今后端渲染组件的时候,style-loader 会报错,style-loader 会找到组件依赖的 CSS,并在组件加载时,把 style 载入到 HTML header 中,可是咱们在服务端渲染的时候,没有 window 对象,所以 style-loader 内部代码会报错。
服务端 webpack 须要移除 style-loader,用其余方法代替,后来我把样式赋值给组件静态变量,而后经过服务端渲染一并返回给前端,可是有个问题,我只能拿到当前组件的样式,子组件的样式没办法拿到,若是要给子组件再添加静态方法,再想办法去取,那就太麻烦了。
后来我找到了一个库 isomorphic-style-loader 能够支持咱们想要的功能,看了下它的源码和使用方法,经过高阶函数把样式赋值给组件,而后利用 react 的 Context,拿到当前须要渲染的全部组件的样式,最后把 style 插入到 HTML 中,这样解决了子组件样式没法导入的问题。 可是我以为有点麻烦,首先须要定义全部组件的高阶函数和引入这个库,而后在 router 之中须要写相关代码收集 style,最后插入到 HTML 中。
以后我定义了一个 ProcessSsrStyle 方法,入参是 style 文件,逻辑是判断环境,若是是服务端把 style 加载到当前组件的 DOM 中,若是是客户端就不处理(由于客户端有style-loader)。 实现和使用很是简单,以下:
ProcessSsrStyle.js
import React from "react";
export default style => {
if (typeof window != "undefined") {
//客户端
return;
}
return <style>{style}</style>;
};复制代码
使用:
render() {
return (
<div className="index"> {ProcessSsrStyle(style)} </div>
);
}复制代码
服务端返回 HTML 的内容以下,用户立刻可以看到完整的页面样式,而当客户端 react 同构完成后,DOM 会被替换为纯 DOM,由于 ProcessSsrStyle 方法在客户端不会输出 style,最终style-loader 执行后 header 中也会有样式,,页面不会出现不一致的变化,对于用户来讲这一切都是无感的。
至此,最核心的功能已经实现,可是在后来的开发中,我发现事情还并无那么简单,由于开发环境彷佛太不友好了,开发效率低,须要手动重启。
先说说最初的开发环境如何工做:
webpack 打包后,启动了两个服务,一个是服务端的 app 应用、端口为 9999,一个是客户端的 dev-server、端口为 8888,dev-server 会监听和打包 client 代码,能够在客户端代码更新的时候,实时热更新前端代码。
当访问 localhost:9999时,server 会返回 HTML,咱们的 server 返回的 HTML 中的 JS 脚本路径是指向的 dev-serve 端口的地址,以下图。 也就是说,客户端的程序和服务端的程序被分别打包,而且运行两个不一样的端口服务。
在生产环境下,由于不须要 dev-server 去监听和热更新,所以只一个服务就足够, 以下图,服务端注册静态资源文件夹:
server/app.js
app.use(
staticCache("dist/client", {
cacheControl: "no-cache,public",
gzip: true
})
);复制代码
目前的构建系统,区分了生产环境和开发环境,如今的开发环境构建是没有什么问题的。 可是开发环境问题就比较明显,存在的最大问题是服务端没有热更新或者从新打包重启。 这样会致使不少问题,最严重的就是前端已经更新了组件,可是服务端并无更新,因此在同构的时候会出现不一致,就会致使报错,有些报错会影响运行,解决办法只有重启。 这样的开发体验是没法忍受的。 后来我开始考虑作服务端的热更新。
最初个人方法是监听修改,打包而后重启应用。 还记得咱们的 client/router/pages.js 文件吗,客户端和服务端的路由都引入了这个文件,因此服务端和客户端的打包依赖都有pages.js,所以全部 pages 的组件相关的依赖均可以被客户端和服务端监听,当一个组件更新了,dev-server 已经帮助咱们监听和热更新了客户端代码,如今咱们要本身来处理如下如何更新和重启服务端代码。
其实方法很简单,就是在服务端打包配置里开启监听,而后在插件配置中,写一个重启的插件,插件代码以下:
plugins: [
new function() {
this.apply = compiler => {
//自定义注册钩子函数,watch监听修改并编译完成后,done被触发,callback必须执行,不然不会执行后续流程
compiler.hooks.done.tap(
"recomplie_complete",
(compilation, callback) => {
if (serverChildProcess) {
console.log("server recomplie completed");
serverChildProcess.kill();
}
serverChildProcess = child_process.spawn("node", [
path.resolve(cwd, "dist/server/bundle.js"),
"dev"
]);
serverChildProcess.stdout.on("data", data => {
console.log(`server out: ${data}`);
});
serverChildProcess.stderr.on("data", data => {
console.log(`server err: ${data}`);
});
callback && callback();
}
);
};
}()
]复制代码
当 webpack 首次运行以后,插件会启动一个子进程,运行 app.js,当文件发生变更后,再次编译,判断是否有子进程,若是有杀掉子进程,而后重启子进程,这样就实现了自动重启。 由于客户端和服务端是两个不一样的打包服务和配置,当文件被修改,他们同时会从新编译,为了保证编译后运行符合预期,要保证服务端先编译完成,客户端后编译完成,因此在客户端的 watch 配置里,增长一点延迟,以下图,默认是 300 毫秒,因此服务端是 300 毫秒后执行编译,而客户端是 1000 毫秒后执行编译。
watchOptions: {
ignored: ["node_modules"],
aggregateTimeout: 1000 //优化,尽可能保证后端从新打包先执行完
}复制代码
如今解决了重启问题,可是我以为还不够,由于在开发的大部分时间里 pages.js 中组件,也就是展现端的代码更新频率会很高,若是总是去重启编译后端的代码,我以为效率过低。 所以我以为再作一次优化。
流程应该是这样的,增长一个 webpack.server-dev-pages.js 配置文件,单独监听和打包出 dist/pages,服务端代码判断若是是开发环境,在路由监听方法中每次执行都从新获取dist/pages 包,服务端监听配置忽略 client 文件夹。
看起来有点懵逼,其实最终的效果就是当 pages 中依赖的组件发生了更新,webpack.server-dev-pages.js 从新编译并打包到 dist/pages中,服务端app不编译和重启,只须要在服务端app路由中从新获取最新的 dist/pages 包,就保证了服务应用更新了全部客户端组件,而服务端应用并不会编译和重启。 当服务端自己的代码发生了修改,仍是会自动编译和重启。
因此最终咱们的开发环境须要启动3个打包配置
server/router,如何清除和更新 pages 包
const path = require("path");
const cwd = process.cwd();
delete __non_webpack_require__.cache[
__non_webpack_require__.resolve(
path.resolve(cwd, "dist/pages/pages.js")
)];
component = __non_webpack_require__(
path.resolve(cwd, "dist/pages/pages.js")
).clientPages[ctx.path];复制代码
至此,比较满意的开发环境基本实现了。 后来又以为每次更新 CSS 都须要去从新打包后端的pages 也没有必要,加上同构的时候 CSS 不一致,仅仅只有警告,没有实质影响,所以我在server-dev-pages 中忽略了 less 文件(由于我用的 less)。 这样会致使一个问题,由于没有更新pages,因此页面会刷新时会先展现旧的样式,而后同构完成又立马变成新样式,在开发环境中这一瞬间是能够接受的,也不影响什么。 可是避免了无谓的编译。
watchOptions: {
ignored: ["**/*.less", "node_modules"] //忽略less,样式修改并不会影响同构
}复制代码
最初作本身小站的目的是学习,加上本身使用,所以有太多个性的东西。 从本身的小站中抽离了出来,已经删去了不少包和代码,只为了让他人更能快速理解其中的核心代码。 代码中有不少注释都能帮助他人理解,若是你们想使用当前库开发一个本身的小站,是彻底能够的,也能够帮助你们更好的理解它。 若是是用于商业项目,推荐 nextjs。
CSS 没有作做用域控制,所以若是想隔离做用域,手动添加上层 CSS 隔离,好比 .index{ ..... } 包裹一层,或者尝试本身引入三方包。
webpack 通用的配置能够封装成一个文件,而后在每一个文件里引入,再个性修改。 可是以前看其余代码的时候发现,这种方法,会增长阅读难度,加上自己配置内容很少,因此不作封装,看起来更直观。
开发环境下,图片路径会出现不一致,好比客户端地址请求地址是 localhost...assets/xx.jpg,而服务端是 assets/xx.jpg,可能会有警告,可是不影响。 由于只是一个是绝对路径,一个是相对路径。
对于此次的 SSR 服务端渲染的实现仍是挺满意的,也花费了挺多时间。 感觉下加载速度吧,欢迎访问大诗人小站,dashiren.cn/ 。 部分页面有接口请求,好比dashiren.cn/space,加载速度依然很快。
仓库已经准备好,下载下来试试吧,安装依赖后,运行命令便可。github.com/zimv/react-…
码字不易,点个赞吧~