美文网首页
Vue SSR 基本使用总结(后端基于KOA 2.x)

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

作者: 李牧敲代码 | 来源:发表于2019-05-08 10:57 被阅读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

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/dfxyoqtx.html