相关文章推荐
道上混的稀饭  ·  C# ...·  1 年前    · 
行走的剪刀  ·  Dart| ...·  1 年前    · 
不羁的勺子  ·  Azure Functions ...·  1 年前    · 

聊聊 Nuxt 开箱即用的特性

最近公司项目中使用了 Nuxt 框架,进行首屏的服务端渲染,加快了内容的到达时间 (time-to-content),于是笔者开始了对 Nuxt 的学习和使用。以下是从源码角度对 Nuxt 的一些特性的介绍和分析。

[[420713]]

最近公司项目中使用了 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() 方法,生成渲染器。

  1. // @nuxt/server/src/server.js 
  2. async ready () { 
  3.  // Initialize vue-renderer 
  4.  this.serverContext = new ServerContext(this) 
  5.  this.renderer = new VueRenderer(this.serverContext) 
  6.  await this.renderer.ready() 
  7.  
  8.  // Setup nuxt middleware 
  9.  await this.setupMiddleware() 
  10.  
  11.  return this 

在 ready 中会执行 this.setupMiddleware() ,其中会调用nuxtMiddleware 中间件(这里是响应的关键)。

  1. // @nuxt/server/src/middleware/nuxt.js 
  2. export default ({ options, nuxt, renderRoute, resources }) => async function nuxtMiddleware (req, res, next) { 
  3.  const context = getContext(req, res) 
  4.  try { 
  5.    const url = normalizeURL(req.url) 
  6.    res.statusCode = 200 
  7.    const result = await renderRoute(url, context) // 渲染相应路由,后文会展开 
  8.     
  9.    const { 
  10.      html, 
  11.      redirected, 
  12.      preloadFiles 
  13.   } = result // 得到html 
  14.  
  15.    // 设置头部字段 
  16.    res.setHeader('Content-Type''text/html; charset=utf-8'
  17.    res.setHeader('Accept-Ranges''none'
  18.    res.setHeader('Content-Length', Buffer.byteLength(html)) 
  19.    res.end(html, 'utf8') // 做出响应 
  20.    return html 
  21. } catch (err) { 
  22.    if (context && context.redirected) { 
  23.      consola.error(err) 
  24.      return err 
  25.   } 
  26.    next(err) 

nuxtMiddleware 中间件中首先标准化请求的url,设置请求状态码,通过url匹配到相应的路由,渲染出对应的路由组件,设置头部信息,最后做出响应。

  1. renderSSR (renderContext) { 
  2.  // Call renderToString from the bundleRenderer and generate the HTML (will update the renderContext as well) 
  3.  // renderSSR 只是 universal app的渲染方法,Nuxt 也可以进行开发普通的 SPA 项目 
  4.  const renderer = renderContext.modern ? this.renderer.modern : this.renderer.SSR 
  5.  return renderer.render(renderContext) 

其中 renderRoute 方法会调用 @nuxt/vue-render 的renderSSR 进行服务端渲染操作。

  1. // @nuxt/vue-renderer/src/renderers/SSR.js 
  2. async render (renderContext) { 
  3.  // Call Vue renderer renderToString 
  4.  let APP = await this.vueRenderer.renderToString(renderContext) 
  5.  
  6.  let HEAD = '' 
  7.  // ... 此处省略n行HEAD拼接代码,后续 HEAD 管理部分会提及 
  8.     
  9.  // Render with SSR template 
  10.  const html = this.renderTemplate(this.serverContext.resources.SSRTemplate, templateParams) 
  11.  
  12.  return { 
  13.    html, 
  14.    preloadFiles 

而 renderSSR 又会调用 renderer.render 方法,将 url 匹配的路由渲染成字符串,将字符串与模版相结合,得到最终返回给浏览器的html,至此 Nuxt 服务端渲染完成。

最后贴一张盗来的 Nuxt 执行流程图,图画的很棒,流程也很清晰,感谢。

数据拉取(Data Fetching)

在客户端程序(CSR)可以通过在 mounted 钩子中获取数据,但在通用程序(Universal)中则需要使用特定的钩子才能在服务端获取数据。

Nuxt 中主要提供了两种钩子获取数据

  • asyncData
  • 只可以在页面级组件中获取,不可以访问 this
  • 通过返回对象保存数据状态或与Vuex配合进行状态保存
  • fetch
  • 所有组件中都可以获取,可以访问 this
  • 无需传入 context,传入 context 会 fallback 到老版的 fetch,功能类似于 asyncData
    1. // .nuxt/server.js 
    2. // Components are already resolved by setContext -> getRouteData (app/utils.js) 
    3. const Components = getMatchedComponents(app.context.route) 
    4.   
    5. // 在匹配的路由中,调用 asyncData 和 legacy 版本的 fetch方法 
    6. const asyncDatas = await Promise.all(Components.map((Component) => { 
    7.  const promises = [] 
    8.  
    9.  // 调用 asyncData(context) 
    10.  if (Component.options.asyncData && typeof Component.options.asyncData === 'function') { 
    11.    const promise = promisify(Component.options.asyncData, app.context) 
    12.    promise.then((asyncDataResult) => { 
    13.      SSRContext.asyncData[Component.cid] = asyncDataResult 
    14.      applyAsyncData(Component) 
    15.      return asyncDataResult 
    16.   }) 
    17.    promises.push(promise) 
    18.   } else { 
    19.      promises.push(null
    20.   } 
    21.  
    22.    // 调用 legacy 版本的fetch(context) 兼容老版本的 fetch 
    23.    if (Component.options.fetch && Component.options.fetch.length) { 
    24.      promises.push(Component.options.fetch(app.context)) 
    25.   } else { 
    26.    promises.push(null
    27.  
    28.  return Promise.all(promises) 
    29. })) 

    在生成的 .nuxt/server.js 中,会遍历匹配的组件,查看组件中是否定义了 asyncData 选项以及 legacy 版 fetch ,存在就依次调用,获得 asyncDatas。

    1. // .nuxt/mixins/fetch.server.js  
    2. // nuxt v2.14及之后 
    3. async function serverPrefetch() { 
    4.  // Call and await on $fetch 
    5.  // v2.14 之后推荐的 fetch 
    6.  try { 
    7.    await this.$options.fetch.call(this) 
    8. } catch (err) { 
    9.    if (process.dev) { 
    10.      console.error('Error in fetch():', err) 
    11.   }  
    12.  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 管理的流程。具体详情请查看。

    1. // @nuxt/vue-app/template/index.js 
    2. // step1 
    3. Vue.use(Meta, JSON.stringify(vueMetaOptions)) 
    4.  
    5. // @nuxt/vue-app/template/template.js 
    6. // step2 
    7. export default async (SSRContext) => { 
    8.  const _app = new Vue(app) 
    9.  // Add meta infos (used in renderer.js) 
    10.  SSRContext.meta = _app.$meta() 
    11.  return _app 

    首先通过Vue插件的形式,注册vue-meta,内部会在Vue的原型上挂载$meta属性。然后将meta添加到服务端渲染上下文中。

    1. async render (renderContext) { 
    2.    // Call Vue renderer renderToString 
    3.    let APP = await this.vueRenderer.renderToString(renderContext) 
    4.    // step3 
    5.    let HEAD = '' 
    6.  
    7.    // Inject head meta 
    8.    // (this is unset when features.meta is false in server template) 
    9.    // 以下就是上文省略的 n 行 HEAD 拼接代码,可以适当忽略 
    10.    // 了解主要过程即可,具体细节按需查看 
    11.    const meta = renderContext.meta && renderContext.meta.inject({ 
    12.      isSSR: renderContext.nuxt.serverRendered, 
    13.      ln: this.options.dev 
    14.   }) 
    15.  
    16.    if (meta) { 
    17.      HEAD += meta.title.text() + meta.meta.text() 
    18.   } 
    19.  
    20.    if (meta) { 
    21.      HEAD += meta.link.text() + 
    22.        meta.style.text() + 
    23.        meta.script.text() + 
    24.        meta.noscript.text() 
    25.   } 
    26.  
    27.    // Check if we need to inject scripts and state 
    28.    const shouldInjectScripts = this.options.render.injectScripts !== false 
    29.  
    30.    // Inject resource hints 
    31.    if (this.options.render.resourceHints && shouldInjectScripts) { 
    32.      HEAD += this.renderResourceHints(renderContext) 
    33.   } 
    34.  
    35.    // Inject styles 
    36.    HEAD += this.renderStyles(renderContext) 
    37.  
    38.  
    39.    // Prepend scripts 
    40.    if (shouldInjectScripts) { 
    41.      APP += this.renderScripts(renderContext) 
    42.   } 
    43.  
    44.    if (meta) { 
    45.      const appendInjectorOptions = { body: true } 
    46.      // Append body scripts 
    47.      APP += meta.meta.text(appendInjectorOptions) 
    48.      APP += meta.link.text(appendInjectorOptions) 
    49.      APP += meta.style.text(appendInjectorOptions) 
    50.      APP += meta.script.text(appendInjectorOptions) 
    51.      APP += meta.noscript.text(appendInjectorOptions) 
    52.   } 
    53.  
    54.    // Template params 
    55.    const templateParams = { 
    56.      HTML_ATTRS: meta ? meta.htmlAttrs.text(renderContext.nuxt.serverRendered /* addSrrAttribute */) : ''
    57.      HEAD_ATTRS: meta ? meta.headAttrs.text() : ''
    58.      BODY_ATTRS: meta ? meta.bodyAttrs.text() : ''
    59.      HEAD, 
    60.      APP, 
    61.      ENV: this.options.env 
    62.   } 
    63.  
    64.    // Render with SSR template 
    65.    // 通过模版和参数 生成html 
    66.    const html = this.renderTemplate(this.serverContext.resources.SSRTemplate, templateParams) 
    67.  
    68.    let preloadFiles 
    69.    if (this.options.render.http2.push) { 
    70.      // 获取需要预加载的文件 
    71.      preloadFiles = this.getPreloadFiles(renderContext) 
    72.   } 
    73.  
    74.    return { 
    75.      html, 
    76.      preloadFiles, 
    77.   } 

    最后在响应的 html 中注入 metadata 即可。

    文件系统路由(File System Routing)

    想必使用过 Nuxt 的同学应该都对其基于文件生成路由的特性,印象深刻。让我从源码角度看看 Nuxt 是如何实现基于 pages 目录(可配置),自动生成路由的。

    首先在启动 Nuxt 项目或者修改文件时,会自动调 generateRoutesAndFiles 方法,生成路由 以及 .nuxt 目录下的文件。

    1. // @nuxt/builder/src/builder.js 
    2. async generateRoutesAndFiles() { 
    3.   ... 
    4.   await Promise.all([ 
    5.     this.resolveLayouts(templateContext), 
    6.     this.resolveRoutes(templateContext), //解析生成路由,需要关注的重点 
    7.     this.resolveStore(templateContext), 
    8.     this.resolveMiddleware(templateContext) 
    9.   ]) 
    10.   ... 

    解析路由会存在三种情况:一是修改了默认的 pages 目录名称,且未在 nuxt.config.js 中配置相关目录,二是使用 nuxt 默认的 pages 目录,三是使用调用用户自定义的路由生成方法生成路由。

    1. // @nuxt/builder/src/builder.js 
    2. async resolveRoutes({ templateVars }) { 
    3.   consola.debug('Generating routes...'
    4.   if (this._defaultPage) { 
    5.     // 在srcDir下未找到pages目录 
    6.   } else if (this._nuxtPage) { 
    7.     // 使用nuxt动态生成路由 
    8.   } else { 
    9.     // 用户提供了自定义方法去生成路由,提供用户自定义路由的能力 
    10.   } 
    11.   // router.extendRoutes method 
    12.   if (typeof this.options.router.extendRoutes === 'function') { 
    13.     const extendedRoutes = await this.options.router.extendRoutes( 
    14.       templateVars.router.routes, 
    15.       resolve 
    16.     ) 
    17.     if (extendedRoutes !== undefined) { 
    18.       templateVars.router.routes = extendedRoutes 
    19.     } 
    20.   } 

    除此之外,还可以提供相应的 extendRoutes 方法,在 nuxt 生成路由的基础上添加自定义路由。

    1. export default { 
    2.   router: { 
    3.     extendRoutes(routes, resolve) { 
    4.       // 例如添加 404 页面 
    5.       routes.push({ 
    6.         name'custom'
    7.         path: '*'
    8.         component: resolve(__dirname, 'pages/404.vue'
    9.       }) 
    10.     } 
    11.   } 

    其中当修改了默认的 pages 目录,导致找不到相关的目录,会使用 @nuxt/vue-app/template/pages/index.vue 文件生成路由。

    1. async resolveRoutes({ templateVars }) { 
    2.   if (this._defaultPage) { 
    3.     // createRoutes 方法根据传参,生成路由。具体算法,不再展开 
    4.     templateVars.router.routes = createRoutes({ 
    5.       files: ['index.vue'], 
    6.       srcDir: this.template.dir + '/pages', // 指向@nuxt/vue-app/template/pages/index.vue 
    7.       routeNameSplitter, // 路由名称分隔符,默认`-` 
    8.       trailingSlash // 尾斜杠 / 
    9.     }) 
    10.   } else if (this._nuxtPage) { 
    11.     const files = {} 
    12.     const ext = new RegExp(`\\.(${this.supportedExtensions.join('|')})$`) 
    13.     for (const page of await this.resolveFiles(this.options.dir.pages)) { 
    14.       const key = page.replace(ext, ''
    15.       // .vue file takes precedence over other extensions 
    16.       if (/\.vue$/.test(page) || !files[key]) { 
    17.         files[key] = page.replace(/(['"])/g, '\\$1') 
    18.       } 
    19.     } 
    20.     templateVars.router.routes = createRoutes({ 
    21.       files: Object.values(files), 
    22.       srcDir: this.options.srcDir, 
    23.       pagesDir: this.options.dir.pages, 
    24.       routeNameSplitter, 
    25.       supportedExtensions: this.supportedExtensions, 
    26.       trailingSlash 
    27.     }) 
    28.     } else { 
    29.       templateVars.router.routes = await this.options.build.createRoutes(this.options.srcDir) 
    30.     } 
    31.     // router.extendRoutes method 
    32.     if (typeof this.options.router.extendRoutes === 'function') { 
    33.       const extendedRoutes = await this.options.router.extendRoutes( 
    34.         templateVars.router.routes, 
    35.         resolve 
    36.       ) 
    37.       if (extendedRoutes !== undefined) { 
    38.         templateVars.router.routes = extendedRoutes 
    39.       } 
    40.   } 

    然后就是调用 createRoutes 方法,生成路由。生成的路由大致长这样,和手动书写的路由文件几乎一致(后续还会进行打包??,懒加载引入路由组件)。

    1.   { 
    2.     name'index'
    3.     path: '/'
    4.     chunkName: 'pages/index'
    5.     component: 'Users/username/projectName/pages/index.vue' 
    6.   }, 
    7.   { 
    8.     name'about'
    9.     path: '/about'
    10.     chunkName: 'pages/about/index'
    11.     component: 'Users/username/projectName/pages/about/index.vue' 
    12.   } 

    智能预取(Smart Prefetching)

    从 Nuxt v2.4.0 开始,当 出现在可视区域后,Nuxt将会预取经过code-splitted的 page 页面的脚本,使得在用户点击之前,该路由指向的地址,就处于 ready 状态,这将极大的提升用户的体验。

    相关实现逻辑集中于 .nuxt/components/nuxt-link.client.js 中。

    首先 Smart Prefetching 特性的实现依赖于window.IntersectionObserver 这个实验性的 API,如果浏览器不支持该 API,就不会进行组件预取操作。

    1. mounted () { 
    2.   if (this.prefetch && !this.noPrefetch) { 
    3.     this.handleId = requestIdleCallback(this.observe, { timeout: 2e3 }) 
    4.   } 

    然后在需要预取的 组件挂载阶段,会调用 requestIdleCallback 方法在浏览器的空闲时段内调用 observe 方法。

    1. const observer = window.IntersectionObserver && new window.IntersectionObserver((entries) => { 
    2.   entries.forEach(({ intersectionRatio, target: link }) => { 
    3.     // 如果intersectionRatio 小于等于0,表示目标不在viewport内 
    4.     if (intersectionRatio <= 0 || !link.__prefetch) { 
    5.       return 
    6.     } 
    7.     // 进行预取数据(其实就是加载组件) 
    8.     link.__prefetch() 
    9.   }) 
    10. }) 

    当被监听的元素的可视情况发生改变的时候(且出现在视图内时),会触发 new window.IntersectionObserver(callback) 的回调,执行真正的预取操作prefetchLink。

    1. prefetchLink () { 
    2.   // 判断网络环境,离线或者2G环境下,不进行预取操作 
    3.   if (!this.canPrefetch()) { 
    4.     return 
    5.   } 
    6.   // 停止监听该元素,提高性能 
    7.   observer.unobserve(this.$el) 
    8.   const Components = this.getPrefetchComponents() 
    9.  
    10.   for (const Component of Components) { 
    11.     // 及时加载组件,使得用户点击时,该组件是一个就绪的状态 
    12.     const componentOrPromise = Component() 
    13.     if (componentOrPromise instanceof Promise) { 
    14.       componentOrPromise.catch(() => {}) 
    15.     Component.__prefetched = true // 已经预取的标志位 
    16.   } 

    上文从源码角度介绍了 Nuxt 服务端渲染的实现、服务端数据的获取以及 Nuxt 开箱即用的几个特性:HEAD 管理、基于文件系统的路由和智能预取 code-splitted 的路由。如果希望对 SSR 进行更深入研究,还可以横向学习 React 的 SSR 实现 Next 框架。

    希望对您有所帮助,如有纰漏,望请辅正。

    为什么使用服务器端渲染 (SSR)?

    Nuxt源码精读

    Vue Meta

    Introducing Smart prefetching

    服务端渲染