Nuxt源码精读

4,112 阅读11分钟

前述

Nuxt是Vue开源社区提供的一整套基于Vue生态的SSR解决方案,包含脚手架、初始化工程目录、调试/构建服务等,其中SSR功能底层依旧依赖的是vue-server-renderer这个模块,恰巧公司项目中也使用了这个模块来做SSR渲染,但上层为了满足公司定制的需求,并没有使用Nuxt这个框架,而是自己自建了一套框架。为了更好的开发适合业务需求的SSR脚手架,本篇文章将深入Nuxt源码,学习Nuxt框架中的优点,帮助我们更好的开发属于自己的SSR脚手架。

关于Nuxt的使用方法见官网:nuxtjs.org/

在开始分析Nuxt源码之前,个人习惯首先要确定几条分析的主轴,要学习框架核心的设计思想,核心的实现思路,这样可以快速的帮我们拨云见日。

如何确定框架实现的主轴呢?我们可以通过抛出问题的方式来快速定位:

  1. 作为一个SSR框架,它是如何实现服务端渲染功能的?
  2. 实现SSR端渲染,首先要构建静态资源,构建静态资源的实现思路如何?
  3. Nuxt是如何将文件目录转化成路由path的?
  4. Nuxt中间件如何管理?
  5. Nuxt脚手架设计思路?
  6. Nuxt如何满足订制化需求?

我们先来分析一下Nuxt工程架构,来学习一下如何设计一个SSR脚手架,同时方便后续根据提出的问题快速定位核心代码。

Nuxt工程架构

首先来看一下Nuxt.js工程架构:

// 工程核心目录结构
├─ distributions   
    ├─ nuxt                 // nuxt指令入口,同时对外暴露@nuxt/core、@nuxt/builder、@nuxt/generator、getWebpackConfig
    ├─ nuxt-start           // nuxt start指令,同时对外暴露@nuxt/core
├─ lerna.json               // lerna配置文件
├─ package.json         
├─ packages                 // 工作目录
    ├─ babel-preset-app     // babel初始预设
    ├─ builder              // 根据路由构建动态当前页ssr资源,产出.nuxt资源
    ├─ cli                  // 脚手架命令入口
    ├─ config               // 提供加载nuxt配置相关的方法
    ├─ core                 //  Nuxt实例,加载nuxt配置,初始化应用模版,渲染页面,启动SSR服务
    ├─ generator            // Generato实例,生成前端静态资源(非SSR)
    ├─ server               // Server实例,基于Connect封装开发/生产环境http服务,管理Middleware
    ├─ types                // ts类型
    ├─ utils                // 工具类
    ├─ vue-app              // 存放Nuxt应用构建模版,即.nuxt文件内容
    ├─ vue-renderer         // 根据构建的SSR资源渲染html
    └─ webpack              // webpack相关配置、构建实例
├─ scripts
├─ test
└─ yarn.lock

Nuxt各模块引用关系如图所示:

Nuxt所有模块都放在一个仓库中,使用lerna来进行模块管理。扫了一遍模块结构后,发现了一个比较有意思的地方,就是Nuxt有两个工作目录,packages不用说,存放nuxt框架所有核心代码。但distributions存放的模块是做什么的?该工作目录中存放两个模块:nuxtnuxt-start

nuxt模块入口:

export * from '@nuxt/core'
export * from '@nuxt/builder'
export * from '@nuxt/generator'

export { getWebpackConfig } from '@nuxt/cli'

该模块对外暴露的@nuxt/core@nuxt/builder@nuxt/generator所有属性以及getWebpackConfig方法。通过上面的工程架构可以看到,这三个模块是nuxt最核心的模块。存放了nuxt相关实例,提供渲染、调试、构建等功能。在脚手架模版工程中,我们可以通过调用@nuxt/cli命令行工具启动nuxt服务,除此之外,也可以在node模块中直接引入@nuxt/core调用渲染功能,引入@nuxt/builder调用构建功能。

在封装脚手架的过程中,通常要考虑‘高内聚低耦合’原则,来保持模块的独立性,这样在功能增多时,便于多人维护,灵活可扩展。而nuxt又在这些基础模块上又封装了一层,这样我们在单独调用功能时可以直接从nuxt这个模块引入;同时对外用户接触到的也始终是nuxt模块,不用再额外去了解其它模块的功能,此外当我们想要改动底层模块时,只要保证nuxt对外输出不变,迁移成本也会变低。这一点非常值得个人去学习。

同理,nuxt-start模块抽象的意义应该也在于此。

其它比较遗憾的是,Nuxt是一整套的SSR解决方案,内部的核心模块互相依赖(见上图模块引用关系图),想要单独引用单一基础模块比较困难,比如单独使用@nuxt/vue-renderer或者@nuxt/webpack

通过大致的浏览一遍框架,可以让我们获得全景模式,梳理功能依赖的脉络,可以帮助我们学习如何去设计一个SSR脚手架,当开发公司内部定制的脚手架时提供了一种成熟的思路。

好,以上是对SSR框架设计的一些思考和总结,下面回到文章开头提到的核心问题。作为一个SSR框架,如何实现页面服务端渲染功能? 说到渲染功能,可能有人会首先关心资源构建的问题,比如如何构建客户端资源和服务端资源,毕竟这是脚手架重要的组成部分,对应实现方式可以见@nuxt/webpack@nuxt/builder的源代码,nuxt实现的可能比较抽象,难以阅读,这里我推荐去看以下的资源:

nuxt是自己封装了webpack配置及相关插件,除了构建clientssr资源,也提供了初始化的webpack配置,如果是自己开发,也可以基于@vue/cli-server提供的基础配置魔改,或者干脆自己配置,最后再引入vue-server-renderer插件做不同构建环境的适配。

ps,既然都自己开发脚手架了,webpack配置肯定得自己搞定嘛(逃

因此,本篇将不再深入研究webpack相关的配置,以及webpack构建的逻辑。将集中精力去分析nuxt实现路由加载、模版预设处理、html插值、服务端渲染等相关的逻辑。

Nuxt渲染过程

ssr核心原理

在文章前述中我有提到,nuxt底层调用了vue-server-renderer这个方法库渲染html资源,调用位置在‘模块引用关系图’中有标出。个人自定义SSR项目中,同样也使用了该模块做html渲染(毕竟官方推荐--!)。html渲染的过程从本质上看非常简洁,假设我们有一个模版+资源映射表:

html模版

<!--html模版-->
<html>
  <head>
    <!--资源预加载-->
    {{{ renderResourceHints() }}}
    <!--style样式-->
    {{{ renderStyles() }}}
  </head>
  <body>
    <!--vue-ssr-outlet-->
    <!--js资源-->
    {{{ renderScripts() }}}
  </body>
</html>

资源映射表

{
    publicPath: "xx/xx/",
    initial: [
        "css/xxx.css"
        "js/xxx.js"
    ],
    async: [
        "css/xxx.css"
        "js/xxx.js"
    ]
}

那么html渲染的过程就会变成:

  1. 读取资源映射表,在node环境加载并执行js bundle,创建Vue实例渲染得到html片段
  2. 读取html模版,匹配{{{}}}中的方法
  3. 实现renderResourceHintsrenderStylesrenderScripts方法,核心是根据资源映射表创建对应的link、style、script标签。
  4. 将html片段及上面创建的标签插入到html模版中。

当然具体的实现肯定要复杂的多,比如处理大文件html的插值、使用html模版还是构建vue实例、异步资源的处理、加载/执行组件脚本、异常处理、缓存等等。

相关源码可以参考这里:

ps:先留一个坑,后面把render相关的源码精读补上!(逃

nuxt渲染过程

nuxt框架中,调用render方法可以做如下实现:

const koa = require('koa')
const { loadNuxt, build } = require('nuxt') 
const isDev = process.env.NODE_ENV !== 'production'

const app = new Koa()

async function start () {
    // 创建nuxt实例
    const nuxt = await loadNuxt(isDev ? 'dev' : 'start')
    
    if(isDev) {
        // 开发环境实时构建ssr资源
        build(nuxt)
    }
    
    app.use(ctx => {
        ctx.status = 200
        return new Promise((resolve, reject) => {
            ctx.res.on('close', resolve)
            ctx.res.on('finish', resolve)
            nuxt.render(ctx.req, ctx.res, promise => {
                // nuxt.render passes a rejected promise into callback on error.
                promise.then(resolve).catch(reject)
            })
        })
    })
    
    app.listen('3000', '127.0.0.1') 
}

start()

以生产环境为例,核心就是创建Nuxt实例,以及调用实例上的render中间方法。

render是一个中间件,其调用过程(生命周期)如下图所示:

image

nuxt.render调用过程

nuxt.render调用路线如图所示:

我只是大致的画出了render方法的调用路线,其中忽略了很多细节,感兴趣的读者可以依照路线图深入细节去探究。

了解了服务端渲染的大致流程后,还有几个点需要解答,比如开头提出的:nuxt如何生成路由、middleware注册等。

注册middleware

细心的朋友可能会发现,在流程中setupMiddleware方法用来注册中间件,但注册的都是服务端中间件,那文档中说的globalLayoutPage级别的中间件又是什么时候注册的呢?并且中间件在server环境和client都会调用,这又是如何实现的呢?

比如layout组件中定义了如下中间件:

export default {
  middleware: (app) => {
    console.log('middleware', app);
  },
  head() {
    return {
      title: 'index.vue'
    }
  }
}

其实实现思路也很直接,如果我们有nuxt模版工程的话,可以打开.nuxt/server.js.nuxt/client.js文件(开发环境),server.js是服务端渲染是调用的js bundle文件,client.js是浏览器执行的js bundle文件。里面有如下代码:

let midd = []

// 获取layout中间件
layout = sanitizeComponent(layout)
if (layout.options.middleware) {
  midd = midd.concat(layout.options.middleware)
}

// 
Components.forEach((Component) => {
  if (Component.options.middleware) {
    midd = midd.concat(Component.options.middleware)
  }
})
midd = midd.map((name) => {
  if (typeof name === 'function') {
    return name
  }
  if (typeof middleware[name] !== 'function') {
    app.context.error({ statusCode: 500, message: 'Unknown middleware ' + name })
  }
  return middleware[name]
})
await middlewareSeries(midd, app.context)

收集所有定义的中间件,然后按照顺序依次调用midd中注册的中间件。关于调用中间件这里,实现的比较有意思:

export function middlewareSeries(promises, appContext) {
  ...
  // 递归调用,每次只执行一个中间件,执行成功后调用下一个中间件
  return promisify(promises[0], appContext)
    .then(() => {
      return middlewareSeries(promises.slice(1), appContext)
    })
}

export function promisify(fn, context) {
  ...
  // 调用中间件
  return Promise.resolve(fn(context))

以上就是中间件注册及调用的过程,nuxt中关于中间件的处理还有很多,比如我们可以在nuxt.config.js中追加serverMiddleware属性来配置服务端中间件(或者直接在服务进程入口调用,如上面koa示例中直接使用koa中间件)。

从应用层看,了解了nuxt中间件调用过程可以方便我们处理很多公共问题,以及做好服务端运维工作,比如:代理、缓存、日志、监控、数据处理等等。

nuxt路由配置

在搭建ssr项目,需要同时考虑服务端路由和客户端路由,手动搭建的话,需要在服务端匹配请求路径,然后调用vue-router实例的push方法切换到当前页渲染。

但nuxt将这些完全黑盒化,提供了另外一种新颖的思路:

比如存在如下目录

pages/
--| users/
-----| _id.vue
--| index.vue

它将会被nuxt转化成:

router:{
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'users-id',
      path: '/users/:id?',
      component: 'pages/users/_id.vue'
    }
  ]
}

相关路由配置将会输出在.nuxt/router.js文件中。也就是说路由表是在构建阶段生成的,话不多说,直接定位到@nuxt/builder模块找寻方案:

class Builder {
    async build() {
        ...
        // 检查文件目录是否存在
        await this.validatePages()
        // 生成路由并且产出模版文件 
        await this.generateRoutesAndFiles()
    }
    
    async generateRoutesAndFiles() {
        ...
        // 根据文件结构生成路由表
        await this.resolveRoutes(templateContext)
        // 产出模版文件
        await this.compileTemplates(templateContext)
    }
}

以上是摘取的nuxt路由生成策略。

Nuxt模块化

在开发脚手架的时候,会面临一个严峻的问题,即如何解决定制化和通用化之间的矛盾,换句话说就是我们需要将脚手架底层相关的hook或者配置暴露出去,供业务方根据场景做定制化需求,假如配置暴露的死板,那么会对脚手架升级造成严重影响(需要向下兼容)。所以抽象化、插件化非常考验脚手架设计架构的能力。

我们来看nuxt框架提供的方案,在nuxt.config.js暴露了headcsspluginsmodulesbuild等配置,可以让我们侵入到html插值、模版构建的功能中,其中个人比较中意的是module功能的设置,它支持以模块化的形式封装多个配置,假设我们接入一个lodash库,需要同时配置pluginbuild的话会很不方便,这时我们就可以抽象成一个模块。

相关文档和源码:

需要注意的是module是在new Nuxt()阶段加载并执行的,moduleOptions就是moduleContainer实例,module挂载的逻辑将会存储在context(上下文集合)里,供后面render过程使用。

总结

以上就是对nuxt框架的学习与思考,仅仅分析了其中主要的几个核心功能,如果想要设计出功能强大的脚手架需要考虑的问题其实是非常多的,从功能上可以划分为:构建、渲染、模块化、插件化;从架构上要考虑:集成性、灵活性、模块管理等等问题。

另外,分析nuxt框架设计可以帮我们横向扩展其它类型的SSR 框架,比如Next.js等,我相信虽然API不一样,但背后的某些设计思想一定是可以共同的,这样就可以达到举一反三的作用。