美文网首页
vue的两种服务器端渲染方案

vue的两种服务器端渲染方案

作者: 京东云开发者 | 来源:发表于2023-02-26 10:48 被阅读0次

    作者:京东零售 姜欣

    关于服务器端渲染方案,之前只接触了基于react的Next.js,最近业务开发vue用的比较多,所以调研了一下vue的服务器端渲染方案。 首先:长文预警,下文包括了两种方案的实践,没有耐心的小伙伴可以直接跳到方案标题下,down代码体验一下。

    前置知识:

    1、什么是服务器端渲染(ssr)?

    简单来说就是用户第一次请求页面时,页面上的内容是通过服务器端渲染生成的,浏览器直接显示服务端返回的完整html就可以,加快首屏显示速度。

    举个栗子:

    当我们访问一个商品列表时,如果使用客户端渲染(csr),浏览器会加载空白的页面,然后下载js文件,通过js在客户端请求数据并渲染页面。如果使用服务器端渲染(ssr),在请求商品列表页面时,服务器会获取所需数据并将渲染后的HTML发送给浏览器,浏览器一步到位直接展示,而不用等待数据加载和渲染,提高用户的首屏体验。

    2、服务器端渲染的优缺点

    优点:

    (1)更好的seo:抓取工具可以直接查看完全渲染的页面。现在比较常用的交互是页面初始展示 loading 菊花图,然后通过异步请求获取内容,但是但抓取工具并不会等待异步完成后再行抓取页面内容。

    (2)内容到达更快:不用等待所有的 js 都完成下载并执行,所以用户会更快速地看到完整渲染的页面。

    缺点:

    (1)服务器渲染应用程序,需要处于 Node.js server 运行环境

    (2)开发成本比较高

    总结:

    总得来说,决定是否使用服务器端渲染,取决于具体的业务场景和需求。对于具有大量静态内容的简单页面,客户端渲染更合适一些,因为它可以更快地加载页面。但是对于需要从服务器动态加载数据的复杂页面,服务器端渲染可能是一个更好的选择,因为他可以提高用户的首屏体验和搜索引擎优化。

    下面进入正文

    方案一:vue插件vue-server-render

    git 示例demo地址

    结论前置:不建议用,配置成本高

    官网地址: https://v2.ssr.vuejs.org/zh/

    首先要吐槽一下官网,按官网教程比较难搞,目录安排的不太合理,一顿操作项目都没起来...

    并且官网示例的构建配置代码是webpack4的,现在初始化项目后基本安装的都是webpack5,有一些语法不同

    (1)首先,先初始化一个npm项目,然后安装依赖得到一个基础项目 。(此处要注意vue-server-renderer 和 vue 必须匹配版本)

    npm init -y
    yarn add vue vue-server-renderer -S
    yarn add express -S
    yarn add webpack webpack-cli friendly-errors-webpack-plugin vue-loader babel-loader @babel/core url-loader file-loader vue-style-loader css-loader sass-loader sass webpack-merge webpack-node-externals -D
    yarn add clean-webpack-plugin @babel/preset-env -D
    yarn add rimraf // 模拟linx的删除命令,在build时先删除dist
    yarn add webpack-dev-middleware webpack-hot-middleware -D
    yarn add chokidar  -D //监听变化
    yarn add memory-fs -D
    yarn add nodemon -D
    ...实在太多,如有缺失可以在package.json中查找
    另外:我现在用的"vue-loader": "^15.9.0"版本,之前用的是"vue-loader": "^17.0.1",报了一个styles的错
    

    (2)配置app.js,entry-client.js,entry-server.js,将官网参考中的示例代码(传送门: 构建配置 )拷贝至对应文件。

    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'
    
    // 导出一个工厂函数,用于创建新的
    // 应用程序、router 和 store 实例
    export function createApp () {
        // 创建 router 和 store 实例
        const router = createRouter()
        const store = createStore()
        
        sync(store, router)
    
        const app = new Vue({
            router,
            store,
            render: h => h(App)
        })
    
        return { app, router, store }
    }
    

    entry-client.js

    import Vue from 'vue'
    import { createApp } from './app'
    
    Vue.mixin({
        beforeMount () {
            const { asyncData } = this.$options
            if (asyncData) {
                this.dataPromise = asyncData({
                    store: this.$store,
                    route: this.$route
                })
            }
        }
    })
    
    const { app, router, store } = createApp()
    
    if (window.__INITIAL_STATE__) {
        store.replaceState(window.__INITIAL_STATE__)
    }
    
    router.onReady(() => {
        // 在初始路由 resolve 后执行,
        // 使用 `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()
            }
    
            Promise.all(activated.map(c => {
                if (c.asyncData) {
                    return c.asyncData({ store, route: to })
                }
            })).then(() => {
                next()
            }).catch(next)
        })
    
        app.$mount('#app')
    })
    

    entry-server.js

    import { createApp } from './app'
    
    export default context => {
        // 返回一个promise,服务器能够等待所有的内容在渲染前,已经准备就绪,
        return new Promise((resolve, reject) => {
            const { app, router, store } = createApp()
            
            router.push(context.url)
    
            router.onReady(() => {
                const matchedComponents = router.getMatchedComponents()
                if (!matchedComponents.length) {
                    return reject({ code: 404 })
                }
    
                // 对所有匹配的路由组件调用 `asyncData()`
                Promise.all(matchedComponents.map(Component => {
                    if (Component.asyncData) {
                        return Component.asyncData({
                            store,
                            route: router.currentRoute
                        })
                    }
                })).then(() => {
                    context.state = store.state
    
                    resolve(app)
                }).catch(reject)
            }, reject)
        })
    }
    

    (3)在根目录下创建server.js 文件

    其中一个非常重要的api:createBundleRenderer,这个api上面有一个方法renderToString将代码转化成html字符串,主要功能就是把用webpack把打包后的服务端代码渲染出来。具体了解可看官网bundle renderer指引(传送门: bundle renderer指引

    // server.js
    const app = require('express')()
    const { createBundleRenderer } = require('vue-server-renderer')
    const fs = require('fs')
    const path = require('path')
    const resolve = file => path.resolve(__dirname, file)
    
    const isProd = process.env.NODE_ENE === "production"
    
    const createRenderer = (bundle, options) => {
        return createBundleRenderer(bundle, Object.assign(options, {
            basedir: resolve('./dist'),
            runInNewContext: false,
        }))
    }
    
    let renderer, readyPromise
    const templatePath = resolve('./src/index.template.html')
    if (isProd) {
        const bundle = require('./dist/vue-ssr-server-bundle.json')
        const clientManifest = require('./dist/vue-ssr-client-manifest.json')
        const template = fs.readFileSync(templatePath, 'utf-8')
    
        renderer = createRenderer(bundle, {
            // 推荐
            template, // (可选)页面模板
            clientManifest // (可选)客户端构建 manifest
        })
    } else {
        // 开发模式
        readyPromise = require('./config/setup-dev-server')(app, templatePath, (bundle, options) => {
            renderer = createRenderer(bundle, options)
        })
    }
    
    const render = (req, res) => {
        const context = {
            title: 'hello ssr with webpack',
            meta: `
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <meta http-equiv="X-UA-Compatible" content="ie=edge">
        `,
            url: req.url
        }
        renderer.renderToString(context, (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)
            }
        })
    }
    
    // 在服务器处理函数中……
    app.get('*', isProd ? render : (req, res) => {
        readyPromise.then(() => render(req, res))
    })
    
    app.listen(8080) // 监听的是8080端口
    

    (4)接下来是config配置

    在根目录新增config文件夹,然后新增四个配置文件:webpack.base.config,webpack.client.config,webpack.server.config,setup-dev-server(此方法是一个封装,为了配置个热加载,差点没搞明白,参考了好多)(官网传送门: 构建配置

    大部分官网有示例代码,但是要在基础上进行一些更改

    webpack.base.config

    // webpack.base.config
    const path = require('path')
    // 用来处理后缀为.vue的文件
    const { VueLoaderPlugin } = require('vue-loader')
    const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
    // 定位到根目录
    const resolve = (dir) => path.join(path.resolve(__dirname, "../"), dir)
    
    // 打包时会先清除一下
    // const { CleanWebpackPlugin } = require('clean-webpack-plugin')
    
    const isProd = process.env.NODE_ENV === "production"
    
    module.exports = {
        mode: isProd ? 'production' : 'development',
        output: {
            path: resolve('dist'),
            publicPath: '/dist/',
            filename: '[name].[chunk-hash].js'
        },
        resolve: {
            alias: {
                'public': resolve('public')
            }
        },
        module: {
            noParse: /es6-promise.js$/,
            rules: [
                {
                    test: /.vue$/,
                    loader: 'vue-loader',
                    options: {
                        compilerOptions: {
                            preserveWhiteSpace: false
                        }
                    }
                },
                {
                    test: /.js$/,
                    loader: 'babel-loader',
                    exclude: /node_modules/
                },
                {
                    test: /.(png|jpg|gif|svg)$/,
                    loader: 'url-loader',
                    options: {
                        limit: 10000,
                        name: '[name].[ext]?[hash]'
                    }
                },
                {
                    test: /.s(a|c)ss?$/,
                    use: ['vue-style-loader', 'css-loader', 'sass-loader']
                }
            ]
        },
        performance: {
            hints: false
        },
        plugins:[
            new VueLoaderPlugin(),
            // 编译后的友好提示,比如编译完成或者编译有错误
            new FriendlyErrorsWebpackPlugin(),
            // 打包时会先清除一下
            // new CleanWebpackPlugin()
        ]
    }
    

    webpack.client.config

    // webpack.client.config
    const {merge} = require('webpack-merge')
    const baseConfig = require('./webpack.base.config.js')
    const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
    
    module.exports = merge(baseConfig, {
        entry: {
            app: './src/entry-client.js'
        },
        optimization: {
            // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
            // 以便可以在之后正确注入异步 chunk。
            // 这也为你的 应用程序/vendor 代码提供了更好的缓存。
            splitChunks: {
                name: "manifest",
                minChunks: Infinity
            }
        },
        plugins: [
            // 此插件在输出目录中
            // 生成 `vue-ssr-client-manifest.json`。
            new VueSSRClientPlugin()
        ]
    })
    

    webpack.server.config

    // webpack.server.config
    const {merge} = require('webpack-merge')
    const nodeExternals = require('webpack-node-externals')
    
    // webpack的基础配置,比如sass,less预编译等
    const baseConfig = require('./webpack.base.config.js')
    const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
    
    module.exports = merge(baseConfig, {
        // 将 entry 指向应用程序的 server entry 文件
        entry: './src/entry-server.js',
    
        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)的依赖模块列入白名单
            allowlist: /.css$/
        }),
    
        // 这是将服务器的整个输出
        // 构建为单个 JSON 文件的插件。
        // 默认文件名为 `vue-ssr-server-bundle.json`
        plugins: [
            new VueSSRServerPlugin()
        ]
    })
    

    setup-dev-server:封装createRenderer方法

    const webpack = require('webpack')
    const fs = require('fs')
    const path = require('path')
    const chokidar = require('chokidar')
    const middleware = require("webpack-dev-middleware")
    const HMR = require("webpack-hot-middleware")
    const MFS = require('memory-fs')
    
    const clientConfig = require('./webpack.client.config')
    const serverConfig = require('./webpack.server.config')
    
    const readFile = (fs, file) => {
        try {
            return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf8')
        } catch (error) {
    
        }
    }
    
    const setupServer = (app, templatePath, cb) => {
        let bundle
        let clientManifest
        let template
        let ready
        const readyPromise = new Promise(r => ready = r)
    
        template = fs.readFileSync(templatePath, 'utf8')
        const update = () => {
            if (bundle && clientManifest) {
                // 通知 server 进行渲染
                // 执行 createRenderer -> RenderToString
                ready()
                cb(bundle, {
                    template,
                    clientManifest
                })
            }
        }
        // webpack -> entry-server -> bundle
        const mfs = new MFS();
        const serverCompiler = webpack(serverConfig);
    
        serverCompiler.outputFileSystem = mfs;
        serverCompiler.watch({}, (err, stats) => {
            if (err) throw err
            // 之后读取输出:
            stats = stats.toJson()
            stats.errors.forEach(err => console.error(err))
            stats.warnings.forEach(err => console.warn(err))
            if (stats.errors.length) return
            bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
            update()
        });
    
        clientConfig.plugins.push(
            new webpack.HotModuleReplacementPlugin()
        )
        clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
        clientConfig.output.filename = '[name].js'
    
        const clientCompiler = webpack(clientConfig);
    
        const devMiddleware = middleware(clientCompiler, {
            noInfo: true, publicPath: clientConfig.output.publicPath, logLevel: 'silent'
        })
        app.use(devMiddleware);
    
        app.use(HMR(clientCompiler));
    
        clientCompiler.hooks.done.tap('clientsBuild', stats => {
            stats = stats.toJson()
            stats.errors.forEach(err => console.error(err))
            stats.warnings.forEach(err => console.warn(err))
            if (stats.errors.length) return
            clientManifest = JSON.parse(readFile(
                devMiddleware.fileSystem,
                'vue-ssr-client-manifest.json'
            ))
            update()
        })
    
        // fs -> templatePath -> template
        chokidar.watch(templatePath).on('change', () => {
            template = fs.readFileSync(templatePath, 'utf8')
            console.log('template is updated');
            update()
        })
    
        return readyPromise
    }
    
    module.exports = setupServer
    

    (5)配置搞完了接下来是代码渲染

    在src目录下,新增index.template.html文件,将官网中的例子(地址:使用一个页面模板 )复制,并进行一些更改

    <html>
    <head>
        <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
        <title>{{ title }}</title>
    
        <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
        {{{ meta }}}
    </head>
    <body>
    <!--这个是告诉我们在哪里插入正文的内容-->
    <!--vue-ssr-outlet-->
    </body>
    </html>
    

    (6)再搞个store和api模拟一下数据请求

    这里介绍一下一个很重要的东西asyncData 预取数据,预取数据是在vue挂载前,所以下文这里用了上下文来获取store而不是this

    asyncData: ({ store }) => { return store.dispatch('getDataAction') },
    

    在src下创建api文件夹,并在下面创建data.js文件

    // data.js
    const getData = () => new Promise((resolve) => {
        setTimeout(() => {
            resolve([
                {
                    id: 1,
                    item: '测试1'
                },
                {
                    id: 2,
                    item: '测试2'
                },
            ])
        }, 1000)
    })
    
    export {
        getData
    }
    

    在src下创建store文件夹,并在下面创建index.js文件

    // store.js
    import Vue from 'vue'
    import Vuex from 'vuex'
    
    Vue.use(Vuex)
    
    import { getData } from '../api/data'
    
    export function createStore () {
        return new Vuex.Store({
            state: {
                lists: []
            },
            actions: {
                getDataAction ({ commit }) {
                    return getData().then((res) => {                   
                        commit('setData', res)
                    })
                }
            },
            mutations: {
                setData (state, data) {
                    state.lists = data
                }
            }
        })
    }
    

    (7)编写组件,在src/components文件夹下写两个组件,在app.vue中引用一下,用上刚写的模拟数据

    Hello.vue

    <template>
      <div>
        这里是测试页面一
        <p>{{item}}</p>
        <router-link to="/hello1">链接到测试页面二</router-link>
      </div>
    </template>
    
    <script>
    export default {
      asyncData: ({ store }) => {
        return store.dispatch('getDataAction')
      },
      computed: {
        item () {
          return this.$store.state.lists
        }
      }
    }
    </script>
    
    <style lang="scss" scoped>
    </style>
    

    Hello1.vue

    <template>
      <div>这里是测试页面二{{item}}</div>
    </template>
    
    <script>
    export default {
      asyncData: ({ store }) => {
        return store.dispatch('getDataAction')
      },
      computed: {
        item () {
          return this.$store.state.lists
        }
      }
    }
    </script>
    
    <style lang="scss" scoped>
    </style>
    

    (8)配置路由并在app.vue使用路由

    router.js

    import Vue from 'vue'
    import Router from 'vue-router'
    
    Vue.use(Router)
    
    export function createRouter () {
        return new Router({
            mode: 'history',
            routes: [
                {
                    path: '/hello',
                    component: () => import('./components/Hello.vue')
                },
                {
                    path: '/hello1',
                    component: () => import('./components/Hello1.vue')
                },
            ]
        })
    } 
    

    app.vue

    <template>
      <div id="app">
        <router-view></router-view>
      </div>
    </template>
    
    <script>
    
    export default {
      name: 'App',
    }
    </script>
    
    <style lang="scss" scoped>
    </style>
    

    (9)根目录下创建一个.babelrc,进行配置

    {
      "presets": [
        [
          "@babel/preset-env",
          {
            "modules": false
          }
        ]
      ]
    }
    

    (10)改写package.json执行命令

    "dev": "nodemon server.js",
    "build": "rimraf dist && npm run build:client && npm run build:server",
    "build:client": "webpack --config config/webpack.client.config.js",
    "build:server": "webpack --config config/webpack.server.config.js"
    

    大搞告成,执行一下dev命令,可以通过访问localhost:8080端口看到页面,记得带上路由哦~

    执行build命令可看到,最后dist文件下共有三个文件:main.[chunk-hash].js,vue-ssr-client-manifest.json,vue-ssr-server-bundle.json

    附上文件整体目录结构

    21.jpeg

    方案二:基于vue的nuxt.js通用应用框架

    git 示例demo地址

    一对比,这个就显得丝滑多了~ 官网地址: nuxt.js

    先对比一下两种方案的差别

    1.vue初始化虽然有cli,但是nuxt.js的cli更加完备
    2.nuxt有更合理的工程化目录,vue过于简洁,比如一些component,api文件夹都是要手动创建的
    3.路由配置:传统应用需要自己来配置,nuxt.js自动生成
    4.没有统一配置,需手动创建。nuxt.js会生成nuxt.config.js
    5.传统不易与管理底层框架逻辑(nuxt支持中间件管理,虽然我还没探索过这里)
    

    显而易见这个上手就快多了,也不需要安装一大堆依赖,如果用了sass需要安装sass和sass-loader,反正我是用了

    (1)创建一个项目 可选npm,npx,yarn,具体看官方文档

    npm init nuxt-app <project-name>
    

    (2)pages下面创建几个文件

    nuxt是通过pages页面形成动态的路由,不用手动配置路由。比如在pages下面新增了个文件about.vue,那么这个页面对应的路由就是/about

    其实这个时候运行npm run dev 就可以看到简单的页面了

    (3)模拟接口

    这里介绍一个插件,可以快速创建一个服务

    npm i json-server 
    

    安装完后,在根目录新增db.json文件,模拟几个接口

    {
      "post": [{"id": 1, "title": "json-server", "author": "jx"}],
      "comments": [{"id": 1, "body": "some comment", "postId": 1}],
      "profile": {"name": "typicode"}
    }
    

    运行命令json-server --watch db.json --port=8000(不加会端口冲突),就可以看到

    22.jpg

    因为是get请求,可以直接点击访问可以看到mock的数据已经返回了

    23.jpg

    (4)页面调用

    先配置一下axios,推荐使用nuxt.js封装的axios:"@nuxtjs/axios": "^5.13.6",然后再在nuxt.config.js文件中modules下面配置一下就可以使用了

    modules: [  '@nuxtjs/axios'],
    

    随便找个接口调用一下

    <template>
      <div>
        <div>
          这里是测试页面一
        </div>
        接口返回数据:{{posts}}
      </div>
    </template>
    
    <script>
    export default {
      name: 'IndexPage',
      async asyncData({$axios}){
        const result = await $axios.get('http://localhost:8000/post')
        return{
          posts: result.data
        }
      }
    }
    </script>
    

    刷新下页面就可以看到效果了,这里注意axios有两个get方法,一个axios.get还会返回头部等信息,另一个axios.get只返回结果

    总结:

    从页面篇幅上应该也能看到哪个容易上手了,nuxt相对于插件来说限定了文件夹的结构,并通过此预定了一些功能,更好上手。预设了利用vue.js开发服务端渲染所需要的各种配置,并且提供了提供了静态站点,异步数据加载,中间件支持,布局支持等

    相关文章

      网友评论

          本文标题:vue的两种服务器端渲染方案

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