手撕一遍服务端渲染(ssr),彻底搞清单页面服务端渲染的原理

1,065 阅读7分钟

背景

关于服务端渲染的文章网上一大堆,也不乏很多好文章,我为什么还要写这一片关于服务端渲染的文章呢,主要出于要讲明白其中一些小细点的问题,如果不明白这些小细点,有可能会让一些刚入门的同学搞的晕头转向,我开始的时候就卡在了这些小点上,明白了这些小细点,有可能只是其中一个小点,就有可能让很多同学对服务端渲染灵魂开窍

好 开始前 咱们先明确一个事情:咱们现在前端的项目基本都是通过vue、或者react写的单页面应用,这个时候你通过查看网页源码你会发现大概是下面这个样子

通过上面这个图片的内容,你能知道这个网页大概是什么内容吗?反正我不知道,网络蜘蛛更不知道了(装逼点说就是对seo不友好),所以,针对一些强依赖搜索引擎作为流量来源的网络单页面应用,虽然在体验上好了不少,但是你会发现搜索引擎根本搜不到你的网页,惊不惊喜!

image.png

所以,如何在保留单页面的用户体验前提下,怎么解决seo的问题,就是本文所要讲的主题:单页面应用的服务端渲染


小细点:细心的同学看到了,咱们要讲的是单页面应用的服务端渲染,并不是简单的服务端渲染

目标

基于上面的问题,咱们总结一下单页面应用的服务端渲染的目标:

  1. 查看任何一个路由的网页源码,里面都包含该网页的内容
  2. 在用户使用的过程中,依然保留单页面应用的属性和体验

第一个目标实现

好,咱们先实现上面的第一个目标:查看任何一个路由的网页源码,里面都包含该网页的内容(本文拿vue来举例实验)

既然要在服务端渲染vue应用,那首先得有个node服务端吧,咱们直接拿express来快速实现一下服务端

小细点:有些同学会有这样的疑问:服务端渲染为什么需要一个node服务端呢?其他服务器行吗,比如tomcat、apache等。咱们的目的是将vue写的程序在服务端渲染成html后发送给客户端,所以,咱们的服务器必须得能运行vue才行吧,也就是说咱们的服务器必须要能执行js,能执行js,并且能作为服务端,是不是立马就想到了node呢!

第一步:新建一个服务端

咱们新建一个server.js,写下如下代码

执行并访问,服务端有了

第二步:渲染vue组件

有了服务端,接下来要做的就是如何将vue程序在服务端渲染成html,vue官方提供了vue-server-renderer专门做这个事情, 咱们修改成如下代码

刷新浏览器,依然可以看到hello world,但是此hello world非彼hello world,这个时候的hello world已经是服务端渲染好发送给客户端的了

为了更好的表述咱们目标1里面提到的(查看任何一个路由的网页源码,里面都包含该网页的内容),咱们加上路由功能

第三步:引入vue-router,实现路由功能

首先新建router.js 引入路由

根据路由,新建三个对应的组件,每个组件写入一些对应的内容

小细点:路由的mode为什么是history,而不是hash,下面会讲到

新建App.vue 作为路由容器,内容如下

路由文件准备好之后,接下来要做的就是根据用户的请求地址,找到对应的vue 组件,并按照最开始方式,渲染成html返回给客户端

首先咱们新建一个main.js,把路由放到vue实例里面,如下,导出一个创建vue实例的方法

新建一个serverEntry.js,作为服务端的bundle 入口文件,其中context参数就是网路请求的上下文,里面包含请求的地址,这个context参数会在server.js 调用的时候传入,通过$router.getMatchedComponents可以获取到匹配的组件,如下:

将上面的文件作为入口,通过webpack 打包成bundle.server.js

注释:因为咱们要通过请求的url找到对应要渲染的组件,所以咱们要使用history模式,因为hash模式对应的#后面的地址不会发送到服务端

修改server.js 如下

重新启动服务,这个时候就实现了咱们的第一个目标:不管访问/helloword . 还是/hellovue 还是/helloreact,通过查看网页源码,确实可以看到对应的内容

至此,第一个目标已经实现,我们实现了服务端的渲染,但是,此时通过点击链接,会重新发送请求,刷新整个页面,这一定不是咱们想要的,所以咱们开始实现第二个目标:依然保留单页面应用的属性和体验,也就是单页面应用的服务端渲染

第二个目标实现

为了保留单页面应用的属性和体验,咱们只需要让vue依然托管咱们的应用,就像咱们写普通单页面应用一样就可以了

第一步:新建客户端入口

咱们新建一个clientEntry.js作为客户端的bundle入口文件,如下:

然后配置webpack,打包生成bundle.client.js

小细点:因为服务端和客户端的目标不一样,这就是为什么需要clientEntry 和serverEntry两个入口文件的原因

第二步:让vue托管客户端

bundle.client.js生成好之后,接下来咱们只需要把这个文件放到页面script标签里面 直接返回就可以了,就像咱们单页面应用里面的那个index.html,里面插入打包后的js文件,所以咱们简单修改一下server.js.让他返回的页面里面插入bundle.client.js

这个时候再点击咱们的链接,发现页面已经不会再重新发送请求刷新页面了,从而也就实现了咱们的第二个目标

至此,单页面的服务端渲染已经基本上完成了

思考点:

  1. 为什么要先将serverEntry.js 打包成bundle.server.js 才能在server.js 里面使用,为什么不能直接按照require的方式引入入口源文件,有答案的可以留在评论区

  2. 如何实现类似nuxt那种配置页面title和description的功能?(context)

  3. 针对上面服务端返回的内容,是不是可以放到一个模板文件中,引入模板文件是不是更好?(js渲染引擎)

综上:咱们已经基本实现了单页面服务端渲染的过程,但是还缺少一个很重要的部分,那就是动态数据的获取(网络接口数据)

第三步:动态接口数据的支持

首先来看下服务端的入口文件

咱们通过路由匹配到相应组件之后,然后直接开始调用了renderer.renderToString,所以,如果想渲染动态数据,我们就得在调用渲染之前,也就是resolve(app)之前,先获取到数据,所以,咱们可以在组件内部添加一个自定义方法,该方法就是专门动态获取数据的,咱们起名字叫beforeServerRender,他返回一个promise,另外,咱们还需要考虑一个问题,就是如何把动态获取的数据挂在组件里面使用,这个时候咱们可以借助vue-store来实现(下面讲原因)。修改serverEntry.js如下

接着咱们在server.js 添加一个测试接口

然后修改一下helloworld.vue

小细点:vue的生命周期是:首先创建一个vue实例对象,初始化一些默认的声明周期函数和默认的事件,进入 beforeCreate(),这时候,data和methods中的数据都没初始化,然后开始初始化数据,进入created(),data和methods中的数据都被初始化好了。也就是说,只有在created之后,才会取到data里面的数据,咱们写的beforeServerRender函数是处于这样一个阶段:vue实例->beforeServerRender->beforeCreate->created,其中,咱们经常使用的this.store是在beforeCreate阶段才有,所以,咱们需要把app.store 是在beforeCreate阶段才有,所以,咱们需要把app.store通过参数的形式传到beforeServerRender函数里,因为这个时候this.store还取不到,同样的,this.data里面的数据也取不到,这也就咱们不直接在beforeServerRender修改userInfo,而是通过store还取不到,同样的,this.data里面的数据也取不到,这也就咱们不直接在beforeServerRender修改userInfo,而是通过store和computed计算属性来实现的原因

修改store.js如下:

这个时候,刷新页面,会出现如下问题:源代码里面有数据,但是页面却没有显示

具体原因是因为:服务端的vue实例和客户端的vue实例不是一个导致的,服务端因为执行了beforeServerRender函数,而该函数给服务端的store有赋值,但是客户端的store 有赋值,但是客户端的store 里面userInfo是空的,所以也就导致了上面的问题。怎么解决呢?既然问题是$store 不一致导致的,咱们改成一致不就好了

第四步:同步服务端$store给客户端

怎么同步呢?又想到了context参数,咱们把服务端的store.state挂载到context上,渲染模板的时候,把这个对象赋值给一个全局对象window.INITIAL_STATE,在客户端再读取这个值不就可以复原了吗。好,咱们按步骤修改一下文件

修改serverEntry.js如下

修改server.js

修改clientEntry.js

这个时候,重新生成bundle.server.js . 和 bundle.client.js,重启服务,刷新浏览器

可以看到,已经达到了目标。至此,咱们已经完成了最初要实现的单页面服务端渲染的两个目标

代码全局目录

有需要下载代码看的,可以直接点击下载

补充问题

  1. 为什么服务端每次请求都新建一个实例?

    服务端服务启动后,会一直运行着,如果使用一个实例,一直保留在内存中,会造成数据相互污染的问题。比如上面store里面的userInfo,如果同时有很多用户访问同一个用户信息页面,很容易出现问题

  1. 服务端渲染和预渲染的区别是什么?

    拿vue 服务端渲染官网的介绍:如果你调研服务器端渲染 (SSR) 只是用来改善少数营销页面(例如 /, /about, /contact 等)的 SEO,那么你可能需要预渲染。无需使用 web 服务器实时动态编译 HTML,而是使用预渲染方式,在构建时 (build time) 简单地生成针对特定路由的静态 HTML 文件。总结:预渲染是提前构建好页面,服务端渲染是动态实时渲染,如果页面内容基本不怎么改动,可以使用预渲染,如果经常改动或者需要动态取读取数据库内容,可以使用服务端渲染

  1. 服务端渲染的优缺点是什么?

    优点:

    1. 除了上面说的利于seo以外,服务端渲染可以加快首屏内容显示

    缺点:

    1. 渲染放到服务端,带来的直接问题就是服务端压力变大
    2. 开发条件所限,只能在某些生命周期钩子函数中使用
    3. 部署条件苛刻,需要处于 Node.js server 运行环境
  1. 服务端渲染为什么首屏显示更快

    如果上面的内容真的理解了,我相信聪明的你应该明白为什么了

  1. 我遇到的一个问题:下面是一个简单的服务,服务提供了一个接口,2秒后请求这个接口并打印结果

令人意外的是:结果是报错,提示超时,访问异常

我用浏览器访问了一下:正常

最后在文档上找到了答案:

大概意思就是如果不写host,会随机用一个未使用的内网ip地址,这也就解释了为什么会超时,以及上面的异常信息里面为什么是10.33.42.50

所以,修改如下就好了

结束语

综上就是我对单页面服务端渲染的理解,我能自圆其说,但并不代表一定是对的,请各位同学自行根据自己的知识辨别,同时,如有错误,感谢大佬们批评指正,不胜感激!