如今各大框架的功能已经完善,如果你现在想做同构渲染,我推举直策应用next.js(react)或nuxt.js(vue)来进行开拓,而不是像我一样手动进行实现。
本文紧张是对付同构事理的描述,不涉及框架的利用。

为了让小白也能看懂,文章会包含很多特殊根本的理论描述,如果以为没必要理解,你可以通过标题跳转到自己感兴趣的部分。
文章中的代码紧张以vue为例,但是事理不局限于任何框架。

点击这里查看完全代码和PPT

jsp替换字符串前端页面秒开的症结  小白也能看懂的同构衬着道理和实现 Angular

1. 什么是同构渲染?为什么利用它?1.1 什么是渲染?

以现在前端盛行的react和vue框架为例。
react中的jsx和vue里面的模板,都是是无法直接在浏览器运行的。
将它们转换成可在浏览器中运行的html,这个过程被称为渲染。

1.2 什么是客户端渲染(client-side-render, 以下简称csr)

CSR是现在前端开拓者最熟习的渲染办法。
利用vue-cli或create-react-app创建一个运用,不作任何额外配置直接打包的出来代码便是CSR。

你可以用如下的方法辨别一个web页面是否是CSR:打开chrome掌握台 - 网络面板,查看第一条要求,就能看到当前页面向做事器要求的html资源;如果是CSR(如下图所示),这个html的body中是没有实际内容的。

那么页面内容是如何渲染出来的呢?仔细看上面的html,会创造存在一个script标签,打包器正是把全体运用都打包进了这个js文件里面。

当浏览器要求页面的时候,做事器先会返回一个空的html和打包好的js代码;等到js代码下载完毕,浏览器再实行js代码,页面就被渲染出来了。
由于页面的渲染是在浏览器中而非做事器端进行的,以是被称为客户端渲染。

CSR的利害

CSR会把全体网站打包进js里,当js下载完毕后,相称于网站的页面资源都被下载好了。
这样在跳转新页面的时候,不须要向做事器再次要求资源(js会直接操作dom进行页面渲染),从而让全体网站的利用体验上更加流畅。

但是这种做法也带来了一些问题:在要求第一个页面的时候须要下载js,而下载js直至页面渲染出来这段韶光,页面会由于没有任何内容而涌现白屏。
在js体积较大或者渲染过程较为繁芜的情形下,白屏问题会非常明显。

其余,由于利用了CSR的网站,会先下载一个空的html,然后才通过js进行渲染;这个空的html会导致某些搜索引擎无法通过爬虫精确获取网站信息,从而影响网站的搜索引擎排名(一样平常称之为搜索引擎优化Search Engine Optimization,简称SEO)。

总而言之,客户端渲染便是通过捐躯首屏加载速率和SEO,来获取用户体验的一种技能。

1.3 什么是做事器端渲染(server-side-render, 以下简称SSR)

理解了CSR,SSR也很好理解了,实在便是把渲染过程放在了在做事器端。
以从前比较盛行的java做事器端渲染技能jsp为例,会先写一个html模板,并用分外的语法<%...%>标记动态内容,里面可以写一些java程序。

渲染的时候,jsp会通过字符串更换的办法,把<%...%>更换为程序实行的结果。
末了做事器将更换完毕的html以字符串的形式发送给用户即可。

同时我们还可以写很多个JSP,根据用户的http要求路径返回相应的文件,这样就完成了一个网站的开拓。

// jsp示例 <body> <hr> <hr> <h2>java脚本1</h2> <% Object obj = new Object(); System.out.println(obj); out.write(obj.toString()); // 这一行表示把结果输出到终极的html中 %> <hr> <hr> <% out.write(obj.toString()); %> </body>

像jsp这类SSR技能,利害势和客户端渲染恰好相反:由于html在做事器端就已经渲染好了,以是不存在客户真个白屏和seo问题;相对应地,每次跳转页面都要向做事看重新要求,意味着用户每次切换页面都要等待一小段韶光,以是用户体验方面则不如客户端。

还有一点显而易见的问题,便是SSR比较CSR会占用较多的做事器端资源。

总而言之,做事器端渲染拥有良好的首屏性能和SEO,但用户体验方面较差。
且会占用较多的做事器端资源。

1.4 什么是同构(Isomorphic)

可以看到,CSR和SSR的利害势是互补的,以是只要把它们二者结合起来,就能实现空想的渲染方法,也便是同构渲染。

同构的理念十分大略,最开始的步骤和SSR相同,将天生的html字符串返回给用户即可;但同时我们可以将CSR天生的JS也一并发送给用户;这样用户在吸收到SSR天生的html后,页面还会再实行一次CSR的流程。

这导致用户只有要求的第一个页面是在做事器端渲染的,其他页面则都是在客户端进行的。
这样我们就拥有了一个同时兼顾首屏、SEO和用户体验的网站。

当然这只是最大略的观点描述,实际操作起来仍旧有不少难点。
我将在后面的内容逐一指出。

1.5 CSR、SSR、同构渲染比拟

以下摘自《vue.js设计与实现》

CSR

SSR

同构

SEO

不友好

友好

友好

白屏问题

占用做事器资源

用户体验

2. 一个最大略的同构案例

查看完全的代码可以点击这里。

2.1 做事器端渲染html字符串

前面说过,同构渲染可以看作把SSR和CSR进行结合。
单独完成SSR和CSR都很大略:CSR就不用说了;SSR的话,vue和react都供应了renderToString函数,只要将组件传入这个函数,可以直接将组件渲染成html字符串。

还有一点须要把稳的是,在客户端渲染里我们会利用createApp来创建一个vue运用实例,但在同构渲染中则须要更换成createSSRApp。
如果仍旧利用原来的createApp,会导致首屏页面先在做事器端渲染一次,浏览器端又重复渲染一次。

而利用了createSSRApp,vue就会在浏览器端渲染前前辈行一次检讨,如果结果和做事器端渲染的结果同等,就会停滞首屏的客户端渲染过程,从而避免了重复渲染的问题。

代码如下:

import { renderToString } from 'vue/server-renderer'import { createSSRApp } from 'vue'// 一个计数的vue组件function createApp() { // 通过createSSRApp创建一个vue实例 return createSSRApp({ data: () => ({ count: 1 }), template: `<button @click="count++">{{ count }}</button>`, });}const app = createApp();// 通过renderToString将vue实例渲染成字符串renderToString(app).then((html) => { // 将字符串插入到html模板中 const htmlStr = ` <!DOCTYPE html> <html> <head> <title>Vue SSR Example</title> </head> <body> <div id="app">${html}</div> </body> </html> `; console.log(htmlStr);});

将上述代码拷贝进任意.js文件,然后实行node xxx.js,即可看到掌握台打印出渲染好的字符串,如下:

2.2 通过做事器发送html字符串

为了简便,这里利用比较盛行的express作为做事器。
代码很大略,直接看注释就能理解。

import express from 'express'import { renderToString } from 'vue/server-renderer'import { createSSRApp } from 'vue'// 一个计数的vue组件function createApp() { return createSSRApp({ data: () => ({ count: 1 }), template: `<button @click="count++">{{ count }}</button>`, });}// 创建一个express实例const server = express();// 通过express.get方法创建一个路由, 浸染是当浏览器访问'/'时, 对该要求进行处理server.get('/', (req, res) => { // 通过createSSRApp创建一个vue实例 const app = createApp(); // 通过renderToString将vue实例渲染成字符串 renderToString(app).then((html) => { // 将字符串插入到html模板中 const htmlStr = ` <!DOCTYPE html> <html> <head> <title>Vue SSR Example</title> </head> <body> <div id="app">${html}</div> </body> </html> `; // 通过res.send将字符串返回给浏览器 res.send(htmlStr); });})// 监听3000端口server.listen(3000, () => { console.log('ready http://localhost:3000')})

同样在掌握台输入node xxx.js,即可启动做事器,然后在浏览器访问http://localhost:3000/ ,就能访问到页面了。

2.3 激活客户端渲染

如果你访问过上面的地址,就会创造页面上的按钮是点不动的。
这是由于通过renderToString渲染出来的页面是完备静态的,这时候就要进行客户端激活。

激活的方法实在便是实行一遍客户端渲染,在vue里面便是实行app.mount。
我们可以创建一个js,在里面写入客户端激活的代码,然后通过script标签把这个文件插入到html模板中,这样浏览器就会要求这个js文件了。

如下所示,首先写一段客户端激活的代码,放到名为client-entry.js的文件里:

import { createSSRApp } from 'vue'// 通过createSSRApp创建一个vue实例function createApp() { return createSSRApp({ data: () => ({ count: 1 }), template: `<button @click="count++">{{ count }}</button>`, });}createApp().mount('#app');

可以看到,这里的createApp函数和做事器真个counter组件是完备相同的(在实际开拓中,createApp代表的便是你的全体运用),以是客户端激活实际上便是把客户端渲染再实行一遍,唯一差异便是要利用createSSRApp这个api防止重复渲染。

其余,要利用vue激活,我们还须要在客户端下载vue。
由于我们的代码没有经由打包器转换,以是没法在浏览器中直策应用import { createSSRApp } from 'vue'这样的语法。
为了方便,这里借用了Import Map功能,这样就支持import直策应用了。
如果想进一步理解可以自行搜索Import Map关键字。

改造后的如下html模板如下:

const htmlStr = ` <!DOCTYPE html> <html> <head> <title>Vue SSR Example</title> // 利用Import Map <script type="importmap"> { "imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" } } </script> // 将client-entry.js文件路径写入script <script type="module" src="/client-entry.js"></script> </head> <body> <div id="app">${html}</div> </body> </html>`;

这样我们的按钮就可以点击了,而且查看掌握台,要求的html资源也是有内容的,不再是csr那种空缺的html了。

查看完全的代码可以点击这里。

3. 实现脱水(Dehydrate)和注水(Hydrate)

同构运用还有一个比较主要的点,便是如何实现做事器真个数据的预取,并让其随着html一起通报到浏览器端。

例如我们有一个列表页,列表数据是从其他做事器获取的;为了让用户第一韶光就看到页面内容,最好的方法当然是在做事器就拿到数据,然后随着html一起通报给浏览器。
浏览器拿到html和传过来的数据,直接对页面进行初始化,而不须要再在客户端要求这个接口(除非做事器端由于某些缘故原由获取数据失落败)。

为了实现这个功能,全体过程分为两部分:

做事器端获取到数据后,把数据随着html一起传给客户真个过程,一样平常叫做脱水(Dehydrate)客户端拿到html和数据,利用这个数据来初始化组件的过程叫做注水(Hydrate)

注水实在便是前面提到过的客户端激活,差异只是前面的没有数据,而这次我们会试着加上数据。
海内也有翻译成"水合"的,现在你该当知道了,注水、客户端激活、水合还有Hydrate实在都是一码事。

查看完全的代码可以点击这里。

3.1 实现做事器端脱水

要在做事器端直接要求一个接口当然很大略,但是为了保持最基本的前后端分离,我们最好的写法还是将接口要求写在组件中。

为了让做事器获取到我们要要求的接口,我们可以在vue组件中挂载一个自定义函数,然后在做事器端调用这个函数即可(须要把稳的是,做事器环境不能直策应用fetch,该当用axios或者node-fetch替代)。
如下:

// 组件中的代码import { createSSRApp } from 'vue'function createApp() { return createSSRApp({ data: () => ({ count: 1 }), template: `<button @click="count++">{{ count }}</button>`, // 自定义一个名为asyncData的函数 asyncData: async () => { // 在处理远程数据并return出去 const data = await getSomeData() return data; } });}// 做事器真个代码const app = createApp();// 保存初始化数据let initData = null;// 判断是否有我们自定义的asyncData方法,如果有就用该函数初始化数据if (app._component.asyncData) { initData = await app._component.asyncData();}

拿到数据后该如何通报到浏览器呢?实在有一个很大略的方法:我们可以把数据格式化成字符串,然后用如下的办法,直接将这个字符串放到html模板的一个script标签中:

const htmlStr = ` <!DOCTYPE html> <html> <head> ... // 将数据格式化成json字符串,放到script标签中 <script>window.__INITIAL_DATA__ = ${JSON.stringify(initData)}</script> </head> ... </html>`;

当html被传到浏览器真个时候,这个script标签就会被浏览器实行,于是我们的数据就被放到了window.__INITIAL_DATA__里面。
此时客户端就可以从这个工具里面拿到数据了。

3.2实现客户端注水

实现了脱水,注水就很大略了。
我们先判断window.__INITIAL_DATA__是否有值,如果有的话直接将其赋值给页面state;否则就让客户对自己要求一次接口。
代码如下:

function createApp() { return createSSRApp({ data: () => ({ count: 1 }), template: `<button @click="count++">{{ count }}</button>`, // 自定义一个名为asyncData的函数 asyncData: async () => { // 在处理远程数据并return出去 const data = await getSomeData() return data; }, async mounted() { // 如果已经有数据了,直接从window中获取 if (window.__INITIAL_DATA__) { // 有做事端数据时,利用做事端渲染时的数据 this.count = window.__INITIAL_DATA__; window.__INITIAL_DATA__ = undefined; return; } else { // 如果没有数据,就要求数据 this.count = await getSomeData(); } } });}

这样我们就实现了一套完全的注水和脱水流程。

查看完全的代码可以点击这里。

4. 同构渲染要(坑)点

做事器端和浏览器端环境不同,以是我们不能像写csr代码一样写同构代码。
根据我的踩坑经历,写同构运用须要尤其把稳以下几点:

4.1 避免状态单例

做事器端返回给客户真个每个要求都该当是全新的、独立的运用程序实例,因此不应当有单例工具——也便是避免直接将工具或变量创建在全局浸染域,否则它将在所有要求之间共享,在不同要求之间造成状态污染。

在客户端中,vue/pinia/vue-router都因此单例的形式存在,为此可以用函数的形式将vue/pinia/vue-router等进行初始化。
也便是像上面的例子那样,用一个函数进行包裹,然后调用这个函数进行运用的初始化。

4.2 避免访问特定平台api

做事器端是node环境,而客户端是浏览器环境,如果你在node端直策应用了像 window 、 document或者fetch(在node端该当用axios或node-fetch),这种仅浏览器可用的全局变量或api,则会在 Node.js 中实行时抛出错误;反之,在浏览器利用了node真个api也是如此。

须要把稳的是,在vue组件中,做事器端渲染时只会实行beforeCreate和created生命周期,在这两个生命周期之外实行浏览器api是安全的。
以是推举将操作dom或访问window之类的浏览器行为,一并写在onMounted生命周期中,这样就能避免在node端访问到浏览器api。

如果要在这两个生命周期中利用浏览器端api,可以利用干系打包工具供应的变量(如vite供应了import.meta.env.SSR),来避免做事器端调用干系代码。

尤其须要把稳的是,一些组件库可能也会由于编写的时候没有考虑到做事器端渲染的情形,导致渲染出错。
这时候可以借助一些第三方组件,如nuxt中的ClientOnly,可以避免这些出错的组件在做事器端进行渲染。

4.3 避免在做事器端生命周期内实行全局副浸染代码

vue做事器端渲染会实行beforeCreate和created生命周期,该当避免在这两个生命周期里产生全局副浸染的代码。

例如利用setInterval设置定时器。
在纯客户真个代码中,我们可以设置一个定时器,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。
但是,由于在 SSR 期间并不会调用销毁钩子函数,以是 timer 将永久保留下来,终极造成做事器内存溢出。

5. 创建实际生产中的同构运用

上面的例子是一个最根本的同构渲染,但间隔一个能在开拓中实际利用的框架还差得很远。
如果把这些内容都细细讲完,我估摸文章要到三万字了,实在太累,而且也很难让新伎俩式员看得懂。
以是这些难点我只讲解一下关键点,如果有兴趣穷究的可以下来自己研究。

按照我踩坑的经历,至少还要办理下面几个问题:

集成前端工具链,如vite、eslint、ts等集成前端路由,如vue-router集玉成局状态管理库,如pinia处理#app节点之外的元素。
如vue的teleport,react的portal处理预加载资源

顺带一提,vue社区有一篇vue ssr指南也值得一看,虽然只有vue2版本的,但是仍旧有很多值得学习的地方。

4.1 集成前端工具链

这部分内容实在太多太杂,须要对打包工具有比较好的节制才能理解。
好在vite官方已经有了一篇完善的教程,而且供应了完全的代码示例,想深入理解的可以点进去看看。

4.2 集成前端路由

前端路由都供应了干系的api来赞助做事器端进行处理。
如vue-router进行做事器端处理的流程如下:

利用createMemoryHistory创建路由。
在做事器端获取用户要求的路径,将路径传入router.push函数,这样router就会处理该路径对应的页面。
router在处理页面的时候,可能会碰到一些异步代码,以是vue-router供应了router.isReady这个异步函数。
await这个函数后,再渲染全体运用,获取的便是当前用户要求的页面了。
4.3 集玉成局状态管理库

官方文档一样平常就有详细教程,如pinia官网就有教你如何进行做事器端渲染。
实际上全局状态管理库的处理便是脱水和注水,以是这里不做详细阐明了。

4.4 处理#app节点之外的元素

页面内容一样平常会渲染到id为app的节点下,但像vue中的teleport和react的portal独立于app节点外,因此须要单独处理。

这里建议把所有的根节点之外的元素统一设置到一个节点下面,如teleport可以通过设置to属性来指定挂载的节点;同时vue也供应了方法来获取所有的teleport。
拿到teleport的信息后,即可通过字符串拼接的办法,将它们一并放到html模板中的目标节点下面了。

4.5 处理预加载资源

利用打包器可以天生manifest,它的浸染是将打包后的模块 ID 与它们关联的 chunk 和资源文件进行映射(大略理解便是通过它你可以知道js、图片等页面资源的位置在哪儿)。
依赖这个manifest获取资源的路径,然后创建link标签拼接到html模板中即可。

详情可查看这里。

5. 做事器端优化

虽然我们写好了做事真个代码,但是这样的代码是十分薄弱的,无论性能还是可靠性都没有保障,是没法在实际生产中运用的。
为此我们须要对做事端代码进行一系列优化。

点击这里查看完全代码。

5.1 做事器端测试压力测试

为了衡量做事器优化的指标,我们可以借助一系列测试工具,apach bench、jmeter等。
我利用的是apach bench,它可以仿照一系列并发要求,用来对做事器进行压力测试。

apach bench可以通过实行abs -n <要求总数> -c <并发数> <测试路径>来进行测试。
例:abs -n 1000 -c 100 http://localhost:3000/,表示以100并发的形式发送1000个要求到localhost:3000。

由于我们的做事本身比较大略,以是这里我以1000并发的形式发送了10000个要求,结果如下:

可以看到Time taken for tests这一栏,统共花了6.6秒旁边。

Node调试工具

除此之外,我们还可以用Chrome浏览器的"开拓者工具"作为node做事器的调试工具。
利用node调试工具不仅能方便地进行调试,还可以清楚地看到诸如内存利用情形等指标,对代码进行更精确地优化。

关于node调试工具的利用可以参考这篇文章。

5.2 多进程优化

node内置了cluster模块,可以快速方便地创建子进程。
如下:

通过os模块判断当前的cpu总数,然后通过cluster.isMaster判断当前是否是主进程,末了通过cluster.fork即可创建一个子进程。

在主进程里,我们进行一些创建、掩护子进程的事情,而在子进程里我们则运行真正的node做事。
如下图所示,我们启动多线程再进行测试:

可以看到速率提升到了3.7秒,明显快了很多。

5.3 内存溢出处理

通过process.memoryUsage();可以判断当前子进程用掉的内存,当占用内存大于某个数(如300M)的时候,我们便将这个子进程关掉,防止内存透露。

5.4 处理未捕获非常

在子进程中,通过process.on('uncaughtException', err => {})可以获取到该进程中的未捕获非常(如做事器端渲染时候发生的一些缺点)。
当捕获到缺点后,我们可以对缺点进行上报或写入日志。

也可以借助一些第三方监控平台如sentry来处理这类问题。
sentry在node真个支配方法可以参考这里。

5.5 心跳包检测

所谓心跳包检测,便是主进程每隔一段韶光向子进程发送一个信息,子进程收到这个信息后,立即回应给主进程一个信息;如果主进程在某次信息发送后,子进程没有回应,解释子进程卡去世了。
这时候就须要杀去世这个子进程然后重新创建一个。

以是心跳包检测的浸染紧张是为了防止子进程卡去世。

详细步骤如下:

主进程通过woker.send方法可以向子进程发送信息(woker为cluster创建的子进程引用)子进程通过process.on('message', () => {})订阅主进程发送的信息,并在收到信息后通过process.send方法返回给主进程信息主进程通过woker.on('message', () => {})订阅子进程发送的信息。
如果累计一定次数没有收到子进程返回的信息,则关闭子进程。

主进程代码如下:

子进程代码如下:

5.6 子进程自动重修

在上面的代码里,如果子进程由于某种缺点(如内存溢出)而被关闭的时候,我们须要重新创建一个子进程,这样就能担保线上做事能够永劫光运行了。
通过如下代码即可监听子进程关闭并重新创建子进程。

点击这里查看完全代码。

完结

文章到这里就结束了,如果有须要补充或者缺点的地方,欢迎在评论区指出。

作者:monet链接:https://juejin.cn/post/7289661061984501819