引文
最近公司项目中使用了 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() 方法,生成渲染器。
// @nuxt/server/src/server.js
async ready () {
// Initialize vue-renderer
this.serverContext = new ServerContext(this)
this.renderer = new VueRenderer(this.serverContext)
await this.renderer.ready()
// Setup nuxt middleware
await this.setupMiddleware()
return this
}
在 ready 中会执行 this.setupMiddleware() ,其中会调用nuxtMiddleware
中间件(这里是响应的关键)。
// @nuxt/server/src/middleware/nuxt.js
export default ({ options, nuxt, renderRoute, resources }) => async function nuxtMiddleware (req, res, next) {
const context = getContext(req, res)
try {
const url = normalizeURL(req.url)
res.statusCode = 200
const result = await renderRoute(url, context) // 渲染相应路由,后文会展开
const {
html,
redirected,
preloadFiles
} = result // 得到html
// 设置头部字段
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Accept-Ranges', 'none')
res.setHeader('Content-Length', Buffer.byteLength(html))
res.end(html, 'utf8') // 做出响应
return html
} catch (err) {
if (context && context.redirected) {
consola.error(err)
return err
}
next(err)
}
}
nuxtMiddleware
中间件中首先标准化请求的url,设置请求状态码,通过url匹配到相应的路由,渲染出对应的路由组件,设置头部信息,最后做出响应。
renderSSR (renderContext) {
// Call renderToString from the bundleRenderer and generate the HTML (will update the renderContext as well)
// renderSSR 只是 universal app的渲染方法,Nuxt 也可以进行开发普通的 SPA 项目
const renderer = renderContext.modern ? this.renderer.modern : this.renderer.ssr
return renderer.render(renderContext)
}
其中 renderRoute
方法会调用 @nuxt/vue-render
的renderSSR
进行服务端渲染操作。
// @nuxt/vue-renderer/src/renderers/ssr.js
async render (renderContext) {
// Call Vue renderer renderToString
let APP = await this.vueRenderer.renderToString(renderContext)
let HEAD = ''
// ... 此处省略n行HEAD拼接代码,后续 HEAD 管理部分会提及
// Render with SSR template
const html = this.renderTemplate(this.serverContext.resources.ssrTemplate, templateParams)
return {
html,
preloadFiles
}
}
而 renderSSR
又会调用 renderer.render 方法,将 url 匹配的路由渲染成字符串,将字符串与模版相结合,得到最终返回给浏览器的html,至此 Nuxt 服务端渲染完成。
最后贴一张盗来的 Nuxt 执行流程图,图画的很棒,流程也很清晰,感谢????????????
数据拉取(Data Fetching)
在客户端程序(CSR)可以通过在 mounted 钩子中获取数据,但在通用程序(Universal)中则需要使用特定的钩子才能在服务端获取数据。
Nuxt 中主要提供了两种钩子获取数据
asyncData
只可以在页面级组件中获取,不可以访问
this
通过返回对象保存数据状态或与Vuex配合进行状态保存
fetch
所有组件中都可以获取,可以访问
this
无需传入
context
,传入 context 会fallback
到老版的 fetch,功能类似于 asyncData
// .nuxt/server.js
// Components are already resolved by setContext -> getRouteData (app/utils.js)
const Components = getMatchedComponents(app.context.route)
// 在匹配的路由中,调用 asyncData 和 legacy 版本的 fetch方法
const asyncDatas = await Promise.all(Components.map((Component) => {
const promises = []
// 调用 asyncData(context)
if (Component.options.asyncData && typeof Component.options.asyncData === 'function') {
const promise = promisify(Component.options.asyncData, app.context)
promise.then((asyncDataResult) => {
ssrContext.asyncData[Component.cid] = asyncDataResult
applyAsyncData(Component)
return asyncDataResult
})
promises.push(promise)
} else {
promises.push(null)
}
// 调用 legacy 版本的fetch(context) 兼容老版本的 fetch
if (Component.options.fetch && Component.options.fetch.length) {
promises.push(Component.options.fetch(app.context))
} else {
promises.push(null)
}
return Promise.all(promises)
}))
在生成的 .nuxt/server.js
中,会遍历匹配的组件,查看组件中是否定义了 asyncData 选项以及 legacy
版 fetch ,存在就依次调用,获得 asyncDatas。
// .nuxt/mixins/fetch.server.js
// nuxt v2.14及之后
async function serverPrefetch() {
// Call and await on $fetch
// v2.14 之后推荐的 fetch
try {
await this.$options.fetch.call(this)
} catch (err) {
if (process.dev) {
console.error('Error in fetch():', err)
}
}
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 管理的流程。具体详情请查看。
// @nuxt/vue-app/template/index.js
// step1
Vue.use(Meta, JSON.stringify(vueMetaOptions))
// @nuxt/vue-app/template/template.js
// step2
export default async (ssrContext) => {
const _app = new Vue(app)
// Add meta infos (used in renderer.js)
ssrContext.meta = _app.$meta()
return _app
}
首先通过Vue插件的形式,注册vue-meta
,内部会在Vue的原型上挂载$meta属性。然后将meta
添加到服务端渲染上下文中。
async render (renderContext) {
// Call Vue renderer renderToString
let APP = await this.vueRenderer.renderToString(renderContext)
// step3
let HEAD = ''
// Inject head meta
// (this is unset when features.meta is false in server template)
// 以下就是上文省略的 n 行 HEAD 拼接代码,可以适当忽略
// 了解主要过程即可,具体细节按需查看
const meta = renderContext.meta && renderContext.meta.inject({
isSSR: renderContext.nuxt.serverRendered,
ln: this.options.dev
})
if (meta) {
HEAD += meta.title.text() + meta.meta.text()
}
if (meta) {
HEAD += meta.link.text() +
meta.style.text() +
meta.script.text() +
meta.noscript.text()
}
// Check if we need to inject scripts and state
const shouldInjectScripts = this.options.render.injectScripts !== false
// Inject resource hints
if (this.options.render.resourceHints && shouldInjectScripts) {
HEAD += this.renderResourceHints(renderContext)
}
// Inject styles
HEAD += this.renderStyles(renderContext)
// Prepend scripts
if (shouldInjectScripts) {
APP += this.renderScripts(renderContext)
}
if (meta) {
const appendInjectorOptions = { body: true }
// Append body scripts
APP += meta.meta.text(appendInjectorOptions)
APP += meta.link.text(appendInjectorOptions)
APP += meta.style.text(appendInjectorOptions)
APP += meta.script.text(appendInjectorOptions)
APP += meta.noscript.text(appendInjectorOptions)
}
// Template params
const templateParams = {
HTML_ATTRS: meta ? meta.htmlAttrs.text(renderContext.nuxt.serverRendered /* addSrrAttribute */) : '',
HEAD_ATTRS: meta ? meta.headAttrs.text() : '',
BODY_ATTRS: meta ? meta.bodyAttrs.text() : '',
HEAD,
APP,
ENV: this.options.env
}
// Render with SSR template
// 通过模版和参数 生成html
const html = this.renderTemplate(this.serverContext.resources.ssrTemplate, templateParams)
let preloadFiles
if (this.options.render.http2.push) {
// 获取需要预加载的文件
preloadFiles = this.getPreloadFiles(renderContext)
}
return {
html,
preloadFiles,
}
}
最后在响应的 html 中注入 metadata
即可。
文件系统路由(File System Routing)
想必使用过 Nuxt 的同学应该都对其基于文件生成路由的特性,印象深刻。让我从源码角度看看 Nuxt 是如何实现基于 pages
目录(可配置),自动生成路由的。
首先在启动 Nuxt 项目或者修改文件时,会自动调 generateRoutesAndFiles
方法,生成路由
以及 .nuxt
目录下的文件。
// @nuxt/builder/src/builder.js
async generateRoutesAndFiles() {
...
await Promise.all([
this.resolveLayouts(templateContext),
this.resolveRoutes(templateContext), //解析生成路由,需要关注的重点
this.resolveStore(templateContext),
this.resolveMiddleware(templateContext)
])
...
}
解析路由会存在三种情况:一是修改了默认的 pages
目录名称,且未在 nuxt.config.js 中配置相关目录,二是使用 nuxt 默认的 pages 目录,三是使用调用用户自定义的路由生成方法生成路由。
// @nuxt/builder/src/builder.js
async resolveRoutes({ templateVars }) {
consola.debug('Generating routes...')
if (this._defaultPage) {
// 在srcDir下未找到pages目录
} else if (this._nuxtPage) {
// 使用nuxt动态生成路由
} else {
// 用户提供了自定义方法去生成路由,提供用户自定义路由的能力
}
// router.extendRoutes method
if (typeof this.options.router.extendRoutes === 'function') {
const extendedRoutes = await this.options.router.extendRoutes(
templateVars.router.routes,
resolve
)
if (extendedRoutes !== undefined) {
templateVars.router.routes = extendedRoutes
}
}
}
除此之外,还可以提供相应的 extendRoutes 方法,在 nuxt 生成路由的基础上添加自定义路由。
export default {
router: {
extendRoutes(routes, resolve) {
// 例如添加 404 页面
routes.push({
name: 'custom',
path: '*',
component: resolve(__dirname, 'pages/404.vue')
})
}
}
}
其中当修改了默认的 pages 目录,导致找不到相关的目录,会使用 @nuxt/vue-app/template/pages/index.vue 文件生成路由。
async resolveRoutes({ templateVars }) {
if (this._defaultPage) {
// createRoutes 方法根据传参,生成路由。具体算法,不再展开
templateVars.router.routes = createRoutes({
files: ['index.vue'],
srcDir: this.template.dir + '/pages', // 指向@nuxt/vue-app/template/pages/index.vue
routeNameSplitter, // 路由名称分隔符,默认`-`
trailingSlash // 尾斜杠 /
})
} else if (this._nuxtPage) {
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 {
templateVars.router.routes = await this.options.build.createRoutes(this.options.srcDir)
}
// router.extendRoutes method
if (typeof this.options.router.extendRoutes === 'function') {
const extendedRoutes = await this.options.router.extendRoutes(
templateVars.router.routes,
resolve
)
if (extendedRoutes !== undefined) {
templateVars.router.routes = extendedRoutes
}
}
}
然后就是调用 createRoutes 方法,生成路由。生成的路由大致长这样,和手动书写的路由文件几乎一致(后续还会进行打包????,懒加载
引入路由组件)。
[
{
name: 'index',
path: '/',
chunkName: 'pages/index',
component: 'Users/username/projectName/pages/index.vue'
},
{
name: 'about',
path: '/about',
chunkName: 'pages/about/index',
component: 'Users/username/projectName/pages/about/index.vue'
}
]
智能预取(Smart Prefetching)
从 Nuxt v2.4.0
开始,当 <nuxt-link>
出现在可视区域后,Nuxt将会预取经过code-splitted
的 page 页面的脚本,使得在用户点击之前,该路由指向的地址,就处于 ready 状态,这将极大的提升用户的体验。
相关实现逻辑集中于 .nuxt/components/nuxt-link.client.js
中。
首先 Smart Prefetching
特性的实现依赖于window.InterpObserver
这个实验性的 API,如果浏览器不支持该 API,就不会进行组件预取操作。
mounted () {
if (this.prefetch && !this.noPrefetch) {
this.handleId = requestIdleCallback(this.observe, { timeout: 2e3 })
}
}
然后在需要预取的 <nuxt-link>
组件挂载阶段,会调用 requestIdleCallback
方法在浏览器的空闲时段内调用 observe
方法。
observe () {
// 浏览器不支持window.InterpObserver,不进行预取操作
if (!observer) {
return
}
// 是否是需要预取的组件
if (this.shouldPrefetch()) {
this.$el.__prefetch = this.prefetchLink.bind(this)
observer.observe(this.$el)
this.__observed = true // 已经预取的标志位,提供给后续好进行移除
}
}
在 observe
方法中首先会判断是否需要预取(过滤掉已经预取的,避免重复拉取),然后设置 prefetchLink
方法到 __prefetch
方法之上,调用observer.observe(this.$el)
开始监听当前元素
const observer = window.InterpObserver && new window.InterpObserver((entries) => {
entries.forEach(({ interpRatio, target: link }) => {
// 如果interpRatio 小于等于0,表示目标不在viewport内
if (interpRatio <= 0 || !link.__prefetch) {
return
}
// 进行预取数据(其实就是加载组件)
link.__prefetch()
})
})
当被监听的元素的可视情况发生改变的时候(且出现在视图内时),会触发 new window.InterpObserver(callback)
的回调,执行真正的预取操作prefetchLink
。
prefetchLink () {
// 判断网络环境,离线或者2G环境下,不进行预取操作
if (!this.canPrefetch()) {
return
}
// 停止监听该元素,提高性能
observer.unobserve(this.$el)
const Components = this.getPrefetchComponents()
for (const Component of Components) {
// 及时加载组件,使得用户点击时,该组件是一个就绪的状态
const componentOrPromise = Component()
if (componentOrPromise instanceof Promise) {
componentOrPromise.catch(() => {})
Component.__prefetched = true // 已经预取的标志位
}
}
总结
上文从源码角度介绍了 Nuxt 服务端渲染的实现、服务端数据的获取以及 Nuxt 开箱即用的几个特性:HEAD 管理、基于文件系统的路由和智能预取 code-splitted 的路由。如果希望对 SSR 进行更深入研究,还可以横向学习 React 的 SSR 实现 Next 框架。
希望对您有所帮助,如有纰漏,望请辅正????。
参考
为什么使用服务器端渲染 (SSR)?
Nuxt源码精读
Vue Meta
Introducing Smart prefetching
服务端渲染