
引文
最近公司项目中使用了 Nuxt 框架,进行首屏的服务端渲染,加快了内容的到达时间 (time-to-content),于是笔者开始了对 Nuxt 的学习和使用。以下是从源码角度对 Nuxt 的一些特性的介绍和分析。
FEATURES
服务端渲染(SSR)
Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。 ------Vue SSR 指南
官方Vue SSR指南的基本用法章节,给出了 demo 级别的服务端渲染实现,Nuxt 也是基于该章节实现的,大体流程几乎一致。建议先食用官方指南,再看本文定大有裨益。
Nuxt 作为一个服务端渲染框架,了解其服务端渲染的实现原理必然是重中之重,就让我们通过相关源码,看看其具体实现吧!
我们通过 nuxt 启动 Nuxt 项目,其首先会执行 startDev 方法,然后调用_listenDev 方法,获取 Nuxt 配置,调用getNuxt方法实例化 Nuxt。然后执行 nuxt.ready() 方法,生成渲染器。
- //@nuxt/server/src/server.js
- asyncready(){
- //Initializevue-renderer
- this.serverContext=newServerContext(this)
- this.renderer=newVueRenderer(this.serverContext)
- awaitthis.renderer.ready()
- //Setupnuxtmiddleware
- awaitthis.setupMiddleware()
- returnthis
- }
在 ready 中会执行 this.setupMiddleware() ,其中会调用nuxtMiddleware 中间件(这里是响应的关键)。
- //@nuxt/server/src/middleware/nuxt.js
- exportdefault({options,nuxt,renderRoute,resources})=>asyncfunctionnuxtMiddleware(req,res,next){
- constcontext=getContext(req,res)
- try{
- consturl=normalizeURL(req.url)
- res.statusCode=200
- constresult=awaitrenderRoute(url,context)//渲染相应路由,后文会展开
- const{
- html,
- redirected,
- preloadFiles
- }=result//得到html
- //设置头部字段
- res.setHeader('Content-Type','text/html;charset=utf-8')
- res.setHeader('Accept-Ranges','none')
- res.setHeader('Content-Length',Buffer.byteLength(html))
- res.end(html,'utf8')//做出响应
- returnhtml
- }catch(err){
- if(context&&context.redirected){
- consola.error(err)
- returnerr
- }
- next(err)
- }
- }
nuxtMiddleware 中间件中首先标准化请求的url,设置请求状态码,通过url匹配到相应的路由,渲染出对应的路由组件,设置头部信息,最后做出响应。
- renderSSR(renderContext){
- //CallrenderToStringfromthebundleRendererandgeneratetheHTML(willupdatetherenderContextaswell)
- //renderSSR只是universalapp的渲染方法,Nuxt也可以进行开发普通的SPA项目
- constrenderer=renderContext.modern?this.renderer.modern:this.renderer.SSR
- returnrenderer.render(renderContext)
- }
其中 renderRoute 方法会调用 @nuxt/vue-render 的renderSSR 进行服务端渲染操作。
- //@nuxt/vue-renderer/src/renderers/SSR.js
- asyncrender(renderContext){
- //CallVuerendererrenderToString
- letAPP=awaitthis.vueRenderer.renderToString(renderContext)
- letHEAD=''
- //...此处省略n行HEAD拼接代码,后续HEAD管理部分会提及
- //RenderwithSSRtemplate
- consthtml=this.renderTemplate(this.serverContext.resources.SSRTemplate,templateParams)
- return{
- html,
- preloadFiles
- }
- }
而 renderSSR 又会调用 renderer.render 方法,将 url 匹配的路由渲染成字符串,将字符串与模版相结合,得到最终返回给浏览器的html,至此 Nuxt 服务端渲染完成。
最后贴一张盗来的 Nuxt 执行流程图,图画的很棒,流程也很清晰,感谢。

数据拉取(Data Fetching)
在客户端程序(CSR)可以通过在 mounted 钩子中获取数据,但在通用程序(Universal)中则需要使用特定的钩子才能在服务端获取数据。
Nuxt 中主要提供了两种钩子获取数据
-
asyncData
- 只可以在页面级组件中获取,不可以访问 this
- 通过返回对象保存数据状态或与Vuex配合进行状态保存
-
fetch
- 所有组件中都可以获取,可以访问 this
- 无需传入 context,传入 context 会 fallback 到老版的 fetch,功能类似于 asyncData
- //.nuxt/server.js
- //ComponentsarealreadyresolvedbysetContext->getRouteData(app/utils.js)
- constComponents=getMatchedComponents(app.context.route)
- //在匹配的路由中,调用asyncData和legacy版本的fetch方法
- constasyncDatas=awaitPromise.all(Components.map((Component)=>{
- constpromises=[]
- //调用asyncData(context)
- if(Component.options.asyncData&&typeofComponent.options.asyncData==='function'){
- constpromise=promisify(Component.options.asyncData,app.context)
- promise.then((asyncDataResult)=>{
- SSRContext.asyncData[Component.cid]=asyncDataResult
- applyAsyncData(Component)
- returnasyncDataResult
- })
- promises.push(promise)
- }else{
- promises.push(null)
- }
- //调用legacy版本的fetch(context)兼容老版本的fetch
- if(Component.options.fetch&&Component.options.fetch.length){
- promises.push(Component.options.fetch(app.context))
- }else{
- promises.push(null)
- }
- returnPromise.all(promises)
- }))
在生成的 .nuxt/server.js 中,会遍历匹配的组件,查看组件中是否定义了 asyncData 选项以及 legacy 版 fetch ,存在就依次调用,获得 asyncDatas。
- //.nuxt/mixins/fetch.server.js
- //nuxtv2.14及之后
- asyncfunctionserverPrefetch(){
- //Callandawaiton$fetch
- //v2.14之后推荐的fetch
- try{
- awaitthis.$options.fetch.call(this)
- }catch(err){
- if(process.dev){
- console.error('Errorinfetch():',err)
- }
- }
- this.$fetchState.pending=false//设置fetchState为false
- }
在服务端实例化 vue 实例之后,执行 serverPrefetch,触发 fetch 选项方法,获取数据,数据会作用于生成 html的过程。
HEAD 管理(Meta Tags and SEO)
截至目前,Google 和 Bing 可以很好对同步 JavaScript 应用程序进行索引。但是对于异步获取数据的网站来说,主流的搜索引擎暂时还无法支持,于是造成网站搜索排名靠后,于是希望获得更好的SEO成为众多网站考虑使用SSR框架的原因。
为了获得良好的SEO,那么就需要对HEAD进行精细化的配置和管理。让我们看看其是如何实现的吧~
Nuxt框架借助 vue-meta 库实现全局、单个页面的 meta 标签的自定义。Nuxt 内部的实现也几乎遵循 vue-meta 官方的 SSR meta 管理的流程。具体详情请查看。
- //@nuxt/vue-app/template/index.js
- //step1
- Vue.use(Meta,JSON.stringify(vueMetaOptions))
- //@nuxt/vue-app/template/template.js
- //step2
- exportdefaultasync(SSRContext)=>{
- const_app=newVue(app)
- //Addmetainfos(usedinrenderer.js)
- SSRContext.meta=_app.$meta()
- return_app
- }
首先通过Vue插件的形式,注册vue-meta,内部会在Vue的原型上挂载$meta属性。然后将meta添加到服务端渲染上下文中。
- asyncrender(renderContext){
- //CallVuerendererrenderToString
- letAPP=awaitthis.vueRenderer.renderToString(renderContext)
- //step3
- letHEAD=''
- //Injectheadmeta
- //(thisisunsetwhenfeatures.metaisfalseinservertemplate)
- //以下就是上文省略的n行HEAD拼接代码,可以适当忽略
- //了解主要过程即可,具体细节按需查看
- constmeta=renderContext.meta&&renderContext.meta.inject({
- isSSR:renderContext.nuxt.serverRendered,
- ln:this.options.dev
- })
- if(meta){
- HEAD+=meta.title.text()+meta.meta.text()
- }
- if(meta){
- HEAD+=meta.link.text()+
- meta.style.text()+
- meta.script.text()+
- meta.noscript.text()
- }
- //Checkifweneedtoinjectscriptsandstate
- constshouldInjectScripts=this.options.render.injectScripts!==false
- //Injectresourcehints
- if(this.options.render.resourceHints&&shouldInjectScripts){
- HEAD+=this.renderResourceHints(renderContext)
- }
- //Injectstyles
- HEAD+=this.renderStyles(renderContext)
- //Prependscripts
- if(shouldInjectScripts){
- APP+=this.renderScripts(renderContext)
- }
- if(meta){
- constappendInjectorOptions={body:true}
- //Appendbodyscripts
- APP+=meta.meta.text(appendInjectorOptions)
- APP+=meta.link.text(appendInjectorOptions)
- APP+=meta.style.text(appendInjectorOptions)
- APP+=meta.script.text(appendInjectorOptions)
- APP+=meta.noscript.text(appendInjectorOptions)
- }
- //Templateparams
- consttemplateParams={
- HTML_ATTRS:meta?meta.htmlAttrs.text(renderContext.nuxt.serverRendered/*addSrrAttribute*/):'',
- HEAD_ATTRS:meta?meta.headAttrs.text():'',
- BODY_ATTRS:meta?meta.bodyAttrs.text():'',
- HEAD,
- APP,
- ENV:this.options.env
- }
- //RenderwithSSRtemplate
- //通过模版和参数生成html
- consthtml=this.renderTemplate(this.serverContext.resources.SSRTemplate,templateParams)
- letpreloadFiles
- if(this.options.render.http2.push){
- //获取需要预加载的文件
- preloadFiles=this.getPreloadFiles(renderContext)
- }
- return{
- html,
- preloadFiles,
- }
- }
最后在响应的 html 中注入 metadata 即可。
文件系统路由(File System Routing)
想必使用过 Nuxt 的同学应该都对其基于文件生成路由的特性,印象深刻。让我从源码角度看看 Nuxt 是如何实现基于 pages 目录(可配置),自动生成路由的。
首先在启动 Nuxt 项目或者修改文件时,会自动调 generateRoutesAndFiles 方法,生成路由 以及 .nuxt 目录下的文件。
- //@nuxt/builder/src/builder.js
- asyncgenerateRoutesAndFiles(){
- ...
- awaitPromise.all([
- this.resolveLayouts(templateContext),
- this.resolveRoutes(templateContext),//解析生成路由,需要关注的重点
- this.resolveStore(templateContext),
- this.resolveMiddleware(templateContext)
- ])
- ...
- }
解析路由会存在三种情况:一是修改了默认的 pages 目录名称,且未在 nuxt.config.js 中配置相关目录,二是使用 nuxt 默认的 pages 目录,三是使用调用用户自定义的路由生成方法生成路由。
- //@nuxt/builder/src/builder.js
- asyncresolveRoutes({templateVars}){
- consola.debug('Generatingroutes...')
- if(this._defaultPage){
- //在srcDir下未找到pages目录
- }elseif(this._nuxtPage){
- //使用nuxt动态生成路由
- }else{
- //用户提供了自定义方法去生成路由,提供用户自定义路由的能力
- }
- //router.extendRoutesmethod
- if(typeofthis.options.router.extendRoutes==='function'){
- constextendedRoutes=awaitthis.options.router.extendRoutes(
- templateVars.router.routes,
- resolve
- )
- if(extendedRoutes!==undefined){
- templateVars.router.routes=extendedRoutes
- }
- }
- }
除此之外,还可以提供相应的 extendRoutes 方法,在 nuxt 生成路由的基础上添加自定义路由。
- exportdefault{
- router:{
- extendRoutes(routes,resolve){
- //例如添加404页面
- routes.push({
- name:'custom',
- path:'*',
- component:resolve(__dirname,'pages/404.vue')
- })
- }
- }
- }
其中当修改了默认的 pages 目录,导致找不到相关的目录,会使用 @nuxt/vue-app/template/pages/index.vue 文件生成路由。
- asyncresolveRoutes({templateVars}){
- if(this._defaultPage){
- //createRoutes方法根据传参,生成路由。具体算法,不再展开
- templateVars.router.routes=createRoutes({
- files:['index.vue'],
- srcDir:this.template.dir+'/pages',//指向@nuxt/vue-app/template/pages/index.vue
- routeNameSplitter,//路由名称分隔符,默认`-`
- trailingSlash//尾斜杠/
- })
- }elseif(this._nuxtPage){
- constfiles={}
- constext=newRegExp(`\\.(${this.supportedExtensions.join('|')})$`)
- for(constpageofawaitthis.resolveFiles(this.options.dir.pages)){
- constkey=page.replace(ext,'')
- //.vuefiletakesprecedenceoverotherextensions
- if(/\.vue$/.test(page)||!files[key]){
- files[key]=page.replace(/(['"])/g,'\\$1')
- }
- }
- templateVars.router.routes=createRoutes({
- files:Object.values(files),
- srcDir:this.options.srcDir,
- pagesDir:this.options.dir.pages,
- routeNameSplitter,
- supportedExtensions:this.supportedExtensions,
- trailingSlash
- })
- }else{
- templateVars.router.routes=awaitthis.options.build.createRoutes(this.options.srcDir)
- }
- //router.extendRoutesmethod
- if(typeofthis.options.router.extendRoutes==='function'){
- constextendedRoutes=awaitthis.options.router.extendRoutes(
- templateVars.router.routes,
- resolve
- )
- if(extendedRoutes!==undefined){
- templateVars.router.routes=extendedRoutes
- }
- }
- }
然后就是调用 createRoutes 方法,生成路由。生成的路由大致长这样,和手动书写的路由文件几乎一致(后续还会进行打包??,懒加载引入路由组件)。
- [
- {
- name:'index',
- path:'/',
- chunkName:'pages/index',
- component:'Users/username/projectName/pages/index.vue'
- },
- {
- name:'about',
- path:'/about',
- chunkName:'pages/about/index',
- component:'Users/username/projectName/pages/about/index.vue'
- }
- ]
智能预取(Smart Prefetching)
从 Nuxt v2.4.0 开始,当 出现在可视区域后,Nuxt将会预取经过code-splitted的 page 页面的脚本,使得在用户点击之前,该路由指向的地址,就处于 ready 状态,这将极大的提升用户的体验。
相关实现逻辑集中于 .nuxt/components/nuxt-link.client.js 中。
首先 Smart Prefetching 特性的实现依赖于window.IntersectionObserver 这个实验性的 API,如果浏览器不支持该 API,就不会进行组件预取操作。
- mounted(){
- if(this.prefetch&&!this.noPrefetch){
- this.handleId=requestIdleCallback(this.observe,{timeout:2e3})
- }
- }
然后在需要预取的 组件挂载阶段,会调用 requestIdleCallback 方法在浏览器的空闲时段内调用 observe 方法。
- constobserver=window.IntersectionObserver&&newwindow.IntersectionObserver((entries)=>{
- entries.forEach(({intersectionRatio,target:link})=>{
- //如果intersectionRatio小于等于0,表示目标不在viewport内
- if(intersectionRatio<=0||!link.__prefetch){
- return
- }
- //进行预取数据(其实就是加载组件)
- link.__prefetch()
- })
- })
当被监听的元素的可视情况发生改变的时候(且出现在视图内时),会触发 new window.IntersectionObserver(callback) 的回调,执行真正的预取操作prefetchLink。
- prefetchLink(){
- //判断网络环境,离线或者2G环境下,不进行预取操作
- if(!this.canPrefetch()){
- return
- }
- //停止监听该元素,提高性能
- observer.unobserve(this.$el)
- constComponents=this.getPrefetchComponents()
- for(constComponentofComponents){
- //及时加载组件,使得用户点击时,该组件是一个就绪的状态
- constcomponentOrPromise=Component()
- if(componentOrPromiseinstanceofPromise){
- componentOrPromise.catch(()=>{})
- Component.__prefetched=true//已经预取的标志位
- }
- }
总结
上文从源码角度介绍了 Nuxt 服务端渲染的实现、服务端数据的获取以及 Nuxt 开箱即用的几个特性:HEAD 管理、基于文件系统的路由和智能预取 code-splitted 的路由。如果希望对 SSR 进行更深入研究,还可以横向学习 React 的 SSR 实现 Next 框架。
希望对您有所帮助,如有纰漏,望请辅正。
参考
为什么使用服务器端渲染 (SSR)?
Nuxt源码精读
Vue Meta
Introducing Smart prefetching
服务端渲染
原文链接:https://mp.weixin.qq.com/s/fvv12ZPxiEpCzER3Q-X5pQ








发表评论
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。