前述
Nuxt是Vue开源社区提供的一整套基于Vue生态的SSR解决方案,包含脚手架、初始化工程目录、调试/构建服务等,其中SSR功能底层依旧依赖的是vue-server-renderer这个模块,恰巧公司项目中也使用了这个模块来做SSR渲染,但上层为了满足公司定制的需求,并没有使用Nuxt这个框架,而是自己自建了一套框架。为了更好的开发适合业务需求的SSR脚手架,本篇文章将深入Nuxt源码,学习Nuxt框架中的优点,帮助我们更好的开发属于自己的SSR脚手架。
关于Nuxt的使用方法见官网:nuxtjs.org/
在开始分析Nuxt源码之前,个人习惯首先要确定几条分析的主轴,要学习框架核心的设计思想,核心的实现思路,这样可以快速的帮我们拨云见日。
如何确定框架实现的主轴呢?我们可以通过抛出问题的方式来快速定位:
- 作为一个SSR框架,它是如何实现服务端渲染功能的?
- 实现SSR端渲染,首先要构建静态资源,构建静态资源的实现思路如何?
- Nuxt是如何将文件目录转化成路由path的?
- Nuxt中间件如何管理?
- Nuxt脚手架设计思路?
- 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存放的模块是做什么的?该工作目录中存放两个模块:nuxt和nuxt-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配置及相关插件,除了构建client和ssr资源,也提供了初始化的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渲染的过程就会变成:
- 读取资源映射表,在node环境加载并执行js bundle,创建Vue实例渲染得到html片段
- 读取html模版,匹配
{{{}}}中的方法 - 实现
renderResourceHints、renderStyles、renderScripts方法,核心是根据资源映射表创建对应的link、style、script标签。 - 将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是一个中间件,其调用过程(生命周期)如下图所示:
nuxt.render调用过程
nuxt.render调用路线如图所示:
我只是大致的画出了render方法的调用路线,其中忽略了很多细节,感兴趣的读者可以依照路线图深入细节去探究。
了解了服务端渲染的大致流程后,还有几个点需要解答,比如开头提出的:nuxt如何生成路由、middleware注册等。
注册middleware
细心的朋友可能会发现,在流程中setupMiddleware方法用来注册中间件,但注册的都是服务端中间件,那文档中说的global、Layout和Page级别的中间件又是什么时候注册的呢?并且中间件在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暴露了head、css、plugins、modules、build等配置,可以让我们侵入到html插值、模版构建的功能中,其中个人比较中意的是module功能的设置,它支持以模块化的形式封装多个配置,假设我们接入一个lodash库,需要同时配置plugin、build的话会很不方便,这时我们就可以抽象成一个模块。
相关文档和源码:
需要注意的是module是在new Nuxt()阶段加载并执行的,moduleOptions就是moduleContainer实例,module挂载的逻辑将会存储在context(上下文集合)里,供后面render过程使用。
总结
以上就是对nuxt框架的学习与思考,仅仅分析了其中主要的几个核心功能,如果想要设计出功能强大的脚手架需要考虑的问题其实是非常多的,从功能上可以划分为:构建、渲染、模块化、插件化;从架构上要考虑:集成性、灵活性、模块管理等等问题。
另外,分析nuxt框架设计可以帮我们横向扩展其它类型的SSR 框架,比如Next.js等,我相信虽然API不一样,但背后的某些设计思想一定是可以共同的,这样就可以达到举一反三的作用。