libin1991 / libin_Blog

爬虫-博客大全
https://libin.netlify.com/
124 stars 17 forks source link

Vue 服务端渲染 SSR #452

Open libin1991 opened 6 years ago

libin1991 commented 6 years ago

前言

因为自己的博客完全的前后端分离写的,在 seo 这一块也没考虑过,于是乎,便开始了本次的SSR之旅

技术栈

vue2 + koa2 + webpack4 + mongodb

因为webpack也已经到了 4.1 的版本了,所以顺带把webpack3迁移到了webpack4

服务端渲染(SSR)

大概意思就是在服务端生成html片段,然后返回给客户端

所以vue-ssr也可以理解为就是把我们以前在客户端写的 .vue文件 转换成 html片段,返回给客户端。

实际上当然是会复杂点,比如服务端 返回 html 片段,客户端直接接受显示,不做任何操作的话,我们是无法触发事件(点击事件等等)的。 为了解决上述问题。 所以 你通过 vue-server-renderer 进行渲染的话, 会在根节点上附带一个 data-server-rendered="true" 的特殊属性。 让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载

**激活模式:**指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。 大概意思就是 服务端 已经渲染好了 html, 只不过服务端渲染过来的是静态页面,无法操作DOM 。 但是因为dom元素已经生成好了, 没有必要丢弃重新创建。 所以客户端便只需要激活这些静态页面,让他们变成动态的(能够响应后续的数据变化)就行。

SSR优势

  • 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
  • 更快的内容到达时间(time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以你的用户将会更快速地看到完整渲染的页面。通常可以产生更好的用户体验,并且对于那些「内容到达时间(time-to-content)与转化率直接相关」的应用程序而言,服务器端渲染(SSR)至关重要。

SSR开发需要注意的问题

  • 服务端渲染只会执行 vue 的两个钩子函数 beforeCreatecreated
  • 服务端渲染无法访问 windowdocument等只有浏览器才有的全局对象。(假如你项目里面有全局引入的插件和JS文件或着在beforeCreatecreated 用到了的这些对象的话,是会报错的,因为服务端不存在这些对象。实在要用的话,可以试下这个插件jsdom

基本上只要你对node有了解,会配置webpackvue能正常使用,基本上这东西实现起来还是比较轻松的,尤其官网给出了完整的例子HackerNews Demo,当然这个是基于express框架的,使用koa的话里面中间件的使用需要做点修改。其余的基本只需要跟着官网的例子来一遍就基本OK了 上面官网的例子需要终端翻墙才能访问数据,如果不想的话可以看下这个例子,跟官网例子基本一样掘金网站

这里也大概说下官网的实现

项目目录

src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── router
│   └── index.js
├── store
│   └── index.js
├── App.vue
├── app.js # universal entry
├── entry-client.js # 运行于客户端的项目入口
└── entry-server.js # 运行于服务端的项目入口

需要用到几个知识点

  • vuex的使用,因为应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。所以会使用的vuex来作为 数据预取存储容器

  • asyncData自定义函数(获取接口数据):

    <template>
      <div>{{ item.title }}</div>
    </template>
    <script>
    export default {
      // 自定义获取数据的函数。
      asyncData ({ store, route }) {
        // 触发 action 后,会返回 Promise
        return store.dispatch('fetchItem', route.params.id)
      },
      computed: {
        // 从 store 的 state 对象中的获取 item。
        item () {
          return this.$store.state.items[this.$route.params.id]
        }
      }
    }
    </script>
    
  • 避免状态单例: 当编写纯客户端(client-only)代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。 所以我们为每个请求创建一个新的根 Vue 实例 因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:

    // router.js
    import Vue from 'vue'
    import Router from 'vue-router'
    Vue.use(Router)
    export function createRouter () {
      return new Router({
        mode: 'history',
        routes: [
          // ...
        ]
      })
    }
    
    // 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)
          }
        }
      })
    }
    
    // app.js
    import Vue from 'vue'
    import App from './App.vue'
    import { createRouter } from './router'
    import { createStore } from './store'
    export function createApp () {
      // 创建 router 和 store 实例
      const router = createRouter()
      const store = createStore()
      // 创建应用程序实例,将 router 和 store 注入
      const app = new Vue({
        router,
        store,
        render: h => h(App)
      })
      // 暴露 app, router 和 store。
      return { app, router, store }
    }
    
    import {createApp} from './app'
    const {app, router, store} = createApp()
    

    按照上面的步骤方法,为每个请求创建新的应用实例,就不会因为多个请求造成 交叉请求状态污染(cross-request state pollution) 了

实现步骤

  1. 首先,获取当前访问的路径,因为renderToString支持传入一个上下文的渲染对象,所以我们传入一个context对象,包含当前的url
```
// server.js 
const context = {
    url: ctx.url
}
renderer.renderToString(context, (err, html) => {
    if (err) {
        return reject(err)
    }
    console.log(html)
})
```
  1. 然后中间经过webpack等配置,能让服务端的项目入口entry-server.js接收到context
```
// entry-server.js
import {createApp} from './app'
export default context => {
    // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise.
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()

        const { url } = context

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

        // 等到 router 将可能的异步组件和钩子函数解析完
        router.onReady(() => {

            // 获取当前路径的组件
            const matchedComponents = router.getMatchedComponents()

            // 没有返回404
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }

            // 如果该路径存在,而且该路径存在需要调用接口来预取数据的情况,便等所有`asyncData`函数执行完毕.
            // `asyncData`函数是组件自定义静态函数, 用来提前获取数据。
            Promise.all(matchedComponents.map( ({asyncData}) => asyncData && asyncData({
                store,
                route: router.currentRoute
            }))).then( () => {
                // 执行完毕后,因为获取到的数据都统一存入 vuex 中, 上方 `asyncData` 里面执行的方法就是调用 vuex 的 action, 然后把数据存入的 vuex 的 state 中
                // 所以我们便 store 里面的 state 赋值给 `context.state`
                // 然后 `renderToString` 解析 html 的时候会把 `context.state` 里面的数据 嵌入到 html 的 `window.__INITIAL_STATE__` 变量中 
                // 这样我们到时候处理 客户端 的时候,便可以把客户端中 vuex 中的state 替换成 `window.__INITIAL_STATE__` 中的数据,来完成客户端与服务端的数据统一
                context.state = store.state
                resolve(app)
            }).catch(reject)
        })
    })
}

```
  1. 上面把我们当前访问路径的组件解析完成返回给客户端,客户端激活这些静态的html,因为我们服务端生成 html 获取数据是通过 asyncData 函数,但是我们只有第一次请求服务端需要渲染,以后再进行页面切换的时候不需要进行渲染的,但是 接口的调用 又放入了 asyncData 函数中,所以页面切换的时候,我们客户都需要处理 asyncData 函数,以前我们一般把数据放入 created 钩子函数中,现在放入的时asyncData里面,所以我们进行客户端切换的时候,需要执行它。获取数据
```
import {createApp} from './app'
const {app, router, store} = createApp()

// 把store中的state 替换成 window.__INITIAL_STATE__ 中的数据
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

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))
        })

        const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
        if (!asyncDataHooks.length) {
            return next()
        }

        // 这里如果有加载指示器(loading indicator),就触发
        Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
            .then(() => {
                // 停止加载指示器(loading indicator)
                next()
            })
            .catch(next)
    })

    // 挂载到根节点上
    app.$mount('#app')
})

```

基本上这样就实现了vue-ssr的过程,具体源码及配置可以在我的 github 查看。

webpack4

最明显的点 是 webpack4 以后拥有默认值了,简单配置一下便能使用 以下是默认值:

  • entry 的默认值是 ./src
  • output.path 的默认值是 ./dist
  • mode 的默认值是 production
  • UglifyJs 插件默认开启 caches 和 parallizes

在 mode 为 develoment 时:

  • 开启 output.pathinfo
  • 关闭 optimization.minimize

在 mode 为 production 时:

  • 关闭 in-memory caching
  • 开启 NoEmitOnErrorsPlugin
  • 开启 ModuleConcatenationPlugin
  • 开启 optimization.minimize

因为给自己博客做ssr的通知也升级了webpack,接下来便看下 迁移至 webpack4 需要修改的部分 webpack 配置

  1. 将CLI移入到 webpack-cli 中,需要安装 webpack-cli

  2. 通过设置 mode 变量来确定当前模式, 不配置会有警告

  • 命令行中配置 webpack --mode development
  • 文件中配置
```
module.exports = {
    mode: 'development',
    entry: {
      app: resolve('src')
    },
    ...
```
  1. webpack.optimize.CommonsChunkPlugin has been removed, please use config.optimization.splitChunks instead webpack4不再提供 webpack.optimize.CommonsChunkPlugin 来分割代码,需要用到新的属性 optimization.splitChunks
```
output: {
  filename: assetsPath('js/[name].[chunkhash].min.js'),
},
optimization: {
  runtimeChunk: {
      name: "manifest"
  },
  splitChunks: {
    chunks: "initial",         // 必须三选一: "initial" | "all"(默认就是all) | "async"
    minSize: 0,                // 最小尺寸,默认0
    minChunks: 1,              // 最小 chunk ,默认1
    maxAsyncRequests: 1,       // 最大异步请求数, 默认1
    maxInitialRequests: 1,    // 最大初始化请求书,默认1
    name: () => {},              // 名称,此选项课接收 function
    cacheGroups: {                 // 这里开始设置缓存的 chunks
      priority: "0",                // 缓存组优先级 false | object |
      vendor: {                   // key 为entry中定义的 入口名称
        chunks: "initial",        // 必须三选一: "initial" | "all" | "async"(默认就是异步)
        test: /react|lodash/,     // 正则规则验证,如果符合就提取 chunk
        name: "vendor",           // 要缓存的 分隔出来的 chunk 名称
        minSize: 0,
        minChunks: 1,
        enforce: true,
        maxAsyncRequests: 1,       // 最大异步请求数, 默认1
        maxInitialRequests: 1,    // 最大初始化请求书,默认1
        reuseExistingChunk: true   // 可设置是否重用该chunk(查看源码没有发现默认值)
      }
    }
  }
},
...
```
  1. compilation.mainTemplate.applyPluginsWaterfall is not a function
解决方案: `yarn add webpack-contrib/html-webpack-plugin -D`
  1. Use Chunks.groupsIterable and filter by instanceof Entrypoint instead:
解决方案: `yarn add extract-text-webpack-plugin@next -D`

升级webpack4也遇到了几个问题

  1. 设置 optimization.splitChunks 打包。分别会打包 jscss 各一份, 不知道啥情况。

  2. 升级4以后,我用 DllPlugin打包, 但是 verdon 打包出来还是一样大,并不会把 我指定的 模块提取出来。

  3. import 做按需加载好像不生效。 例如:const _import_ = file => () => import(file + '.vue'), 然后通过 _import_('components/Foo') 便能直接按需加载, 但是webpack4就没生效,都是一次性加载出来的。

上面是我们升级4遇到的几个问题,可能是我配置方面出错了,但是webpack4 以前都是正常的。 具体我这边的配置放到了 github 上。

总结

以上就是我这次个人博客SSR 之旅。

github

博客地址