Vue SSR 基本使用总结(后端基于KOA 2.x)

2019-05-08  本文已影响0人  李牧敲代码

【应用场景】

之前基于Vue做过SPA应用,说实话,SPA一直有2个缺点让我十分不爽。其中一个是SEO,由于ajax 的存在,搜索引擎无法精确抓取你的页面内容。另外一个是首屏的加载确实慢,但是通过各种优化,SPA的首屏加载也还算是能够接受。但是,最近在折腾weex的时候发现它内置的<web>组件不支持展示部分HTML。如果要实现相应的功能可能即要改android端还要改ios端,有这个时间成本,还是用Vue SSR解决来的靠谱。下面来讲解下基于KOA 2.X 的Vue SSR的基本使用。

  1. 新增2个入口文件 enter-client.js 和 entry-server.js
  2. 配置webpack
  3. 完成router 和 store(数据预取)
  4. 基于koa 2.7完成server端基本配置
  5. 启动服务并测试

先看下目录结构(这个目录结构可能还需要优化,后期再更新,本文目的在于先跑通功能。PS:本文所有例子和配置都是手动写的,目的在于用最基本的代码跑通功能,而不是通过各种脚手架)


image

新增2个入口文件 enter-client.js 和 entry-server.js

【entry-server.js】

// entry-server.js
import { createApp } from './app'

export default context => {
    // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
    return new Promise((resolve, reject) => {
        const { app, router, store } = 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 && Component.asyncData) {
                    return Component.asyncData({
                        store,
                        route: router.currentRoute
                    })
                }
            })).then(() => {
                // console.log('store.state', store.state)
                console.log('matchedComponents123', matchedComponents)
                // 在所有预取钩子(preFetch hook) resolve 后,
                // 我们的 store 现在已经填充入渲染应用程序所需的状态。
                // 当我们将状态附加到上下文,
                // 并且 `template` 选项用于 renderer 时,
                // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
                context.state = store.state
                // Promise 应该 resolve 应用程序实例,以便它可以渲染
                resolve(app)
            }).catch(reject)
        }, reject => {
            console.log(reject)
        })
        router.onError((err) => {
            console.log('err', err)
        })
    })
    // return app
}

这个服务端入口文件的目的就是在于触发路由对应的每个模块的asyncData函数,从而完成数据预取!SSR可没有什么异步渲染,先拿到数据再渲染页面。而这个asyncData函数里面做了2件事

  1. 通过http客户端(随便用什么,比如axios之类的)获得接口信息。
  2. 通过vuex将接口获取的数据放到store里,已供页面使用(数据也有了,页面也有了,基本功能不就跑通了嘛~)
    【entery-client.js】
import { createApp } from './app'
const {app, router} = createApp();

//解析完所有路由(包括异步路由)
router.onReady(() => {    
    app.$mount('#app')
})

上面客户端入口文件就做了1件事——完成路由解析后做下挂载,这个#app实现应该在模板文件里写好。
看下模板文件的内容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <!--vue-ssr-outlet-->
    this is template html
    <div id="app"></div>
</body>
</html>

注意上面的注释不能省略,这是告诉vue ssr渲染在哪。

配置webpack

先写个webpack-base-conf.js,最后通过webpack-merge插件合成最终的webpack-server.js和webpack-client.js

//wepback-base-conf.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); //生成html文件
const CleanWebpackPlugin = require('clean-webpack-plugin'); //每次build的时候清空之前的目录
const webpack = require('webpack');
const VueLoaderPlugin = require('vue-loader/lib/plugin');


//把所有路径定位到项目工程根目录下
function resolve(dir) {
    return path.resolve(__dirname, dir);
}

module.exports = {
    devtool: 'source-map',
    mode: 'none',
    entry: {
        main: resolve('../www/entry-server.js'),
    },
    output: {
        path: resolve('dist'),
        filename: '[hash].[name].[id].js'
    },
    resolve: {
        extensions: ['.js', '.vue', '.json'],
        alias: {
            '@': resolve('../www')
        }
    },
    devServer: {
        contentBase: resolve('dist'),
        historyApiFallback: true, //不跳转
        // inline: true, //实时刷新
        hot: true
    },
    //webpack 4 分割代码块的插件
    optimization: {
        splitChunks: {
            chunks: "async",
            minSize: 30000,
            minChunks: 1,
            maxAsyncRequests: 5,
            maxInitialRequests: 3,
            automaticNameDelimiter: '~',
            name: true,
            cacheGroups: {
                vendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 2,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    },
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            },
            {
                test: /\.css$/,
                use: [
                    'vue-style-loader',
                    'css-loader'
                ]
            },
            {
                test: /\.js$/,
                loader: 'babel-loader',
                include: [resolve('../www'), resolve('../node_modules/webpack-dev-server/client')],
                options: {
                    "plugins": [
                        "dynamic-import-webpack"
                    ]
                }
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new webpack.NamedModulesPlugin(),
        new webpack.HotModuleReplacementPlugin(),
        // new HtmlWebpackPlugin({
        //     title: 'Development',
        //     // template: resolve('src/index.html')
        // }),
        new VueLoaderPlugin()
    ]
}

//wepback-client-conf.js
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack-base-conf');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin');

//把所有路径定位到项目工程根目录下
function resolve(dir) {
    return path.resolve(__dirname, dir);
}


module.exports = merge(baseConfig, {
    //server端入口文件
    entry: {
        client: resolve('../www/entry-client.js')
    },
    // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
    // 并且还会在编译 Vue 组件时,
    // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
    // target: 'node',

    devtool: 'source-map',

    output: {
        // libraryTarget: 'commonjs2'
        path: resolve('../www/dist/client')
    },
    optimization: {
        splitChunks: {
            chunks: "async",
            minSize: 30000,
            minChunks: 1,
            maxAsyncRequests: 5,
            maxInitialRequests: 3,
            automaticNameDelimiter: '~',
            name: true,
            cacheGroups: {
                vendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 2,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    },
    // externals: nodeExternals({
    //     // 不要外置化 webpack 需要处理的依赖模块。
    //     // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    //     // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    //     whitelist: /\.css$/
    // }),
    plugins: [
        new HtmlWebpackPlugin({
            template: resolve('../www/index.template.html')
        }),
        // 此插件在输出目录中
        // 生成 `vue-ssr-client-manifest.json`。
        new VueSSRClientPlugin()
    ]
})
//webpack-server-conf.js

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

//把所有路径定位到项目工程根目录下
function resolve(dir) {
    return path.resolve(__dirname, dir);
}

module.exports = merge(baseConfig, {
    //server端入口文件
    entry: {
        server: resolve('../www/entry-server.js'),
    },
    // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
    // 并且还会在编译 Vue 组件时,
    // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
    target: 'node',

    devtool: 'source-map',

    output: {
        libraryTarget: 'commonjs2',
        path: resolve('../www/dist/client/')
    },

    externals: nodeExternals({
        // 不要外置化 webpack 需要处理的依赖模块。
        // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
        // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
        whitelist: /\.css$/
    }),

    // 这是将服务器的整个输出
    // 构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    plugins: [
        // new HtmlWebpackPlugin({
        //     template: resolve('../www/index.template.html'),
        //     filename: 'index.template.html',
        //     files: {
        //         js: 'client.bundle.js'
        //     },
        //     excludeChunks: ['server']
        // }),
        new VueSSRServerPlugin()
    ]
})

完成router 和 store(数据预取)

看下app.js

//www/app.js
import Vue from 'vue';
import App from './App.vue';
import { createRouter } from './routes/index.js'
import { createStore } from './store/index.js'
import {sync} from 'vuex-router-sync'

export function createApp() {
    const router = createRouter();
    const store = createStore();

    // 同步路由状态(route state)到 store
    sync(store, router);
    
    // console.log('router', router)
    const app = new Vue({
        router: router,
        store: store,
        render: h=> h(App)
    })
    return {
        app,
        router,
        store
    }
}

这www/app.js使用来返回v-router和vuex的store的
看下对应的路由文件和store文件

//www/routes/index.js
import Vue from 'vue';
import Router from 'vue-router';
// import App from '../App.vue'
Vue.use(Router);


export function createRouter() {
    return new Router({
        mode: 'history',
        routes: [
            {
                path: '/p/:id',
                name: 'article',
                component: () => import('../components/article.vue')
                // component: App
            },
            {
                path: '/u',
                name: 'user',
                component: () => import('../components/user.vue'),
                children: [
                    {
                        path: 'admin',
                        name: 'admin',
                        component: () => import('../components/admin.vue')
                        // component: App
                    }
                ]
            },            
        ]
    })

//www/store/index.js
import Vuex from 'vuex';
import Vue from 'vue';

Vue.use(Vuex)

export function createStore() {
    return new Vuex.Store({
        state: {
    
        },
        getters: {
    
        },
        mutations: {
    
        },
        actions: {
            
        }
    })
}

//store子模块article
export default {
    namespaced: true,
    state() {
        return {
            name: 'wcx'
        }
    },
    getters() {
        return {

        }
    },
    mutations() {
        return {
            changeSyncName(state, params) {
                state.name = params;
            }
        }
    },
    actions() {
        return {
            changeAsyncName(context, params) {
                context.commit('changeSyncName', params)
            }
        }
    }
}

这上面都是vuex的基本操作,不会的话可以看vuex官网

再看下App.vue

<template>
    <div id="app">
        this is App.vue12346666sssss111
        <!-- <router-link to="/p/123">go</router-link> -->
        <a href="/p/123" target="view_window">article.vue</a>
        <a href="/u" target="view_window">user.vue</a>
        <router-view></router-view>
    </div>
</template>
<script>
export default {
  asyncData ({ store, route }) {
    // 触发 action 后,会返回 Promise
    // return store.dispatch('fetchItem', route.params.id)
    let promise = new Promise((resolve, reject) => {
        resolve(123);
    })
  },
  computed: {
    // 从 store 的 state 对象中的获取 item。
    item () {
    //   return this.$store.state.items[this.$route.params.id]
    }
  },
  methods: {

  },   
}
</script>

article.vue

<template>
    <div>
        this is b.vue
        {{$store.state.article.name}}
    </div>
</template>
<script>
import articleStore from '../store/article.js'

export default {
  asyncData ({ store, route }) {
      store.registerModule('article', articleStore);
      console.log('route', route)
    // 触发 action 后,会返回 Promise
    return store.dispatch('article/changeAsyncName', route.params.id)
    // let promise = new Promise((resolve, reject) => {
    //     this.name = 'test'
    //     resolve(123);
    // })
  },
  data() {
    return {
        store123: this.$store,
        route123: this.$route,
        name:''
    }
  },
  created() {
    //   console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__)
  },
  computed: {
    // 从 store 的 state 对象中的获取 item。
    item () {
    //   return this.$store.state.items[this.$route.params.id]
    }
  }    
}
</script>

admin.vue

<template>
    <div>
        this is admin.vue    
        <router-view to="/u/admin"></router-view>
    </div>
</template>
<script>
import articleStore from '../store/article.js'

export default {
  asyncData ({ store, route }) {
    //   store.registerModule('article', articleStore);
    //   console.log('route', route)
    // // 触发 action 后,会返回 Promise
    // return store.dispatch('article/changeAsyncName', 'admin')
    let promise = new Promise((resolve, reject) => {
        this.name = 'test'
        resolve(123);
    })
  },
  data() {
    return {

    }
  },
  created() {
    //   console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__)
  },
  computed: {
    // 从 store 的 state 对象中的获取 item。
    item () {
    //   return this.$store.state.items[this.$route.params.id]
    }
  }    
}
</script>

user.vue

<template>
    <div>
        this is user.vue
        <router-link to="/u/admin">go to admin</router-link>
        <router-view></router-view>
    </div>
</template>
<script>
import articleStore from '../store/article.js'

export default {
  asyncData ({ store, route }) {
    //   store.registerModule('article', articleStore);
    //   console.log('route', route)
    // // 触发 action 后,会返回 Promise
    // return store.dispatch('article/changeAsyncName', 123)
    let promise = new Promise((resolve, reject) => {
        this.name = 'test'
        resolve(123);
    })
  },
  data() {
    return {

    }
  },
  created() {
    //   console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__)
  },
  computed: {
    // 从 store 的 state 对象中的获取 item。
    item () {
    //   return this.$store.state.items[this.$route.params.id]
    }
  }    
}
</script>

基于koa 2.7完成server端基本配置


const path = require('path');
const Koa = require('koa');
const logger = require('koa-logger')
const router = require('./server/routes/index.js')//后端路由文件
const staticify = require('koa-static');
const home = staticify(path.resolve(__dirname, './www/dist/client'))

console.log(path.resolve(__dirname, './www/dist/client'))
// const webserve = require('koa-static');
// const home = webserve(path.resolve(__dirname, './www'));

let app = new Koa();
app.use(logger())
    .use(home)

app.use(router.routes())
    .use(router.allowedMethods())
    .listen(8088, (ctx) => {
        console.log(`server is runnning at 8088`)
    });

后端路由文件

const Router = require('koa-router');
const router = new Router();
const Web = require('../controllers/Web')


router.get('*', Web.createHtml);
module.exports = router;

看下后端路由处理逻辑

//wwww/controllers/Web.js
const { renderer, createBundleRenderer } = require('vue-server-renderer');
const Vue = require('vue');
const fs = require('fs');


class Web {
    static async createHtml(ctx, next) {
        const app = new Vue({
            data() {
                return {
                    ctx: ctx,
                    name: 'wcx2018',
                    age: 13
                }
            },
            template: `<div>hello you are visiting at {{ctx.url}} name: {{name}} age: {{age}}</div>`
        });
        //上下文
        const context = {
            url: ctx.url
        }
        const serverBundle = require('../../www/dist/client/vue-ssr-server-bundle.json')
        const clientManifest = require('../../www/dist/client/vue-ssr-client-manifest.json')
        //未传模板的写法
        // renderer.createRenderer().renderToString(app, (err, html) => {
        //     if (err) {
        //         ctx.throw(500).end(err)
        //     } else {
        //         let ssrHtml = `
        //         <!DOCTYPE html>
        //         <html lang="en">
        //           <head><title>Hello</title></head>
        //           <body>${html}</body>
        //         </html>            
        //         `
        //         ctx.body = ssrHtml
        //     }
        // })
        const renderer = createBundleRenderer(serverBundle, {
            // runInNewContext: false, // 推荐
            template: fs.readFileSync('./www/index.template.html', 'utf-8'),
            clientManifest
        })
        // const a = await renderer.renderToString((err, html) => {
        //     if(err) {
        //         if(err.code === 404) {
        //             ctx.throw(404).end(err)
        //         }else {
        //             ctx.throw(500).end(err)
        //         }
        //         console.log(err)
        //     } else {
        //         ctx.body=321
        //         next()
        //         // ctx.body = html
        //     }
        // })
        const html = await renderer.renderToString(context)
        ctx.body = html
    }
}

module.exports = Web;

看下package.json

{
  "name": "vue_ssr",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "ssrbuild": "webpack --config ./build/webpack-server-conf.js",
    "csrbuild": "webpack --config ./build/webpack-client-conf.js",
    "ssrdev": "webpack-dev-server --config ./build/webpack-server-conf.js ",
    "csrdev": "webpack-dev-server ---config ./build/webpack-client-conf.js "
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.4.4",
    "@babel/plugin-syntax-dynamic-import": "^7.2.0",
    "@babel/plugin-syntax-jsx": "^7.2.0",
    "@babel/plugin-transform-runtime": "^7.4.4",
    "@babel/preset-env": "^7.4.4",
    "babel-helper-vue-jsx-merge-props": "^2.0.3",
    "babel-loader": "^8.0.5",
    "babel-plugin-dynamic-import-node": "^2.2.0",
    "babel-plugin-dynamic-import-webpack": "^1.1.0",
    "babel-plugin-syntax-jsx": "^6.18.0",
    "babel-plugin-transform-vue-jsx": "^3.7.0",
    "babel-preset-env": "^1.7.0",
    "clean-webpack-plugin": "^2.0.1",
    "css-loader": "^2.1.1",
    "html-webpack-plugin": "^3.2.0",
    "koa": "^2.7.0",
    "koa-logger": "^3.2.0",
    "koa-router": "^7.4.0",
    "koa-static": "^5.0.0",
    "vue": "^2.6.10",
    "vue-loader": "^15.7.0",
    "vue-router": "^3.0.6",
    "vue-server-renderer": "^2.6.10",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "vuex": "^3.1.0",
    "vuex-router-sync": "^5.0.0",
    "webpack": "^4.30.0",
    "webpack-cli": "^3.3.1",
    "webpack-merge": "^4.2.1",
    "webpack-node-externals": "^1.7.2"
  },
  "dependencies": {
    "@babel/runtime": "^7.4.4",
    "webpack-dev-server": "^3.3.1"
  }
}

先执行下npm run ssrbuild,把www/dist/client下的vue-ssr-server-bundle.json复制出来,然后再执行 npm run csrbuild,把刚才的vue-ssr-server-bundle.json再复制进www/dist/client目录。
最后再在根目录下执行node app.js
看下www/dist/client目录下的文件和最终效果:

image
image
上一篇下一篇

猜你喜欢

热点阅读