Vue同构

Vue同构

本文Demo取自笔者个人博客,主要是对笔者博客系统的重构,记录心得。

什么是同构?

SPA

single page application,单页面应用。当用户第一次访问网页时,服务器会下发一个html文件,当用户再次刷新或者进行页面间跳转的时候,并不会再次请求html,而是通过刷新清除,动态切换到其他页面组件。
原理:vue-router底层会对路由变化进行监听,无论你使用的是hash模式,还是history模式:

vue-router源码:src/history/hash.js

window.addEventListener(
      supportsPushState ? 'popstate' : 'hashchange',
      () => {
        const current = this.current
        if (!ensureSlash()) {
          return
        }
        this.transitionTo(getHash(), (route) => {
          if (supportsScroll) {
            handleScroll(this.router, route, current, true)
          }
          if (!supportsPushState) {
            replaceHash(route.fullPath)
          }
        })
      }
    )

html5推出之前,vue只支持hash模式,只能通过通过onhashchange事件来监听url的变化,从而作出响应。所以我们常见的URL是这种:https://xxx.com/#/
html5推出之后,新增的pushStatereplaceStatepopstate三个API让vue彻底告别上述那种难看的URL。
下面我们来看一下这三个API在Vue的使用:

vue-router源码:src/history/html5.js

window.addEventListener('popstate', e => {
      const current = this.current
      const location = getLocation(this.base)
      if (this.current === START && location === initLocation) {
        return
      }

      this.transitionTo(location, route => {
        if (supportsScroll) {
          handleScroll(router, route, current, true)
        }
      })
 })

首先,window会监听popstate事件,在回调中获取location,然后执行transitionTo函数,我们去找transitionTo函数,可是在该文件中没有直接声明transitionTo函数,但是HTML5History却是继承了 类Hisotry,所以接下来就要在History类中寻找transitionTo函数:

vue-router源码:src/history/base.js

transitionTo (
    location: RawLocation,
    onComplete?: Function,
    onAbort?: Function
  ) {
    const route = this.router.match(location, this.current)
    this.confirmTransition(
      route,
      () => {
        //更新route
        this.updateRoute(route)
        //执行回调
        onComplete && onComplete(route)
        this.ensureURL()

        // fire ready cbs once
        if (!this.ready) {
          this.ready = true
          this.readyCbs.forEach(cb => {
            cb(route)
          })
        }
      },
      err => {
        if (onAbort) {
          onAbort(err)
        }
        if (err && !this.ready) {
          this.ready = true
          this.readyErrorCbs.forEach(cb => {
            cb(err)
          })
        }
      }
    )
  }

在上述源码中我们可以清楚的看到,transitionTo函数会首先获取匹配的路由const route = this.router.match(location, this.current),然后将route传入confirmTransition函数,执行confirmTransition函数。
在这个函数中,程序会首先判断当前路由是否和route是完全相同的,如果完全相同,那么就会取消路由更新,不做任何处理

if (isSameRoute(route, current) && route.matched.length === current.matched.length) {
  this.ensureURL()
  return abort(new NavigationDuplicated(route))
}

然后回到transitionTo函数,当执行回调的时候,首先会执行updateRoute函数

updateRoute (route: Route) {
    const prev = this.current
    this.current = route
    this.cb && this.cb(route)
    this.router.afterHooks.forEach(hook => {
      hook && hook(route, prev)
    })
  }

hook:

hook(route, current, (to: any) => {
  if (to === false || isError(to)) {
    // next(false) -> abort navigation, ensure current URL
    this.ensureURL(true)
    abort(to)
  } else if (
    typeof to === 'string' ||
    (typeof to === 'object' &&
     (typeof to.path === 'string' || typeof to.name === 'string'))
  ) {
    // next('/') or next({ path: '/' }) -> redirect
    abort()
    if (typeof to === 'object' && to.replace) {
      this.replace(to)
    } else {
      this.push(to)
    }
  } else {
    // confirm transition and pass on the value
    next(to)
  }
})

在该函数中,会对route和当前路由进行一系列的判断操作,在满足一定条件下,会执行this.replace()、 this.push()函数。

那么让我们接着去寻找这几个函数的实现:

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      pushState(cleanPath(this.base + route.fullPath))
      handleScroll(this.router, route, fromRoute, false)
      onComplete && onComplete(route)
    }, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      replaceState(cleanPath(this.base + route.fullPath))
      handleScroll(this.router, route, fromRoute, false)
      onComplete && onComplete(route)
    }, onAbort)
  }

  ensureURL (push?: boolean) {
    if (getLocation(this.base) !== this.current.fullPath) {
      const current = cleanPath(this.base + this.current.fullPath)
      push ? pushState(current) : replaceState(current)
    }
  }

vue-router:src/history/push-state.js

export function pushState (url?: string, replace?: boolean) {
  saveScrollPosition()
  // try...catch the pushState call to get around Safari
  // DOM Exception 18 where it limits to 100 pushState calls
  const history = window.history
  try {
    if (replace) {
      // preserve existing history state as it could be overriden by the user
      const stateCopy = extend({}, history.state)
      stateCopy.key = getStateKey()
      history.replaceState(stateCopy, '', url)
    } else {
      history.pushState({ key: setStateKey(genStateKey()) }, '', url)
    }
  } catch (e) {
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

export function replaceState (url?: string) {
  pushState(url, true)
}

从代码中我们可以看到,其实无论是pushstate还是replacestate,其内部实现都是基于history的,都是通过history.pushState()或者history.replaceState()实现的。

综上,我们已将URL变化到Vue-router路由变化的过程理一遍,这也是vue-router的实现原理。

回到SPA的话题

SPA的页面跳转是通过vue-router监测路由变化,使用js渲染完成的。
优点:渲染快、无须向服务器请求html,减轻服务器压力
缺点:首屏加载慢、SEO差
优化方案:使用预渲染(prerender-spa-plugin)、骨架屏(vue-skeleton-webpack-pluginvue-server-renderer

MPA

multi page application 多页面应用。
每一次页面跳转的时候,后台服务器都会返回一个新的html文档,这种类型的网站也就是多页网站,也叫多页应用。
优点:SEO友好
缺点:每次跳转都需要向服务器请求html、服务器压力增大

小结:

SPA MPA
应用构成 单个HTML和各个组件组成 多个HTML页面组成
跳转方式 动态切换页面组件 一个HTML跳到另一个HTML
资源加载 切页无需加载公共资源 切页需要重新加载公共资源
URL http://xxx/shell.html#page1 http://xxx/page2.html
能否实现动画 可以 不可以
页面传参 页面传递数据容易(VuexVue中的父子组件通讯props对象) 依赖URLcookie或者localstorage,实现麻烦
SEO 需要单独的方案做处理 无需单独处理
使用范围 对体验要求高,特别是移动应用 需要对搜索引擎友好的网站
用户体验 页面片段间切换快,用户体验好,包括移动设备 页面间切换加载慢,不流畅,用户体验差,尤其在移动端

纵观SPA和MPA,各有千秋,如何在其中作出选择,着实让人为难。如果可以综合两者的优点就好了。

千呼万唤始出来 犹抱琵琶半遮面

同构应用(SSR + CSR)

终极大招来了,你准备好了吗?
Vue同构 ,综合SPA和MPA的优点,实现刷新SSR、切页CSR。
SSR:server side render
CSR:client side render

实现

这里先放上Vue SSR官方链接。

对SPA的改造

修改入口文件

在浏览器端渲染中,入口文件是main.js,而到了服务器端渲染,除了基础的main.js,还需要配置entry-client.js(客户端入口文件)和entry-server.js(服务端入口文件)。
mian.js

import Vue from "vue";
import App from "./App";
//引入router构造函数
import { createRouter } from "./router";
//引入store构造函数
import { createStore } from "./store";
// import "./assets/css/reset.css"; 去除全局样式 使用cnd 或则 在app.vue中引入
import "./utils/extend";

Vue.config.productionTip = false;

//去除传统Vue实例化方案
// new Vue({
//   el: '#app',
//   router,
//   components: { App },
//   template: '<App/>'
// })

export function createApp() {
    const router = createRouter();
    const store = createStore();
    const app = new Vue({
        router,
        store,
        render: h => h(App)
    });
    return { app, router, store };
}

在main.js中,我们使用createApp(),返回一个Vue的实例,取消传统的Vue实例化方案。主要是因为对应SSR应用,程序运行咋服务端,每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染 (cross-request state pollution)。

新增entry-client.js
import { createApp } from './main.js'

const { app,router,store } = createApp();

router.onReady(() => {
    app.$mount('#app')
})
新增entry-server.js
import { createApp } from './main';

export default context => {
    return new Promise((resolve,reject) => {
        const { app,router,store } = createApp();
        router.push(context.url);
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            if(!matchedComponents){
                return reject({code:404})
            }
            return Promise.all(matchedComponents.map(component => {
                if(component.asyncData && typeof component.asyncData == 'function'){
                    return component.asyncData({
                        store
                    })
                }
            })).then(() => {
                context.state = store.state;
                resolve(app)
            }).catch(reject)
        },reject)
    })
}
router.js

给每个请求一个新的路由router实例

import Vue from "vue";
import Router from "vue-router";

Vue.use(Router);

export function createRouter() {
    return new Router({
        mode: "history",
        scrollBehavior(to, from, savedPosition) {
            if (savedPosition) {
                return savedPosition;
            } else {
                return {
                    x: 0,
                    y: 0
                };
            }
        },
        routes: [
            {
                path: "/",
                name: "index",
                component: () => import('../views/index.vue'),
                meta: { keepAlive: true }
            },
            {
                path: "/detail/:id",
                name: "detail",
                component: () => import('../views/articleDetail.vue'),
                meta: { keepAlive: false }
            }
        ]
    });
}
store.js

给每个请求一个新的store实例

import Vue from "vue";
import Vuex from "vuex";
import { post } from "../http";

Vue.use(Vuex);

function fetchData() {
    return new Promise((resolve, reject) => {
        // resolve(['bar ajax 返回数据']);
        post("/findBlogInfo", {}).then(res => {
            if (res.data.code == 200) {
                resolve(res.data.data);
            }
        });
    });
}


const isClient = (typeof window !== 'undefined') ? true : false
//如果window.__INITIAL_STATE__ 存在 即将window.__INITIAL_STATE__复制给state
const state = isClient ? (window.__INITIAL_STATE__ || {}) : {
    list: [],
    detail:{}
}
const actions = {
    getData({ commit }, payload) {
        return fetchData().then(res => {
            commit("setData", { res });
        });
    }
}
const mutations = {
    setData(state, { res }) {
        // state.list = res;
        Vue.set(state, 'list', res);
    }
}

export function createStore() {
    return new Vuex.Store({
        state,
        actions,
        mutations
    });
}
使用asyncData获取异步数据

index.vue

 asyncData({ store }) {
   return store.dispatch("getData");
 },
  computed: {
    articleList() {
      return this.$store.state.list;
    }
  },

server.js

const Koa = require("koa");
const fs = require("fs");
const serverStatic = require('koa-static');
const path = require("path");
const { createBundleRenderer, createRenderer } = require("vue-server-renderer");

const app = new Koa();
app.use(serverStatic(path.join(__dirname)+'/dist/'))
const resolve = file => path.resolve(__dirname, file);

// 生成服务端渲染函数
const renderer = createBundleRenderer(
    require("./dist/vue-ssr-server-bundle.json"),
    {
        // 模板html文件
        template: fs.readFileSync(resolve("./index.html"), "utf-8"),
        // client manifest
        clientManifest: require("./dist/vue-ssr-client-manifest.json")
    }
);

function renderToString(context) {
    return new Promise((resolve, reject) => {
        renderer.renderToString(context, (err, html) => {
            err ? reject(err) : resolve(html);
        });
    });
}

app.use(async (ctx, next) => {
    try {
        const context = {
            title: "服务端渲染测试",
            url: ctx.req.url 
        };
        ctx.status = 200
        // console.log("请求过来了:",ctx.req.url);
        const render = await renderToString(context);
        ctx.body = render;
    } catch (e) {
        console.log(e,'error');
        // 如果没找到,放过请求,继续运行后面的中间件
        next();
    }
});

app.listen(3001, () => {
    console.log("server is running at http://localhost:3001");
});

修改index.html

修改模版文件

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum=1.0,user-scalable=0">
    <meta name="description" content="个人博客" />
    <meta name="keywords" content="前端、个人博客、CSS、JAVASCRIPT、MYSQL" />
    <link href="https://cdn.bootcss.com/minireset.css/0.0.2/minireset.min.css" rel="stylesheet">
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css"
        integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">

    <!-- 最新的 Bootstrap 核心 JavaScript 文件 -->
    <script defer src="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/js/bootstrap.min.js"
        integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
        crossorigin="anonymous"></script>
    <script src="https://cdn.bootcss.com/marked/0.8.2/marked.js"></script>

    <title>{{ title }}</title>
</head>

<body>
    <!--vue-ssr-outlet-->
</body>

</html>

其中最主要的就是<!--vue-ssr-outlet-->,原因后面会讲到。

修改webpack配置

webpack.base.conf.js
修改入口文件

entry: {
    //app:'./src/main.js'
    app: './src/entry-client.js'
}

webpack.prod.conf.js
服务端渲染不需要html-webpack-plugin,引入vue-server-renderer/client-plugin,生成vue-ssr-client-manifest.json,为服务器下发html时静态注入资源文件

const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
module.exports = {
    plugins: [
         // new HtmlWebpackPlugin({
    //   filename: process.env.NODE_ENV === 'testing'
    //     ? 'index.html'
    //     : config.build.index,
    //   template: 'index.html',
    //   inject: true,
    //   minify: {
    //     removeComments: true,
    //     collapseWhitespace: true,
    //     removeAttributeQuotes: true
    //     // more options:
    //     // https://github.com/kangax/html-minifier#options-quick-reference
    //   },
    //   // necessary to consistently work with multiple chunks via CommonsChunkPlugin
    //   chunksSortMode: 'dependency'
    // }),
        new VueSSRClientPlugin()
    ]
}

新增webpack.server.js

处理服务端打包

const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.conf.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const path = require('path');

module.exports = merge(baseConfig, {
  // 将 entry 指向应用程序的 server entry 文件
  entry: path.join(__dirname,'..','src/entry-server.js'),

  // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
  // 并且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',

  // 对 bundle renderer 提供 source map 支持
  devtool: 'source-map',

  // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },

  // https://webpack.js.org/configuration/externals/#function
  // https://github.com/liady/webpack-node-externals
  // 外置化应用程序依赖模块。可以使服务器构建速度更快,
  // 并生成较小的 bundle 文件。
  externals: nodeExternals({
    // 不要外置化 webpack 需要处理的依赖模块。
    // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    whitelist: /\.css$/
  }),

  // 这是将服务器的整个输出
  // 构建为单个 JSON 文件的插件。
  // 默认文件名为 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin()
  ]
})

修改package.json

"scripts": {
    "client:prod": "scripty",
    "server:prod": "scripty",
    "build": "rm -rf dist && npm run client:prod && npm run server:prod",
    "server": "nodemon server.js "
  },

借用插件scripty优化script命令:根目录下新建script文件夹、新建子目录client、server对应客户端打包命令和服务端打包命令

client / prod.sh: node build/build.js
server / prod.sh: cross-env NODE_ENV=production webpack --config build/webpack.server.conf.js

本地打包测试

npm run build

npm run server

然后我们直接查看网页源码:
image.png
如果结果如上图所示,即证明我们的服务端渲染已经完成了。
切换页面,查看network,如果没有重新请求html,即证明已经实现切页CSR。

pm2 进程守护

因为我们的程序是运行在服务端的,为避免程序突然down掉,我们需要使用pm2进行进程守护。具体使用请参考pm2官方文档

服务器部署

将我们打包好的dist文件夹、package.json、index.html、server.js上传到我们的服务器。然后执行一下命令:

npm install

npm  run server 

配置nginx

使用nginx进行代理,实现外网访问。
nginx路径:/usr/local/nginx
配置nginx.conf

server {
    listen 80;
  server_name : xxx;//你的服务器域名 ip
  location / {
      proxy_set_header X-Real-IP  $remote_addr;
    proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
    proxy_set_header   Host   $host;
    proxy_pass  http://127.0.0.1:3001/;//对应上面server.js运行的端口
    proxy_redirect     off;
    proxy_set_header X-Nginx-Proxy true;
  }
}

优化方向

容灾

已知我们的程序运行在node中,那么我们就要做好容灾的准备,避免大流量的涌入导致node程序崩溃。
用户访问量增大,就会导致服务器压力增大,这时候我们就需要考虑降级处理,让部分用户的流量导向CSR,不再进行服务端渲染,避免服务器压力持续增大。 这一点可能需要nginx的配置。

热更新

做前端的我们都清楚,前端程序更新速度是很快的,有可能每一个月都会更新一次,而node运行在服务端的程序是不会那么频繁的更新的,如何处理node端程序和前端代码分开更新,这也是一个要优化的点。

以上两点,笔者因时间问题,暂未进行实践,后期会持续实践更新。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!