聊聊 Nuxt 開箱即用的特性
引文
最近公司項(xiàng)目中使用了 Nuxt 框架,進(jìn)行首屏的服務(wù)端渲染,加快了內(nèi)容的到達(dá)時間 (time-to-content),于是筆者開始了對 Nuxt 的學(xué)習(xí)和使用。以下是從源碼角度對 Nuxt 的一些特性的介紹和分析。
FEATURES
服務(wù)端渲染(SSR)
Vue.js 是構(gòu)建客戶端應(yīng)用程序的框架。默認(rèn)情況下,可以在瀏覽器中輸出 Vue 組件,進(jìn)行生成 DOM 和操作 DOM。然而,也可以將同一個組件渲染為服務(wù)器端的 HTML 字符串,將它們直接發(fā)送到瀏覽器,最后將這些靜態(tài)標(biāo)記"激活"為客戶端上完全可交互的應(yīng)用程序。 ------Vue SSR 指南
官方Vue SSR指南的基本用法章節(jié),給出了 demo 級別的服務(wù)端渲染實(shí)現(xiàn),Nuxt 也是基于該章節(jié)實(shí)現(xiàn)的,大體流程幾乎一致。建議先食用官方指南,再看本文定大有裨益。
Nuxt 作為一個服務(wù)端渲染框架,了解其服務(wù)端渲染的實(shí)現(xiàn)原理必然是重中之重,就讓我們通過相關(guān)源碼,看看其具體實(shí)現(xiàn)吧!
我們通過 nuxt 啟動 Nuxt 項(xiàng)目,其首先會執(zhí)行 startDev 方法,然后調(diào)用_listenDev 方法,獲取 Nuxt 配置,調(diào)用getNuxt方法實(shí)例化 Nuxt。然后執(zhí)行 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 中會執(zhí)行 this.setupMiddleware() ,其中會調(diào)用nuxtMiddleware 中間件(這里是響應(yīng)的關(guān)鍵)。
- // @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) // 渲染相應(yīng)路由,后文會展開
- const {
- html,
- redirected,
- preloadFiles
- } = result // 得到html
- // 設(shè)置頭部字段
- 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') // 做出響應(yīng)
- return html
- } catch (err) {
- if (context && context.redirected) {
- consola.error(err)
- return err
- }
- next(err)
- }
- }
nuxtMiddleware 中間件中首先標(biāo)準(zhǔn)化請求的url,設(shè)置請求狀態(tài)碼,通過url匹配到相應(yīng)的路由,渲染出對應(yīng)的路由組件,設(shè)置頭部信息,最后做出響應(yīng)。
- renderSSR (renderContext) {
- // Call renderToString from the bundleRenderer and generate the HTML (will update the renderContext as well)
- // renderSSR 只是 universal app的渲染方法,Nuxt 也可以進(jìn)行開發(fā)普通的 SPA 項(xiàng)目
- const renderer = renderContext.modern ? this.renderer.modern : this.renderer.SSR
- return renderer.render(renderContext)
- }
其中 renderRoute 方法會調(diào)用 @nuxt/vue-render 的renderSSR 進(jìn)行服務(wù)端渲染操作。
- // @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拼接代碼,后續(xù) HEAD 管理部分會提及
- // Render with SSR template
- const html = this.renderTemplate(this.serverContext.resources.SSRTemplate, templateParams)
- return {
- html,
- preloadFiles
- }
- }
而 renderSSR 又會調(diào)用 renderer.render 方法,將 url 匹配的路由渲染成字符串,將字符串與模版相結(jié)合,得到最終返回給瀏覽器的html,至此 Nuxt 服務(wù)端渲染完成。
最后貼一張盜來的 Nuxt 執(zhí)行流程圖,圖畫的很棒,流程也很清晰,感謝。
數(shù)據(jù)拉取(Data Fetching)
在客戶端程序(CSR)可以通過在 mounted 鉤子中獲取數(shù)據(jù),但在通用程序(Universal)中則需要使用特定的鉤子才能在服務(wù)端獲取數(shù)據(jù)。
Nuxt 中主要提供了兩種鉤子獲取數(shù)據(jù)
- asyncData
- 只可以在頁面級組件中獲取,不可以訪問 this
- 通過返回對象保存數(shù)據(jù)狀態(tài)或與Vuex配合進(jìn)行狀態(tài)保存
- 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)
- // 在匹配的路由中,調(diào)用 asyncData 和 legacy 版本的 fetch方法
- const asyncDatas = await Promise.all(Components.map((Component) => {
- const promises = []
- // 調(diào)用 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)
- }
- // 調(diào)用 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 選項(xiàng)以及 legacy 版 fetch ,存在就依次調(diào)用,獲得 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 // 設(shè)置fetchState 為 false
- }
在服務(wù)端實(shí)例化 vue 實(shí)例之后,執(zhí)行 serverPrefetch,觸發(fā) fetch 選項(xiàng)方法,獲取數(shù)據(jù),數(shù)據(jù)會作用于生成 html的過程。
HEAD 管理(Meta Tags and SEO)
截至目前,Google 和 Bing 可以很好對同步 JavaScript 應(yīng)用程序進(jìn)行索引。但是對于異步獲取數(shù)據(jù)的網(wǎng)站來說,主流的搜索引擎暫時還無法支持,于是造成網(wǎng)站搜索排名靠后,于是希望獲得更好的SEO成為眾多網(wǎng)站考慮使用SSR框架的原因。
為了獲得良好的SEO,那么就需要對HEAD進(jìn)行精細(xì)化的配置和管理。讓我們看看其是如何實(shí)現(xiàn)的吧~
Nuxt框架借助 vue-meta 庫實(shí)現(xiàn)全局、單個頁面的 meta 標(biāo)簽的自定義。Nuxt 內(nèi)部的實(shí)現(xiàn)也幾乎遵循 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,內(nèi)部會在Vue的原型上掛載$meta屬性。然后將meta添加到服務(wù)端渲染上下文中。
- 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 拼接代碼,可以適當(dāng)忽略
- // 了解主要過程即可,具體細(xì)節(jié)按需查看
- 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
- // 通過模版和參數(shù) 生成html
- const html = this.renderTemplate(this.serverContext.resources.SSRTemplate, templateParams)
- let preloadFiles
- if (this.options.render.http2.push) {
- // 獲取需要預(yù)加載的文件
- preloadFiles = this.getPreloadFiles(renderContext)
- }
- return {
- html,
- preloadFiles,
- }
- }
最后在響應(yīng)的 html 中注入 metadata 即可。
文件系統(tǒng)路由(File System Routing)
想必使用過 Nuxt 的同學(xué)應(yīng)該都對其基于文件生成路由的特性,印象深刻。讓我從源碼角度看看 Nuxt 是如何實(shí)現(xiàn)基于 pages 目錄(可配置),自動生成路由的。
首先在啟動 Nuxt 項(xiàng)目或者修改文件時,會自動調(diào) generateRoutesAndFiles 方法,生成路由 以及 .nuxt 目錄下的文件。
- // @nuxt/builder/src/builder.js
- async generateRoutesAndFiles() {
- ...
- await Promise.all([
- this.resolveLayouts(templateContext),
- this.resolveRoutes(templateContext), //解析生成路由,需要關(guān)注的重點(diǎn)
- this.resolveStore(templateContext),
- this.resolveMiddleware(templateContext)
- ])
- ...
- }
解析路由會存在三種情況:一是修改了默認(rèn)的 pages 目錄名稱,且未在 nuxt.config.js 中配置相關(guān)目錄,二是使用 nuxt 默認(rèn)的 pages 目錄,三是使用調(diào)用用戶自定義的路由生成方法生成路由。
- // @nuxt/builder/src/builder.js
- async resolveRoutes({ templateVars }) {
- consola.debug('Generating routes...')
- if (this._defaultPage) {
- // 在srcDir下未找到pages目錄
- } else if (this._nuxtPage) {
- // 使用nuxt動態(tài)生成路由
- } 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
- }
- }
- }
除此之外,還可以提供相應(yīng)的 extendRoutes 方法,在 nuxt 生成路由的基礎(chǔ)上添加自定義路由。
- export default {
- router: {
- extendRoutes(routes, resolve) {
- // 例如添加 404 頁面
- routes.push({
- name: 'custom',
- path: '*',
- component: resolve(__dirname, 'pages/404.vue')
- })
- }
- }
- }
其中當(dāng)修改了默認(rèn)的 pages 目錄,導(dǎo)致找不到相關(guān)的目錄,會使用 @nuxt/vue-app/template/pages/index.vue 文件生成路由。
- async resolveRoutes({ templateVars }) {
- if (this._defaultPage) {
- // createRoutes 方法根據(jù)傳參,生成路由。具體算法,不再展開
- templateVars.router.routes = createRoutes({
- files: ['index.vue'],
- srcDir: this.template.dir + '/pages', // 指向@nuxt/vue-app/template/pages/index.vue
- routeNameSplitter, // 路由名稱分隔符,默認(rèn)`-`
- 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
- }
- }
- }
然后就是調(diào)用 createRoutes 方法,生成路由。生成的路由大致長這樣,和手動書寫的路由文件幾乎一致(后續(xù)還會進(jìn)行打包??,懶加載引入路由組件)。
- [
- {
- 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'
- }
- ]
智能預(yù)取(Smart Prefetching)
從 Nuxt v2.4.0 開始,當(dāng)
相關(guān)實(shí)現(xiàn)邏輯集中于 .nuxt/components/nuxt-link.client.js 中。
首先 Smart Prefetching 特性的實(shí)現(xiàn)依賴于window.IntersectionObserver 這個實(shí)驗(yàn)性的 API,如果瀏覽器不支持該 API,就不會進(jìn)行組件預(yù)取操作。
- mounted () {
- if (this.prefetch && !this.noPrefetch) {
- this.handleId = requestIdleCallback(this.observe, { timeout: 2e3 })
- }
- }
然后在需要預(yù)取的
- const observer = window.IntersectionObserver && new window.IntersectionObserver((entries) => {
- entries.forEach(({ intersectionRatio, target: link }) => {
- // 如果intersectionRatio 小于等于0,表示目標(biāo)不在viewport內(nèi)
- if (intersectionRatio <= 0 || !link.__prefetch) {
- return
- }
- // 進(jìn)行預(yù)取數(shù)據(jù)(其實(shí)就是加載組件)
- link.__prefetch()
- })
- })
當(dāng)被監(jiān)聽的元素的可視情況發(fā)生改變的時候(且出現(xiàn)在視圖內(nèi)時),會觸發(fā) new window.IntersectionObserver(callback) 的回調(diào),執(zhí)行真正的預(yù)取操作prefetchLink。
- prefetchLink () {
- // 判斷網(wǎng)絡(luò)環(huán)境,離線或者2G環(huán)境下,不進(jìn)行預(yù)取操作
- if (!this.canPrefetch()) {
- return
- }
- // 停止監(jiān)聽該元素,提高性能
- observer.unobserve(this.$el)
- const Components = this.getPrefetchComponents()
- for (const Component of Components) {
- // 及時加載組件,使得用戶點(diǎn)擊時,該組件是一個就緒的狀態(tài)
- const componentOrPromise = Component()
- if (componentOrPromise instanceof Promise) {
- componentOrPromise.catch(() => {})
- Component.__prefetched = true // 已經(jīng)預(yù)取的標(biāo)志位
- }
- }
總結(jié)
上文從源碼角度介紹了 Nuxt 服務(wù)端渲染的實(shí)現(xiàn)、服務(wù)端數(shù)據(jù)的獲取以及 Nuxt 開箱即用的幾個特性:HEAD 管理、基于文件系統(tǒng)的路由和智能預(yù)取 code-splitted 的路由。如果希望對 SSR 進(jìn)行更深入研究,還可以橫向?qū)W習(xí) React 的 SSR 實(shí)現(xiàn) Next 框架。
希望對您有所幫助,如有紕漏,望請輔正。
參考
為什么使用服務(wù)器端渲染 (SSR)?
Nuxt源碼精讀
Vue Meta
Introducing Smart prefetching
服務(wù)端渲染