引文

最近公司项目中使用了 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
}
// @nuxt/server/src/server.js


在 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)
}
}
// @nuxt/server/src/middleware/nuxt.js


​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)
}
renderSSR (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
}
}
// @nuxt/vue-renderer/src/renderers/ssr.js


而 ​​renderSSR​​ 又会调用 renderer.render 方法,将 url 匹配的路由渲染成字符串,将字符串与模版相结合,得到最终返回给浏览器的html,至此 Nuxt 服务端渲染完成。

最后贴一张盗来的 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


在生成的 ​​.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
}
// .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
}
// @nuxt/vue-app/template/index.js


首先通过Vue插件的形式,注册​​vue-meta​​​,内部会在Vue的原型上挂载$meta属性。然后将​​meta​​添加到服务端渲染上下文中。

async render (renderContext) {
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
// @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
}
}
}
// @nuxt/builder/src/builder.js


除此之外,还可以提供相应的 extendRoutes 方法,在 nuxt 生成路由的基础上添加自定义路由。

export default {
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 }) {


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.IntersectionObserver​​ 这个实验性的 API,如果浏览器不支持该 API,就不会进行组件预取操作。

mounted () {
mounted () {
if (this.prefetch && !this.noPrefetch) {
this.handleId = requestIdleCallback(this.observe, { timeout: 2e3 })
}
}

然后在需要预取的 ​​<nuxt-link>​​​ 组件挂载阶段,会调用 ​​requestIdleCallback​​​ 方法在浏览器的空闲时段内调用 ​​observe​​ 方法。

observe () {


observe () {
// 浏览器不支持window.IntersectionObserver,不进行预取操作
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.IntersectionObserver && new window.IntersectionObserver((entries) => {
entries.forEach(({ intersectionRatio, target: link }) => {
// 如果intersectionRatio 小于等于0,表示目标不在viewport内
if (intersectionRatio <= 0 || !link.__prefetch) {
return
}
// 进行预取数据(其实就是加载组件)
link.__prefetch()
})
})
const observer = window.IntersectionObserver && new window.IntersectionObserver((entries) => {


当被监听的元素的可视情况发生改变的时候(且出现在视图内时),会触发 ​​new window.IntersectionObserver(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 // 已经预取的标志位
}
}
prefetchLink () {


总结

上文从源码角度介绍了 Nuxt 服务端渲染的实现、服务端数据的获取以及 Nuxt 开箱即用的几个特性:HEAD 管理、基于文件系统的路由和智能预取 code-splitted 的路由。如果希望对 SSR 进行更深入研究,还可以横向学习 React 的 SSR 实现 Next 框架。

希望对您有所帮助,如有纰漏,望请辅正????。

参考


  • 为什么使用服务器端渲染 (SSR)?
  • Nuxt源码精读
  • Vue Meta
  • Introducing Smart prefetching
  • 服务端渲染