Nuxtjs小结

服务端渲染(Server Side Render)并不是一个新的概念,在单页应用(SPA)还没有流行起来的时候,页面就是通过服务端渲染好,并传递给浏览器的。当用户需要访问新的页面时,需要再次请求服务器,返回新的页面。

Vue.js 推出后,其数据驱动和组件化思想,以及简洁易上手的特性给开发者带来了巨大的便利,Vue.js 官方提供的 vue-server-renderer 可以用来进行服务端渲染的工作,但是需要增加额外的工作量,开发体验仍有待提高,而 Nuxt.js 推出后,这个问题被很好的解决了。

Nuxt.js 简介

Nuxt.js 是一个基于 Vue.js 的通用应用框架,Nuxt.js 预设了利用 Vue.js 开发服务端渲染的应用所需要的各种配置,并且可以一键生成静态站点。同时,Nuxt.js 的热加载机制可以使开发者非常便捷的进行网站的开发。

Nuxt.js 于 2016 年 10 月 25 号发布,Nuxt.js 社区也在逐步完善中,官网已经支持了中文文档。https://zh.nuxtjs.org/

简单上手

vue-cli  或者  yarn 都可以

vue init nuxt/starter
yarn create nuxt-app <项目名>

下面简要介绍一下各个目录的作用:

.nuxt/ :用于存放 Nuxt.js 的核心库文件。

server.js 文件,描述了 Nuxt.js 进行服务端渲染的逻辑, router.js 文件包含一张自动生成的路由表。

assets/ :用于存放静态资源。

components/ :存放项目中的各种组件。

layouts/ :创建自定义的页面布局,可以在这个目录下创建全局页面的统一布局,或是错误页布局。如果需要在布局中渲染 pages 目录中的路由页面,需要在布局文件中加上 <nuxt /> 标签。

middleware/ :放置自定义的中间件,会在加载组件之前调用。

在中间价可以做路由拦截,参数过滤等等,middleware的第一参数是一个上下文参数,能够解构出route,params,query等等...参数,足够我们做各种骚操作。既然它们能够定义在不同位置,那么它们的执行顺序就有前有后

执行顺序:nuxt.config => layout => page

pages/ :在这个目录下,Nuxt.js 会根据目录的结构生成 vue-router 路由,详见下文。

plugins/ :可以在这个目录中放置自定义插件,在根 Vue 对象实例化之前运行。例如,可以将项目中的埋点逻辑封装成一个插件,放置在这个目录中,并在 nuxt.config.js 中加载。

static/ :不使用 Webpack 构建的静态资源,会映射到根路径下,如 robots.txt

store/ :存放 Vuex 状态树。

nuxt.config.js :Nuxt.js 的配置文件。

Nuxt.js 的渲染流程

Nuxt.js 通过一系列构建于 Vue.js 之上的方法进行服务端渲染,具体流程如下:

调用 nuxtServerInit 方法

当请求打入时,最先调用的即是 nuxtServerInit 方法,可以通过这个方法预先将服务器的数据保存,如已登录的用户信息等。另外,这个方法中也可以执行异步操作,并等待数据解析后返回。

Middleware

经过第一步后,请求会进入 Middleware 层,在该层中有三步操作:

读取 nuxt.config.js 中全局 middleware 字段的配置,并调用相应的中间件方法 匹配并加载与请求相对应的 layout 调用 layoutpage 的中间件方法

调用 validate 方法

在这一步可以对请求参数进行校验,或是对第一步中服务器下发的数据进行校验,如果校验失败,将抛出 404 页面。

调用 fetchasyncData 方法

这两个方法都会在组件加载之前被调用,它们的职责各有不同, asyncData 用来异步的进行组件数据的初始化工作,而 fetch 方法偏重于异步获取数据后修改 Vuex 中的状态。

我们在 Nuxt.js 的源码 util.js 中可以看到以下方法:

if (
    // For SSR, we once all this function without second param to just apply asyncData
    // Prevent doing this for each SSR request
    !asyncData && Component.options.__hasNuxtData
  ) {
    return
  }
 
 

  const ComponentData = Component.options._originDataFn || Component.options.data || function () { return {} }
  Component.options._originDataFn = ComponentData
 
  Component.options.data = function () {
    const data = ComponentData.call(this, this)
    if (this.$ssrContext) {
      asyncData = this.$ssrContext.asyncData[Component.cid]
    }
    return { ...data, ...asyncData }
  }
 
  Component.options.__hasNuxtData = true
 
  if (Component._Ctor && Component._Ctor.options) {
    Component._Ctor.options.data = Component.options.data
  }

这个方法会在 asyncData 方法调用完毕后进行调用,可以看到,组件从 asyncData 方法中获取的数据会和组件原生的 data 方法获取的数据做一次合并,最终仍然会在 data 方法中返回,所以得出, asyncData 方法其实是原生 data 方法的扩展。

经过以上四步后,接下来就是渲染组件的工作了,整个过程就可以用官网的流程图来展示:

服务端

  • 服务启动 (nuxt start)

生成静态网站时,服务端的生命周期仅在构建时执行,但每个生成的页面都执行。

  • 生成器启动 (nuxt generate)
  • Nuxt hooks
  • 服务端中间件( serverMiddleware)

服务端插件(Server-side Nuxt plugins)

  • 在 nuxt.config.js 中设置

nuxtServerInit

  • Vuex 操作仅在服务端调用去预设 store
  • 第一个参数是** Vuex上下文,第二个参数是 Nuxt.js上下文**
  • 此处调度其他操作→仅“入口点”用于服务器端的后续存储操作
  • 只能够在 store/index.js 中设置

中间件( Middleware)

  • 全局中间件(Global middleware)
  • 布局中间件(Layout middleware)
  • 路由中间件(Route middleware)
  • asyncData
  • beforeCreate (Vue 生命周期方法)
  • created (Vue 生命周期方法)
  • 新的 fetch 方法
  • 状态序列化 ( Nuxt.js hook 钩子 render:routeContext)
  • HTML 渲染 ( Nuxt.js hook 钩子render:route)
  • 当 HTML 已经被发送到浏览器render:routeDone 钩子
  • generate:before Nuxt.js hook 钩子

HTML files 已经生成

  • 全部静态生成
  • e.g. 静态文件被读取
  • generate:page (可编辑的HTML)
  • generate:routeCreated (生成的 Route)
  • generate:done当所有 HTML 文件都已生成

客户端

无论选择哪种Nuxt.js模式,这一部分的生命周期都将在浏览器中完全执行。

  • 接收 HTML
  • 加载 assets (e.g. Javascript)
  • 客户端激活(Vue Hydration)
  • 所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程
  • 中间件(Middleware)
  • 全局中间件(Global middleware)
  • 布局中间件(Layout middleware)
  • 路由中间件(Route middleware)
  • 客户端 plugin
  • 在 nuxt.config.js 中定义
  • beforeCreate (Vue 生命周期方法)
  • created (Vue 生命周期方法)
  • 新的 fetch 方法
  • beforeMount (Vue 生命周期方法)
  • mounted (Vue 生命周期方法)

如上文所述,在 .nuxt 目录下,你可以找到 server.js 文件,这个文件封装了 Nuxt.js 在服务端渲染的逻辑,包括一个完整的 Promise 对象的链式调用,从而完成上面描述的整个服务端渲染的步骤。

nuxt的路由机制和原理

nuxt会根据pages下的文件自动生成路由并引入,支持vue-router的基础路由,动态路由,嵌套路由等。基础路由很简单,需要注意的是,在使用动态路由时,需要创建对应的以下划线作为前缀的 Vue文件或目录。

异步数据asyncData

注意:必须要页面组件才能调用asyncData(就是components下是不能调用,必须路由的页面才行)

异步数据beforeCreate,created

注意:在任何vue组件的生命周期内,只有beforeCreate和created这两个钩子会在浏览器端和服务端均被调用;其他的钩子都只会在浏览器端调用。

部分源码解析

github下载源码查看

入口 packages/builder/src/index.js

import Builder from './builder'
export { default as Builder } from './builder'
 
 
export function getBuilder (nuxt) {
    return new Builder(nuxt)
}
 
export function build (nuxt) {
    return getBuilder(nuxt).build()
}

packages/builder/src/builder.js   这个文件顾名思义就是构建,对所有文件打包,其中必不可少就是 构建路由。

// Generate routes and interpret the template files   代码157行
await this.generateRoutesAndFiles()

路由在这个方法中,跟踪过后可以看到他不仅对路由处理了,还对layout, store, middleware。 看注释也可以明白 还对模版文件进行了转译。

因为我们是研究路由机制,所以继续跟踪resolveRoutes。

generateRoutesAndFiles方法中 resolveFiles引入了glob库对page下的文件进行遍历,并进行了字符串的处理,最后将所有的vue文件地址,整个的项目地址和pages作为参数传给createRoutes 函数。

async generateRoutesAndFiles () {
    this.plugins = Array.from(await this.normalizePlugins())
    const templateContext = this.createTemplateContext()
    await Promise.all([
      this.resolveLayouts(templateContext),
      this.resolveRoutes(templateContext),
      this.resolveStore(templateContext),
      this.resolveMiddleware(templateContext)
    ])
    this.addOptionalTemplates(templateContext)
    await this.resolveCustomTemplates(templateContext)
    await this.resolveLoadingIndicator(templateContext)
    await this.compileTemplates(templateContext)
}

继续跟踪createRoutes函数。

async resolveRoutes ({ templateVars }) {
    consola.debug('Generating routes...')
    const { routeNameSplitter, trailingSlash } = this.options.router
 
    if (this._defaultPage) {
      templateVars.router.routes = createRoutes({
        files: ['index.vue'],
        srcDir: this.template.dir + '/pages',
        routeNameSplitter,
        trailingSlash
      })
    } else if (this._nuxtPages) {
      // Use nuxt createRoutes bases on pages/
      const files = {}
      const ext = new RegExp(`\\.(${this.supportedExtensions.join('|')})$`)
      for (const page of await this.resolveFiles(this.options.dir.pages)) {
        const key = page.replace(ext, '')
        // .vue file takes precedence over other extensions
        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 { // If user defined a custom method to create routes
      templateVars.router.routes = await this.options.build.createRoutes(
        this.options.srcDir
      )
    }
}

在createRoutes函数中对传过来的所有文件地址进行遍历,再对每一个文件地址字符串处理,以中划线进行拼接。以此作为route.name。

async resolveFiles (dir, cwd = this.options.srcDir) {
  return this.ignore.filter(await glob(this.globPathWithExtensions(dir), {
    cwd,
    follow: this.options.build.followSymlinks
  }))
}

再对routes进行查找,这里就可以看出为什么使用嵌套路由要在同路径下再加一个同名的vue文件,它的判断条件就是在routes中找到 name:route.name的集合。

const routes = []
  files.forEach((file) => {
    const keys = file
      .replace(new RegExp(`^${pagesDir}`), '')
      .replace(new RegExp(`\\.(${supportedExtensions.join('|')})$`), '')
      .replace(/\/{2,}/g, '/')
      .split('/')
      .slice(1)
    const route = { name: '', path: '', component: r(srcDir, file) }
    let parent = routes
    keys.forEach((key, i) => {
      // remove underscore only, if its the prefix
      const sanitizedKey = key.startsWith('_') ? key.substr(1) : key
      route.name = route.name ? route.name + routeNameSplitter + sanitizedKey : sanitizedKey
      route.name += key === '_' ? 'all' : ''
      route.chunkName = file.replace(new RegExp(`\\.(${supportedExtensions.join('|')})$`), '')
      ...
    })
    parent.push(route)
  })

如果有嵌套路由,暂时route.path为空,没有嵌套路由就直接以“/“拼接route.path,这里就可以看到动态路由的合成原理,如果是动态路由,route.path将会以 : 替换 _ ,末尾加上 ?


const child = parent.find(parentRoute => parentRoute.name === route.name)
// let child = _.find(parent, { name: route.name })
if (child) {
        child.children = child.children || []
        parent = child.children
        route.path = ''
} else if (key === 'index' && i + 1 === keys.length) {
        route.path += i > 0 ? '' : '/'
} else {
        route.path += '/' + normalizeURL(getRoutePathExtension(key))
        if (key.startsWith('_') && key.length > 1) {
          route.path += '?'
        }
}

将route.name和route.path都放入routes中,进行排序,路径短的先放入,最后调用cleanChildrenRoutes函数,对嵌套路由进行处理。到这routes的path 和name的命名的处理已经结束。

...
sortRoutes(routes)
return cleanChildrenRoutes(routes, false, routeNameSplitter, trailingSlash)

再回到build.js中, 打包完后会生成模版文件,routes.js。

在模版文件route.js中, 实例了项目的路由 并引入了路由组件,在引入时,将组件命名为下划线加上组件的hash值并去重引入。

实战演练

针对公司内部管理系统进行Nuxtjs的重构,大体界面如下:

原版:

Nuxtjs版本:

使用Chrome的LightHouse性能测试结果来看,效果性能还是有很显著的提升的。

综上所述

Nuxtjs的优点在于着重UI渲染,同时抽象出客户端/服务器分布,静态渲染、前后分离,自动代码分层,服务、模板皆可配置,项目结构清晰,路由级别的异步数据获取。针对vue的用户,上手难度不大。在重构公司项目的时候,也会发生一些阻碍,针对服务端和客户端的axios,目前没有找到比较好的两端兼容的方法,目前项目结构中两者是独立开的。

在前不久的第四届VueConf上,尤大在对未来的规划中也包含了Nuxt3的beta版也会在不久之后发布,让我们拭目以待~