同构代码
同构渲染简单来说就是一份代码,服务端先通过服务端渲染(server-side rendering,下称ssr),生成html以及初始化数据,客户端拿到代码和初始化数据后,通过对html的dom进行patch和事件绑定对dom进行客户端激活(client-side hydration,下称csh),这个整体的过程叫同构渲染。其实就是满足三个条件:1. 同一份代码 2. ssr 3. csh
利用 webpack build 出同构的代码
vue-server-renderer-demo
├── dist
│ ├── client_app.js # webpack build entry-client.js
│ └── server_app.js # webpack build entry-server.js
├── src
│ ├── views
│ │ ├── index.vue
│ │ └── demo.vue
│ ├── App.vue
│ ├── title-mixin.js
│ ├── router.js
│ ├── app.js
│ ├── store.js
│ ├── app.js # universal entry
│ ├── entry-client.js # runs in browser only
│ └── entry-server.js # runs on server only
├── server.js
├── webpack.config.js
├── index.template.html
└── package.json
App.vue
<script>
export default {
template: `<div id="root"><router-view></router-view></div>`,
}
</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