nuxt · 2020-08-05 0

Nuxt –ssr的原理

为什么需要nuxt

  • seo– 由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面
    • 截至目前,Google 和 Bing 可以很好对同步 JavaScript 应用程序进行索引
    • 同步是关键, 如果你的应用程序初始展示 loading 菊花图,然后通过 Ajax 获取内容,抓取工具并不会等待异步完成后再行抓取页面内容
    • 所以: 如果 SEO 对你的站点至关重要,而你的页面又是异步获取内容,则你可能需要服务器端渲染(SSR)解决此问题
  • 更快的到达时间
    • 无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记

分析

环境

  • nodejs 6+
  • vue-server-renderer 和 vue 必须匹配版本
  • vue-server-renderer 依赖一些 Node.js 原生模块,因此只能在 Node.js 中使用
  • 构建指令解析
    • npm run build 以entry-server.js 与entry-client.js为路口文件 webpack打包
    • npm run start:test 以server.js 开启nodejs 服务

未使用模板文件

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello World</div>`
})

// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)
  // => <div data-server-rendered="true">Hello World</div>
})

// 在 2.5.0+,如果没有传入回调函数,则会返回 Promise:
renderer.renderToString(app).then(html => {
  console.log(html)
}).catch(err => {
  console.error(err)
})

express中的使用方式

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })

  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body> //html为当前renderer渲染后返回的html文档
      </html>
    `)
  })
})

server.listen(8080)

使用模板文件替换

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet--> //此注释不可删除,rendererToString的html插入点
  </body>
</html>
  • 使用模板切使用转义
//1.使用转义
<html>
  <head>
    <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
    <title>{{ title }}</title>

    <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
    {{{ meta }}}
  </head>
  <body>
    <!--vue-ssr-outlet--> //html
  </body>
</html>

//2.模板可以是*.vue文件
  • 最终使用方式
const Vue = require('vue');
const server = require('express')();

const template = require('fs').readFileSync('./index.template.html', 'utf-8');

const renderer = require('vue-server-renderer').createRenderer({
  template,  //使用模板文件服务器在renderToString直接返回完整的html文件--模板文件可包括
             //vue的template插入到<!--vue-ssr-outlet-->注释处
             //vue的data插入到对应的双花括号处(参考上图)
});

const context = {
    title: 'vue ssr',
    metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};

server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`,
  });

  renderer
  .renderToString(app, context, (err, html) => { 
   //最终与createRenderer的模板与Vue的app实例渲染后
   //生成html返回给服务器
    console.log(html);
    if (err) {
      res.status(500).end('Internal Server Error')
      return;
    }
    res.end(html);
  });
})

server.listen(8080);

生命周期

  • 由于没有动态更新
  • 只有 beforeCreate 和 created 会在服务器端渲染 (SSR) 过程中被调用
  • 其他生命周期都在客户端执行(mount)
  • 避免在 beforeCreate 和 created 生命周期时产生全局副作用的代码, 如 setInterval

自定义指令— https://ssr.vuejs.org/zh/api/#directives

  • 自定义指令直接操作 DOM,因此会在服务器端渲染 (SSR) 过程中导致错误
  • 推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。 –组件化
  • 如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用 directives 选项所提供”服务器端版本(server-side version)” –服务端版本

服务端避免VUE实例的状态单例

  • Node.js 服务器是一个长期运行的进程
  • 代码进入该进程时,进行一次取值并留存在内存中, 这意味着如果创建一个单例对象,它将在每个传入的请求之间共享
  • 为每个请求创建一个新的根 Vue 实例。这与每个用户在自己的浏览器中使用新应用程序的实例类似

构建步骤

图解

webpack的使用

  • 通常 Vue 应用程序是由 webpack 和 vue-loader 构建,但 例如通过 file-loader 导入文件,通过 css-loader 导入 CSS loader在NODE不全支持
  •  Node.js 最新版本能够完全支持 ES2015 特性,但客户端不支持所以需要编译

项目目录结构–Vue的实例

src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry)  // createAPP() vue实例工厂
   // app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import { sync } from 'vuex-router-sync'

export function createApp () {
  // 创建 router 和 store 实例
  const router = createRouter()
  const store = createStore()

  // 同步路由状态(route state)到 store //在store中创建router的模块做数据同步操作
  sync(store, router)

  // 创建应用程序实例,将 router 和 store 注入
  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })

  // 暴露 app, router 和 store。
  return { app, router, store }
}
├── entry-client.js # 仅运行于浏览器
     import { createApp } from './app'
     const { app, router, store } = createApp()

    if (window.__INITIAL_STATE__) {
   store.replaceState(window.__INITIAL_STATE__)
  }
   //因为路由器必须要提前解析路由配置中的异步组件,
   才能正确地调用组件中可能存在的路由钩子-所以调用onReady
   router.onReady(() => {
  // 添加路由钩子函数,用于处理 asyncData.
  // 在初始路由 resolve 后执行,
  // 以便我们不会二次预取(double-fetch)已有的数据。
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)

    // 我们只关心非预渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })

    if (!activated.length) {
      return next()
    }

    // 这里如果有加载指示器 (loading indicator),就触发

    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {

      // 停止加载指示器(loading indicator)

      next()
    }).catch(next)
  })
       app.$mount('#app')
    })


└── entry-server.js # 仅运行于服务器
     
     // entry-server.js
    import { createApp } from './app'

     export default context => {
    // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
    return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    // 设置服务器端 router 的位置
    router.push(context.url)

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      // 对所有匹配的路由组件调用 `asyncData()`
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 在所有预取钩子(preFetch hook) resolve 后,
        // 我们的 store 现在已经填充入渲染应用程序所需的状态。
        // 当我们将状态附加到上下文,
        // 并且 `template` 选项用于 renderer 时,
        // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.state = store.state
      // Promise 应该 resolve 应用程序实例,以便它可以渲染
      resolve(app)
    }, reject)
  })
}

└── server.js # node服务器启动文件
    // server.js
   const createApp = require('/path/to/built-server-bundle.js') //打包后的entry-server.js

  server.get('*', (req, res) => {
  const context = { url: req.url }

  createApp(context).then(app => {
    renderer.renderToString(app, (err, html) => {
      if (err) {
        if (err.code === 404) {
          res.status(404).end('Page not found')
        } else {
          res.status(500).end('Internal Server Error')
        }
      } else {
        res.end(html)
      }
    })
  })
})

└── store.js # vuex使用
    // store.js
   import Vue from 'vue'
   import Vuex from 'vuex'

   Vue.use(Vuex)

  // 假定我们有一个可以返回 Promise 的
  // 通用 API(请忽略此 API 具体实现细节)
  import { fetchItem } from './api'
 
  export function createStore () {
  return new Vuex.Store({
    state: {
      items: {}
    },
    actions: {
      fetchItem ({ commit }, id) {
        // `store.dispatch()` 会返回 Promise,
        // 以便我们能够知道数据在何时更新
        return fetchItem(id).then(item => {
          commit('setItem', { id, item })
        })
      }
    },
    mutations: {
      setItem (state, { id, item }) {
        Vue.set(state.items, id, item)
      }
    }
  })
}
└── store.js # vuex使用

router的实例-参考

  • createRouter同vue相同生成工厂模式-router采用history模式
  • createApp的Vue工厂实例化绑定router
  • 在entry-server.js中以promise的方式调用createApp,动态router.push请求url,生成router确保服务器在渲染前数据内容都准备好- 并返回resolve(app);

数据预取–参考

  • vuex的使用
  • 我们需要通过访问路由,来决定获取哪部分数据 – 这也决定了哪些组件需要渲染。所以在路由组件中放置数据预取逻辑 (asyncData)
  • 服务器端的数据预取
    • 在entry-server中执行router.onReady,然后获取当前路由匹配的组件(getMatchedComponents),匹配到组件则调用asyncData,而后设置context.state = store.state,没有匹配到组件则404
  • 客户端的数据预取
    • 在路由导航之前解析数据()
      • router.beforeResolve中获取路由匹配的component,后调用asyncData获取所有数据
    • 匹配要渲染的视图后,再获取数据 (用户更快看到页面)
      • 全局注册Vue.mixinbeforeRouteUpdate
      • beforeMount

Store 代码拆分--参考

  • 我们的 Vuex store 可能会分为多个模块
  • 我们可以在路由组件的 asyncData 钩子函数中,使用 store.registerModule 惰性注册(lazy-register)这个模块:
// 在路由组件内
<template>
  <div>{{ fooCount }}</div>
</template>

<script>
// 在这里导入模块,而不是在 `store/index.js` 中
import fooStoreModule from '../store/modules/foo'

export default {
  asyncData ({ store }) {
    store.registerModule('foo', fooStoreModule)
    return store.dispatch('foo/inc')
  },

  // 重要信息:当多次访问路由时,
  // 避免在客户端重复注册模块。
  destroyed () {
    this.$store.unregisterModule('foo')
  },

  computed: {
    fooCount () {
      return this.$store.state.foo.count
    }
  }
}
</script>

客户端激活

  • 由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素
  • 我们需要”激活”这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)
  • 注意如table这些标签,如果没有tbody浏览器会自动添加tbody标签,这会导致 如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染 — 在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配
// 强制使用应用程序的激活模式
app.$mount('#app', true)

参考https://ssr.vuejs.org/zh/#%E4%BB%80%E4%B9%88%E6%98%AF%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%AB%AF%E6%B8%B2%E6%9F%93-ssr-%EF%BC%9F

非框架实现:

https://nuxtjs.org/docs/2.x/internals-glossary/nuxt-render