同构代码

  1. 同构渲染简单来说就是一份代码,服务端先通过服务端渲染(server-side rendering,下称ssr),生成html以及初始化数据,客户端拿到代码和初始化数据后,通过对htmldom进行patch和事件绑定对dom进行客户端激活(client-side hydration,下称csh),这个整体的过程叫同构渲染。其实就是满足三个条件:1. 同一份代码 2. ssr 3. csh

利用 webpack build 出同构的代码

image.png

  1. vue-server-renderer-demo
  2. ├── dist
  3. ├── client_app.js # webpack build entry-client.js
  4. └── server_app.js # webpack build entry-server.js
  5. ├── src
  6. ├── views
  7. ├── index.vue
  8. └── demo.vue
  9. ├── App.vue
  10. ├── title-mixin.js
  11. ├── router.js
  12. ├── app.js
  13. ├── store.js
  14. ├── app.js # universal entry
  15. ├── entry-client.js # runs in browser only
  16. └── entry-server.js # runs on server only
  17. ├── server.js
  18. ├── webpack.config.js
  19. ├── index.template.html
  20. └── package.json

App.vue

  1. <script>
  2. export default {
  3. template: `<div id="root"><router-view></router-view></div>`,
  4. }
  5. </script>

app.js

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
// export a factory function for creating fresh app, router and store
// instances
export function createApp () {
  // create router instance
  const router = createRouter()

  const app = new Vue({
    // inject router into root Vue instance
    router,
    render: h => h(App)
  })

  // return both the app and the router
  return { app, router }
}

entry-client.js

import { createApp } from './app'

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

if (window.__INITIAL_STATE__) {
  // We initialize the store state with the data injected from the server
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
  app.$mount('#root')
})

entry-server.js

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

export default context => {
  // since there could potentially be asynchronous route hooks or components,
  // we will be returning a Promise so that the server can wait until
  // everything is ready before rendering.
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    // set server-side router's location
    router.push(context.url)

    // wait until router has resolved possible async components and hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // no matched routes, reject with 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      context.rendered = () => {
        // After the app is rendered, our store is now
        // filled with the state from our components.
        // When we attach the state to the context, and the `template` option
        // is used for the renderer, the state will automatically be
        // serialized and injected into the HTML as `window.__INITIAL_STATE__`.
        context.state = store.state
      }

      // the Promise should resolve to the app instance so it can be rendered
      resolve(app)
    }, reject)
  })
}

server.js

const express = require('express')
const server = express()
const serverRenderer = require('vue-server-renderer')
let createApp = require('./dist/server_app')
if (createApp.default) {
  createApp = createApp.default
}

const renderer = serverRenderer.createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})

server.use(express.static(__dirname + '/dist')); // 设置静态文件目录,外网可直接访问

const context = {
  title: 'vue ssr',
  mate: `
    <meta charset="utf-8" />
  `,
  script: `<script src="/client_app.js"></script>`
};

server.get('*', (req, res) => {
  if (req.url === '/client_app.js') {
    return
  }
  const props = { url: req.url }
  createApp(props).then(app => {
    renderer.renderToString(app, 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)
      }
    })
  }).catch(err => {
    console.error('error:', err)
  })
})

server.listen(8088)

webpack.config.js

const path = require('path')
const webpack = require('webpack')
const VueLoaderPlugin = require('vue-loader/lib/plugin') 

const entry = {}
let isServer = false

if (process.env.BASE_BUILD === 'server') {
  entry['server_app'] = './src/entry-server.js'
  isServer = true
} else {
  entry['client_app'] = './src/entry-client.js'
}

module.exports = {
  mode: 'development',
  devtool: 'source-map',
  entry,
  output: {
    path: path.join(__dirname, "./dist/"), //文件输出目录
    filename: '[name].js',
    chunkFilename: '[name].common.js',
    libraryTarget: isServer ? 'commonjs2' : 'umd',
    publicPath: path.join(__dirname, "./dist/"),
    globalObject: 'this',
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: [
          'vue-loader',
        ]
      }
    ]
  },
  externals: {
    vue: 'Vue',
  },
  plugins: [
    new webpack.DefinePlugin({  
      'process.env.BASE_BUILD': `'${process.env.BASE_BUILD}'`
    }),
    new VueLoaderPlugin()
  ]
}

package.json

{
    "name": "vue-server-renderer-demo",
    "version": "1.0.0",
    "description": "",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "build:server": "cross-env BASE_BUILD=server npx webpack",
        "build:client": "cross-env BASE_BUILD=client npx webpack",
        "dev": "node ./server.js"
    },
    "author": "",
    "license": "ISC",
    "dependencies": {
        "express": "^4.17.1",
        "vue": "^2.6.14",
        "vue-router": "^3.5.2",
        "vue-server-renderer": "^2.6.14"
    },
    "devDependencies": {
        "vue-loader": "^15.9.8",
        "vue-loader-plugin": "^1.3.0",
        "vue-template-compiler": "^2.6.14",
        "webpack": "^5.52.1",
        "webpack-cli": "^4.8.0"
    }
}

index.template.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- 非 HTML 转译 -->
    <title>{{ title }}</title>
    <!-- HTML 转译 -->
    {{{ mate }}}
    <script src="https://unpkg.com/vue/dist/vue.js"></script>
  </head>
  <body>
    <!--vue-ssr-outlet-->

    {{{ script }}}
  </body>
</html>

router.js

// router.js
import Vue from 'vue'
import Router from 'vue-router'

// vue-router 会调用document ... 暂时还不可以用
// const Demo = () => import('./views/demo.vue')
// const Index = () => import('./views/index.vue')

import Demo from './views/demo.vue';
import Index from './views/index.vue';

Vue.use(Router)

export function createRouter () {
  return new Router({
    mode: 'history',
    routes: [
      {
        path: "/",
        component: Demo,
        name: "Demo"
      },
      {
        path: "/home",
        component: Index,
        name: "Index"
      },
      // { path: '/item/:id', component: () => import('./views/index.vue') }
    ]
  })
}

view/demo.js and view/index.js

<template>
  <div v-if="item" @click="test">{{ item.title }}</div>
  <div v-else @click="test">...</div>
</template>

<script>
// views/demo.js
import titleMixin from '../title-mixin'
export default {
  mixins: [titleMixin],
  title () {
    return "666"
  },

  computed: {
    item () {
      return this.$store.state.items[this.$route.params.id]
    }
  },

  // Server-side only
  // This will be called by the server renderer automatically
  serverPrefetch () {
    // return the Promise from the action
    // so that the component waits before rendering
    return this.fetchItem()
  },

  // Client-side only
  mounted () {
    // If we didn't already do it on the server
    // we fetch the item (will first show the loading text)
    if (!this.item) {
      this.fetchItem()
    }
  },


  methods: {
    test() {
      console.log('999')
    },
    fetchItem () {
      // return the Promise from the action
      return this.$store.dispatch('fetchItem', this.$route.params.id)
    }
  },
}
</script>
<script>
// views/index.js
export default {
  template: `<div @click="test">index</div>`,
  methods: {
    test() {
      console.log('1010')
    }
  },
}
</script>

store.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

// Assume we have a universal API that returns Promises
// and ignore the implementation details
import { fetchItem } from './api'

export function createStore () {
  return new Vuex.Store({
    // IMPORTANT: state must be a function so the module can be
    // instantiated multiple times
    state: () => ({
      items: {}
    }),

    actions: {
      fetchItem ({ commit }, id) {
        // return the Promise via `store.dispatch()` so that we know
        // when the data has been fetched
        return fetchItem(id).then(item => {
          commit('setItem', { id, item })
        })
      }
    },

    mutations: {
      setItem (state, { id, item }) {
        Vue.set(state.items, id, item)
      }
    }
  })
}

api.js

export const fetchItem = async () => {
  return { title: "Item Demo" }
}

title-mixin.js

function getTitle(vm) {
  // components can simply provide a `title` option
  // which can be either a string or a function
  const { title } = vm.$options
  if (title) {
    return typeof title === 'function'
      ? title.call(vm)
      : title
  }
}

const serverTitleMixin = {
  created() {
    const title = getTitle(this)
    if (title) {
      this.$ssrContext.title = title
    }
  }
}

const clientTitleMixin = {
  mounted() {
    const title = getTitle(this)
    if (title) {
      document.title = title
    }
  }
}

export default process.env.BASE_BUILD === 'server'
  ? serverTitleMixin
  : clientTitleMixin