服务端渲染(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
调用 layout
和 page
的中间件方法
调用 validate
方法
在这一步可以对请求参数进行校验,或是对第一步中服务器下发的数据进行校验,如果校验失败,将抛出 404 页面。
调用 fetch
及 asyncData
方法
这两个方法都会在组件加载之前被调用,它们的职责各有不同, 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版也会在不久之后发布,让我们拭目以待~