随着愈来愈多新型前端框架的推出,SSR 这个概念在前端开发领域的流行度愈来愈高,也有愈来愈多的项目采用这种技术方案进行了实现。SSR 产生的背景是什么?适用的场景是什么?实现的原理又是什么?但愿你们在这篇文章中可以找到你想要的答案。css
说到 SSR,不少人的第一反应是“服务器端渲染”,但我更倾向于称之为“同构”,因此首先咱们来对“客户端渲染”,“服务器端渲染”,“同构”这三个概念简单的作一个分析:前端
客户端渲染:客户端渲染,页面初始加载的 HTML 页面中无网页展现内容,须要加载执行JavaScript 文件中的 React 代码,经过 JavaScript 渲染生成页面,同时,JavaScript 代码会完成页面交互事件的绑定,详细流程可参考下图(图片取材自 fullstackacademy.com):node
服务器端渲染:用户请求服务器,服务器上直接生成 HTML 内容并返回给浏览器。服务器端渲染来,页面的内容是由 Server 端生成的。通常来讲,服务器端渲染的页面交互能力有限,若是要实现复杂交互,仍是要经过引入 JavaScript 文件来辅助实现。服务器端渲染这个概念,适用于任何后端语言。react
同构:同构这个概念存在于 Vue,React 这些新型的前端框架中,同构其实是客户端渲染和服务器端渲染的一个整合。咱们把页面的展现内容和交互写在一块儿,让代码执行两次。在服务器端执行一次,用于实现服务器端渲染,在客户端再执行一次,用于接管页面交互,详细流程可参考下图(图片取材自 fullstackacademy.com):webpack
通常状况下,当咱们使用 React 编写代码时,页面都是由客户端执行 JavaScript 逻辑动态挂 DOM 生成的,也就是说这种普通的单页面应用实际上采用的是客户端渲染模式。在大多数状况下,客户端渲染彻底可以知足咱们的业务需求,那为何咱们还须要 SSR 这种同构技术呢?web
CSR 项目的 TTFP(Time To First Page)时间比较长,参考以前的图例,在 CSR 的页面渲染流程中,首先要加载 HTML 文件,以后要下载页面所需的 JavaScript 文件,而后 JavaScript 文件渲染生成页面。在这个渲染过程当中至少涉及到两个 HTTP 请求周期,因此会有必定的耗时,这也是为何你们在低网速下访问普通的 React 或者 Vue 应用时,初始页面会有出现白屏的缘由。express
CSR 项目的 SEO 能力极弱,在搜索引擎中基本上不可能有好的排名。由于目前大多数搜索引擎主要识别的内容仍是 HTML,对 JavaScript 文件内容的识别都还比较弱。若是一个项目的流量入口来自于搜索引擎,这个时候你使用 CSR 进行开发,就很是不合适了。后端
SSR 的产生,主要就是为了解决上面所说的两个问题。在 React 中使用 SSR 技术,咱们让 React 代码在服务器端先执行一次,使得用户下载的 HTML 已经包含了全部的页面展现内容,这样,页面展现的过程只须要经历一个 HTTP 请求周期,TTFP 时间获得一倍以上的缩减。api
同时,因为 HTML 中已经包含了网页的全部内容,因此网页的 SEO 效果也会变的很是好。以后,咱们让 React 代码在客户端再次执行,为 HTML 网页中的内容添加数据及事件的绑定,页面就具有了 React 的各类交互能力。跨域
可是,SSR 这种理念的实现,并不是易事。咱们来看一下在 React 中实现 SSR 技术的架构图:
使用 SSR 这种技术,将使本来简单的 React 项目变得很是复杂,项目的可维护性会下降,代码问题的追溯也会变得困难。
因此,使用 SSR 在解决问题的同时,也会带来很是多的反作用,有的时候,这些反作用的伤害比起 SSR 技术带来的优点要大的多。从我的经验上来讲,我通常建议你们,除非你的项目特别依赖搜索引擎流量,或者对首屏时间有特殊的要求,不然不建议使用 SSR。
好,若是你确实遇到了 React 项目中要使用 SSR 的场景并决定使用 SSR,那么接下来咱们就结合上面这张 SSR 架构图,开启 SSR 技术点的难点剖析。
在开始以前,咱们先来分析下虚拟 DOM 和 SSR 的关系。
上面咱们说过,SSR 的工程中,React 代码会在客户端和服务器端各执行一次。你可能会想,这没什么问题,都是 JavaScript 代码,既能够在浏览器上运行,又能够在 Node 环境下运行。但事实并不是如此,若是你的 React 代码里,存在直接操做 DOM 的代码,那么就没法实现 SSR 这种技术了,由于在 Node 环境下,是没有 DOM 这个概念存在的,因此这些代码在 Node 环境下是会报错的。
好在 React 框架中引入了一个概念叫作虚拟 DOM,虚拟 DOM 是真实 DOM 的一个 JavaScript 对象映射,React 在作页面操做时,实际上不是直接操做 DOM,而是操做虚拟 DOM,也就是操做普通的 JavaScript 对象,这就使得 SSR 成为了可能。在服务器,我能够操做 JavaScript 对象,判断环境是服务器环境,咱们把虚拟 DOM 映射成字符串输出;在客户端,我也能够操做 JavaScript 对象,判断环境是客户端环境,我就直接将虚拟 DOM 映射成真实 DOM,完成页面挂载。
其余的一些框架,好比 Vue,它可以实现 SSR 也是由于引入了和 React 中同样的虚拟 DOM 技术。
好,接下来咱们回过头看流程图,前两步不说了,服务器端渲染确定要先向 Node 服务器发送请求。重点是第 3 步,你们能够看到,服务器端要根据请求的地址,判断要展现什么样的页面了,这一步叫作服务器端路由。
咱们再看第 10 步,当客户端接收到 JavaScript 文件后,要根据当前的路径,在浏览器上再判断当前要展现的组件,从新进行一次客户端渲染,这个时候,还要经历一次客户端路由(前端路由)。
那么,咱们下面要说的就是服务器端路由和客户端路由的区别。
实现 React 的 SSR 架构,咱们须要让相同的 React 代码在客户端和服务器端各执行一次。你们注意,这里说的相同的 React 代码,指的是咱们写的各类组件代码,因此在同构中,只有组件的代码是能够公用的,而路由这样的代码是没有办法公用的,你们思考下这是为何呢?其实缘由很简单,在服务器端须要经过请求路径,找到路由组件,而在客户端需经过浏览器中的网址,找到路由组件,是彻底不一样的两套机制,因此这部分代码是确定没法公用。咱们来看看在 SSR 中,先后端路由的实现代码:
客户端路由:
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<div>
<Route path='/' component={Home}>
</div>
</BrowserRouter>
</Provider>
)
}
ReactDom.render(<App/>, document.querySelector('#root'))
复制代码
客户端路由代码很是简单,你们必定很熟悉,BrowserRouter 会自动从浏览器地址中,匹配对应的路由组件显示出来。
服务器端路由代码:
const App = () => {
return
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<div>
<Route path='/' component={Home}>
</div>
</StaticRouter>
</Provider>
}
Return ReactDom.renderToString(<App/>)
复制代码
服务器端路由代码相对要复杂一点,须要你把 location(当前请求路径)传递给 StaticRouter 组件,这样 StaticRouter 才能根据路径分析出当前所须要的组件是谁。(PS:StaticRouter 是 React-Router 针对服务器端渲染专门提供的一个路由组件。)
经过 BrowserRouter 咱们可以匹配到浏览器即将显示的路由组件,对浏览器来讲,咱们须要把组件转化成 DOM,因此须要咱们使用 ReactDom.render 方法来进行 DOM 的挂载。而 StaticRouter 可以在服务器端匹配到将要显示的组件,对服务器端来讲,咱们要把组件转化成字符串,这时咱们只须要调用 ReactDom 提供的 renderToString 方法,就能够获得 App 组件对应的 HTML 字符串。
对于一个 React 应用来讲,路由通常是整个程序的执行入口。在 SSR 中,服务器端的路由和客户端的路由不同,也就意味着服务器端的入口代码和客户端的入口代码是不一样的。
咱们知道, React 代码是要经过 Webpack 打包以后才能运行的,也就是第 3 步和第10 步运行的代码,其实是源代码打包事后生成的代码。上面也说到,服务器端和客户端渲染中的代码,只有一部分一致,其他是有区别的。因此,针对代码运行环境的不一样,要进行有区别的 Webpack 打包。
简单写两个 Webpack 配置文件做为 DEMO:
客户端 Webpack 配置:
{
entry: './src/client/index.js',
output: {
filename: 'index.js',
path: path.resolve(__dirname, 'public')
},
module: {
rules: [{
test: /\.js?$/,
loader: 'babel-loader'
},{
test: /\.css?$/,
use: ['style-loader', {
loader: 'css-loader',
options: {modules: true}
}]
},{
test: /\.(png|jpeg|jpg|gif|svg)?$/,
loader: 'url-loader',
options: {
limit: 8000,
publicPath: '/'
}
}]
}
}
复制代码
服务器端 Webpack 配置:
{
target: 'node',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'build')
},
externals: [nodeExternals()],
module: {
rules: [{
test: /\.js?$/,
loader: 'babel-loader'
},{
test: /\.css?$/,
use: ['isomorphic-style-loader', {
loader: 'css-loader',
options: {modules: true}
}]
},{
test: /\.(png|jpeg|jpg|gif|svg)?$/,
loader: 'url-loader',
options: {
limit: 8000,
outputPath: '../public/',
publicPath: '/'
}
}]
}
};
复制代码
上面咱们说了,在 SSR 中,服务器端渲染的代码和客户端的代码的入口路由代码是有差别的,因此在 Webpack 中,Entry 的配置首先确定是不一样的。
在服务器端运行的代码,有时咱们须要引入 Node 中的一些核心模块,咱们须要 Webpack 作打包的时候可以识别出相似的核心模块,一旦发现是核心模块,没必要把模块的代码合并到最终生成的代码中,解决这个问题的方法很是简单,在服务器端的 Webpack配置中,你只要加入 target: node 这个配置便可。
服务器端渲染的代码,若是加载第三方模块,这些第三方模块也是不须要被打包到最终的源码中的,由于 Node 环境下经过 NPM 已经安装了这些包,直接引用就能够,不须要额外再打包到代码里。为了解决这个问题,咱们可使用 webpack-node-externals 这个插件,代码中的 nodeExternals 指的就是这个插件,经过这个插件,咱们就能解决这个问题。关于 Node 这里的打包问题,可能看起来有些抽象,不是很明白的同窗能够仔细读一下 webpack-node-externals 相关的文章或文档,你就能很好的明白这里存在的问题了。
接下来咱们继续分析,当咱们的 React 代码中引入了一些 CSS 样式代码时,服务器端打包的过程会处理一遍 CSS,而客户端又会处理一遍。查看配置,咱们能够看到,服务器端打包时咱们用了 isomorphic-style-loader,它处理 CSS 的时候,只在对应的 DOM 元素上生成 class 类名,而后返回生成的 CSS 样式代码。
而在客户端代码打包配置中,咱们使用了 css-loader 和 style-loader,css-loader 不但会在 DOM 上生成 class 类名,解析好的 CSS 代码,还会经过 style-loader 把代码挂载到页面上。不过这么作,因为页面上的样式实际上最终是由客户端渲染时添加上的,因此页面可能会存在一开始没有样式的状况,为了解决这个问题, 咱们能够在服务器端渲染时,拿到 isomorphic-style-loader 返回的样式代码,而后以字符串的形式添加到服务器端渲染的 HTML 之中。
而对于图片等类型的文件引入,url-loader 也会在服务器端代码和客户端代码打包的过程当中分别进行打包,这里,我偷了一个懒,不管服务器端打包仍是客户端打包,我都让打包生成的文件存储在 public 目录下,这样,虽然文件会打包出来两遍,可是后打包出来的文件会覆盖以前的文件,因此看起来仍是只有一份文件。
固然,这样作的性能和优雅性并不高,只是给你们提供一个小的思路,若是想进行优化,你可让图片的打包只进行一次,借助一些 Webpack 的插件,实现这个也并不是难事,你甚至能够本身也写一个 loader,来解决这样的问题。
若是你的 React 应用中没有异步数据的获取,单纯的作一些静态内容展现,通过上面的配置,你会发现一个简单的 SSR 应用很快的就能够被实现出来了。可是,真正的一个 React 项目中,咱们确定要有异步数据的获取,绝大多数状况下,咱们还要使用 Redux 管理数据。而若是想在 SSR 应用中实现,就不是这么简单了。
客户端渲染中,异步数据结合 Redux 的使用方式遵循下面的流程(对应图中第 12 步):
而在服务器端,页面一旦肯定内容,就没有办法 Rerender 了,这就要求组件显示的时候,就要把 Store 的数据都准备好,因此服务器端异步数据结合 Redux 的使用方式,流程是下面的样子(对应图中第 4 步):
下面,咱们分析下服务器端渲染这部分的流程:
const store = createStore(reducer, defaultState)
export default store;
复制代码
然而在服务器端,这么写就有问题了,由于服务器端的 Store 是全部用户都要用的,若是像上面这样构建 Store,Store 变成了一个单例,全部用户共享 Store,显然就有问题了。因此在服务器端渲染中,Store 的建立应该像下面这样,返回一个函数,每一个用户访问的时候,这个函数从新执行,为每一个用户提供一个独立的 Store:
const getStore = (req) => {
return createStore(reducer, defaultState);
}
export default getStore;
复制代码
根据路由分析 Store 中须要的数据: 要想实现这个步骤,在服务器端,首先咱们要分析当前出路由要加载的全部组件,这个时候咱们能够借助一些第三方的包,好比说 react-router-config, 具体这个包怎么使用,不作过多说明,你们能够查看文档,使用这个包,传入服务器请求路径,它就会帮助你分析出这个路径下要展现的全部组件。
派发 Action 获取数据: 接下来,咱们在每一个组件上增长一个获取数据的方法:
Home.loadData = (store) => {
return store.dispatch(getHomeList())
}
复制代码
这个方法须要你把服务器端渲染的 Store 传递进来,它的做用就是帮助服务器端的 Store 获取到这个组件所需的数据。 因此,组件上有了这样的方法,同时咱们也有当前路由所须要的全部组件,依次调用各个组件上的 loadData 方法,就可以获取到路由所需的全部数据内容了。
// matchedRoutes 是当前路由对应的全部须要显示的组件集合
matchedRoutes.forEach(item => {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve);
})
promises.push(promise);
}
})
Promise.all(promises).then(() => {
// 生成 HTML 逻辑
})
复制代码
这里,咱们使用 Promise 来解决这个问题,咱们构建一个 Promise 队列,等待全部的 Promise 都执行结束后,也就是全部 store.dispatch 都执行完毕后,再去生成 HTML。这样的话,咱们就实现告终合 Redux 的 SSR 流程。
在上面,咱们说到,服务器端渲染时,页面的数据是经过 loadData 函数来获取的。而在客户端,数据获取依然要作,由于若是这个页面是你访问的第一个页面,那么你看到的内容是服务器端渲染出来的,可是若是通过 react-router 路由跳转道第二个页面,那么这个页面就彻底是客户端渲染出来的了,因此客户端也要去拿数据。
在客户端获取数据,使用的是咱们最习惯的方式,经过 componentDidMount 进行数据的获取。这里要注意的是,componentDidMount 只在客户端才会执行,在服务器端这个生命周期函数是不会执行的。因此咱们没必要担忧 componentDidMount 和 loadData 会有冲突,放心使用便可。这也是为何数据的获取应该放到 componentDidMount 这个生命周期函数中而不是 componentWillMount 中的缘由,能够避免服务器端获取数据和客户端获取数据的冲突。
上一部分咱们说到了获取数据的问题,在 SSR 架构中,通常 Node 只是一个中间层,用来作 React 代码的服务器端渲染,而 Node 须要的数据一般由 API 服务器单独提供。
这样作一是为了工程解耦,二也是为了规避 Node 服务器的一些计算性能问题。
请你们关注图中的第 4 步和第 12,13 步,咱们接下来分析这几个步骤。
服务器端渲染时,直接请求 API 服务器的接口获取数据没有任何问题。可是在客户端,就有可能存在跨域的问题了,因此,这个时候,咱们须要在服务器端搭建 Proxy 代理功能,客户端不直接请求 API 服务器,而是请求 Node 服务器,通过代理转发,拿到 API 服务器的数据。
这里你能够经过 express-http-proxy 这样的工具帮助你快速搭建 Proxy 代理功能,可是记得配置的时候,要让代理服务器不只仅帮你转发请求,还要把 cookie 携带上,这样才不会有权限校验上的一些问题。
// Node 代理功能实现代码
app.use('/api', proxy('http://apiServer.com', {
proxyReqPathResolver: function (req) {
return '/ssr' + req.url;
}
}));
复制代码
到这里,整个 SSR 的流程体系中关键知识点的原理就串联起来了,若是你以前适用过 SSR 框架,那么这些知识点的整理我相信能够从原理层面很好的帮助到你。
固然,我也考虑到阅读本篇文章的同窗可能有很大一部分对 SSR 的基础知识很是有限,看了文章可能会云里雾里,这里为了帮助这些同窗,我编写了一个很是简单的 SSR 框架,代码放在这里:
初学者结合上面的流程图,一步步梳理流程图中的逻辑,梳理结束后,回来再看一遍这篇文章,相信你们就豁然开朗了。
固然在真正实现 SSR 架构的过程当中,难点有时不是实现的思路,而是细节的处理。好比说如何针对不一样页面设置不一样的 title 和 description 来提高 SEO 效果,这时候,咱们其实能够用 react-helmet 这样的工具帮咱们达成目标,这个工具对客户端和服务器端渲染的效果都很棒,值得推荐。还有一些诸如工程目录的设计,404,301 重定向状况的处理等等,不过这些问题,咱们只须要在实践中遇到的时候逐个攻破就能够了。
好了,关于 SSR 的所有分享就到这里,但愿这篇文章可以或多或少帮助到你。
文章可随意转载,但请保留此 原文连接。 很是欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj(at)alibaba-inc.com 。