├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── README.md ├── build ├── setup-dev-server.js ├── utils.js ├── webpack.base.conf.js ├── webpack.dev.conf.js ├── webpack.prod.conf.js └── webpack.server.conf.js ├── config └── index.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── index.js ├── server ├── index.js └── index.template.html ├── src ├── App.vue ├── app.js ├── assets │ ├── images │ │ └── code.png │ └── styles │ │ └── common.styl ├── components │ ├── A │ │ └── A.vue │ └── B │ │ └── B.vue ├── entry-client.js ├── entry-server.js ├── mixins │ └── title-mixins.js ├── router │ └── index.js └── store │ └── index.js └── static └── favicon.ico /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-syntax-dynamic-import" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "plugins": [ 4 | "html" 5 | ], 6 | "parser": "babel-eslint" 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | server-dist/ 4 | server-build 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 基于webpack4构建的vue应用(SSR/SPA) 2 | 3 | 📦 此项目是通过webpack搭建了vue单页面应用以及vue服务端渲染应用。项目只是作为学习webpack以及SSR,并不足以强大能够用于线上项目。 4 | 5 | > 看完整代码可以查找对应Tag。 6 | 7 | ## webpack-SPA 8 | 9 | [完整代码](https://github.com/lhz960904/webpack-vue-ssr/tree/webpack4-SPA-v1.0) 10 | 11 | #### Usage: 12 | 13 | ```shell 14 | npm install 15 | 16 | npm run dev # 开发环境 17 | 18 | npm run build # 线上构建 19 | ``` 20 | 21 | 22 | 23 | 24 | 25 | ## webpack-SSR 26 | 27 | [完整代码](https://github.com/lhz960904/webpack-vue-ssr/tree/webpack-SSR-v1.0) 28 | 29 | #### Usage: 30 | 31 | ```shell 32 | npm install 33 | 34 | npm run dev # 开发环境 35 | 36 | npm run build # 线上构建 37 | npm run start # 线上运行 38 | ``` 39 | 40 | ```javascript 41 | // movie.vue 42 | export default { 43 | // 更改title 44 | title () { 45 | return 'demo1' 46 | }, 47 | // 异步获取数据 48 | asyncData ({ store, route }) { 49 | // 触发 action 后,例:请求电影、传入id 50 | return store.dispatch('fetchMovie', 54321) 51 | }, 52 | } 53 | 54 | 55 | // store/index.js 56 | return new Vuex.Store({ 57 | state: { 58 | movie: {} 59 | }, 60 | actions: { 61 | fetchMovie ({ commit }, id) { 62 | return new Promise((resolve, reject) => { 63 | // ajax去请求数据 64 | }).then(res => { 65 | commit('setMoive', { res }) 66 | }) 67 | } 68 | }, 69 | mutations: { 70 | setMoive (state, { res }) { 71 | Vue.set(state, 'movie', res) 72 | } 73 | } 74 | }) 75 | ``` 76 | -------------------------------------------------------------------------------- /build/setup-dev-server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | // memory-fs可以使webpack将文件写入到内存中,而不是写入到磁盘。 4 | const MFS = require('memory-fs') 5 | const webpack = require('webpack') 6 | // 监听文件变化,兼容性更好(比fs.watch、fs.watchFile、fsevents) 7 | const chokidar = require('chokidar') 8 | const clientConfig = require('./webpack.dev.conf') 9 | const serverConfig = require('./webpack.server.conf') 10 | // webpack热加载需要 11 | const webpackDevMiddleware = require('koa-webpack-dev-middleware') 12 | // 配合热加载实现模块热替换 13 | const webpackHotMiddleware = require('koa-webpack-hot-middleware') 14 | 15 | // 读取vue-ssr-webpack-plugin生成的文件 16 | const readFile = (fs, file) => { 17 | try { 18 | return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8') 19 | } catch (e) { 20 | console.log('读取文件错误:', e) 21 | } 22 | } 23 | 24 | module.exports = function setupDevServer(app, templatePath, cb) { 25 | let bundle 26 | let template 27 | let clientManifest 28 | 29 | 30 | // 监听改变后更新函数 31 | const update = () => { 32 | if (bundle && clientManifest) { 33 | cb(bundle, { 34 | template, 35 | clientManifest 36 | }) 37 | } 38 | } 39 | 40 | // 监听html模板改变、需手动刷新 41 | template = fs.readFileSync(templatePath, 'utf-8') 42 | chokidar.watch(templatePath).on('change', () => { 43 | template = fs.readFileSync(templatePath, 'utf-8') 44 | update() 45 | }) 46 | 47 | // 修改webpack入口配合模块热替换使用 48 | clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] 49 | 50 | // 编译clinetWebpack 插入Koa中间件 51 | const clientCompiler = webpack(clientConfig) 52 | const devMiddleware = webpackDevMiddleware(clientCompiler, { 53 | publicPath: clientConfig.output.publicPath, 54 | noInfo: true 55 | }) 56 | app.use(devMiddleware) 57 | 58 | clientCompiler.plugin('done', stats => { 59 | stats = stats.toJson() 60 | stats.errors.forEach(err => console.error(err)) 61 | stats.warnings.forEach(err => console.warn(err)) 62 | if (stats.errors.length) return 63 | clientManifest = JSON.parse(readFile( 64 | devMiddleware.fileSystem, 65 | 'vue-ssr-client-manifest.json' 66 | )) 67 | update() 68 | }) 69 | 70 | // 插入Koa中间件(模块热替换) 71 | app.use(webpackHotMiddleware(clientCompiler)) 72 | 73 | const serverCompiler = webpack(serverConfig) 74 | const mfs = new MFS() 75 | serverCompiler.outputFileSystem = mfs 76 | serverCompiler.watch({}, (err, stats) => { 77 | if (err) throw err 78 | stats = stats.toJson() 79 | if (stats.errors.length) return 80 | 81 | // vue-ssr-webpack-plugin 生成的bundle 82 | bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) 83 | update() 84 | }) 85 | 86 | } 87 | 88 | -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const config = require('../config') 3 | const packageConfig = require('../package.json') 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 5 | 6 | // 返回文件绝对路径 7 | exports.assetsPath = function (_path) { 8 | const assetsSubDirectory = process.env.NODE_ENV === 'production' 9 | ? config.build.assetsSubDirectory 10 | : config.dev.assetsSubDirectory 11 | 12 | return path.join(assetsSubDirectory, _path) 13 | } 14 | 15 | 16 | // cssloader兼容各种预处理语言(less|sass|scss|stylus) 17 | exports.cssLoaders = function (options) { 18 | options = options || {} 19 | 20 | const cssLoader = { 21 | loader: 'css-loader', 22 | options: { 23 | sourceMap: options.sourceMap 24 | } 25 | } 26 | 27 | const postcssLoader = { 28 | loader: 'postcss-loader', 29 | options: { 30 | sourceMap: options.sourceMap 31 | } 32 | } 33 | 34 | // webpack4.0版本以上采用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin 35 | function generateLoaders(loader, loaderOptions) { 36 | const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [cssLoader] 37 | 38 | if (loader) { 39 | loaders.push({ 40 | loader: loader + '-loader', 41 | options: Object.assign({}, loaderOptions, { 42 | sourceMap: options.sourceMap 43 | }) 44 | }) 45 | } 46 | 47 | if (options.extract) { 48 | return [MiniCssExtractPlugin.loader].concat(loaders) 49 | } else { 50 | return ['vue-style-loader'].concat(loaders) 51 | } 52 | } 53 | 54 | return { 55 | css: generateLoaders(), 56 | postcss: generateLoaders(), 57 | less: generateLoaders('less'), 58 | sass: generateLoaders('sass', { indentedSyntax: true }), 59 | scss: generateLoaders('sass'), 60 | stylus: generateLoaders('stylus'), 61 | styl: generateLoaders('stylus') 62 | } 63 | } 64 | 65 | // Generate loaders for standalone style files (outside of .vue) 66 | exports.styleLoaders = function (options) { 67 | const output = [] 68 | const loaders = exports.cssLoaders(options) 69 | 70 | for (const extension in loaders) { 71 | const loader = loaders[extension] 72 | output.push({ 73 | test: new RegExp('\\.' + extension + '$'), 74 | use: loader 75 | }) 76 | } 77 | 78 | return output 79 | } 80 | 81 | //创建友好错误提示的格式 82 | exports.createNotifierCallback = () => { 83 | const notifier = require('node-notifier') 84 | 85 | return (severity, errors) => { 86 | if (severity !== 'error') return 87 | 88 | const error = errors[0] 89 | const filename = error.file && error.file.split('!').pop() 90 | 91 | notifier.notify({ 92 | title: packageConfig.name, 93 | message: severity + ': ' + error.name, 94 | subtitle: filename || '', 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const utils = require('./utils') 3 | const config = require('../config') 4 | // vue-loader v15版本需要引入此插件 5 | const VueLoaderPlugin = require('vue-loader/lib/plugin') 6 | // 服务端渲染用到的插件、默认生成JSON 7 | const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') 8 | 9 | // 用于返回文件相对于根目录的绝对路径 10 | const resolve = dir => path.resolve(__dirname, '..', dir) 11 | 12 | // 创建ESlint相关rules 13 | const createLintingRule = () => ({ 14 | test: /\.(js|vue)$/, 15 | loader: 'eslint-loader', 16 | enforce: 'pre', 17 | include: [resolve('src'), resolve('server')], 18 | options: { 19 | // 更友好、更详细的提示 20 | formatter: require('eslint-friendly-formatter'), 21 | // 只给出警告,开发有很好的体验,emitError为true会阻止浏览器显示内容 22 | emitWarning: !config.dev.showEslintErrorsInOverlay 23 | } 24 | }) 25 | 26 | module.exports = { 27 | entry: resolve('src/entry-client.js'), 28 | output: { 29 | path: config.build.assetsRoot, 30 | filename: '[name].js', 31 | publicPath: process.env.NODE_ENV === 'production' 32 | ? config.build.assetsPublicPath 33 | : config.dev.assetsPublicPath 34 | }, 35 | resolve: { 36 | extensions: ['.js', '.vue'], 37 | alias: { 38 | 'components': resolve('src/components'), 39 | 'assets': resolve('src/assets') 40 | } 41 | }, 42 | module: { 43 | rules: [ 44 | ...(config.dev.useEslint ? [createLintingRule()] : []), 45 | { 46 | // 编译.vue文件, vue-cli2还包含vue-loader.conf.js, 47 | // 但vue-loader15已经将大部分配置改为默认,所以没必要新建个文件 48 | test: /\.vue$/, 49 | loader: 'vue-loader', 50 | options: { 51 | // 配置哪些引入路径按照模块方式查找 52 | transformAssetUrls: { 53 | video: ['src', 'poster'], 54 | source: 'src', 55 | img: 'src', 56 | image: 'xlink:href' 57 | } 58 | } 59 | }, 60 | { 61 | test: /\.js$/, // 利用babel-loader编译js,使用更高的特性,排除npm下载的.vue组件 62 | loader: 'babel-loader', 63 | exclude: file => ( 64 | /node_modules/.test(file) && 65 | !/\.vue\.js/.test(file) 66 | ) 67 | }, 68 | { 69 | test: /\.(png|jpe?g|gif|svg)$/, // 处理图片 70 | use: [ 71 | { 72 | loader: 'url-loader', 73 | options: { 74 | limit: 10000, 75 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 76 | } 77 | } 78 | ] 79 | }, 80 | { 81 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, // 处理字体 82 | loader: 'url-loader', 83 | options: { 84 | limit: 10000, 85 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 86 | } 87 | } 88 | ] 89 | }, 90 | plugins: [ 91 | new VueLoaderPlugin(), 92 | new VueSSRClientPlugin() 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const utils = require('./utils') 3 | const webpack = require('webpack') 4 | const config = require('../config') 5 | const merge = require('webpack-merge') 6 | const baseWebpackConfig = require('./webpack.base.conf') 7 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 8 | 9 | const HOST = process.env.HOST 10 | const PORT = process.env.PORT && Number(process.env.PORT) 11 | 12 | const devWebpackConfig = merge(baseWebpackConfig, { 13 | mode: 'development', 14 | entry: { 15 | 'app': path.join(__dirname, '../src/entry-client.js') 16 | }, 17 | module: { 18 | rules: utils.styleLoaders({ 19 | sourceMap: config.dev.cssSourceMap, 20 | usePostCSS: true 21 | }) 22 | }, 23 | devtool: config.dev.devtool, // cheap-module-eval-source-map编译更快 24 | plugins: [ 25 | // 热加载必备 26 | new webpack.HotModuleReplacementPlugin(), 27 | // 友好错误提示 28 | new FriendlyErrorsPlugin({ 29 | onErrors: config.dev.notifyOnErrors 30 | ? utils.createNotifierCallback() 31 | : undefined 32 | }) 33 | ] 34 | }) 35 | 36 | module.exports = devWebpackConfig 37 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const utils = require('./utils') 3 | const webpack = require('webpack') 4 | const config = require('../config') 5 | const merge = require('webpack-merge') 6 | const baseWebpackConfig = require('./webpack.base.conf') 7 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 8 | 9 | const pordWebpackConfig = merge(baseWebpackConfig, { 10 | mode: 'production', 11 | output: { 12 | path: config.build.assetsRoot, 13 | // chunkhash是根据内容生成的hash, 易于缓存 14 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 15 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 16 | }, 17 | module: { 18 | rules: utils.styleLoaders({ 19 | sourceMap: config.build.productionSourceMap, 20 | // 将css样式单独提取出文件 21 | extract: true, 22 | usePostCSS: true 23 | }) 24 | }, 25 | devtool: config.build.productionSourceMap ? config.build.devtool : false, 26 | plugins: [ 27 | // webpack4.0版本以上采用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin 28 | new MiniCssExtractPlugin({ 29 | filename: utils.assetsPath('css/[name].[contenthash].css'), 30 | chunkFilename: utils.assetsPath('css/[name].[contenthash].css') 31 | }), 32 | // 当vendor模块不再改变时, 根据模块的相对路径生成一个四位数的hash作为模块id 33 | new webpack.HashedModuleIdsPlugin() 34 | ], 35 | // 优化相关, 暂时占个位置 36 | // optimization: { 37 | // splitChunks: { 38 | // chunks: 'all' 39 | // }, 40 | // runtimeChunk: true 41 | // }, 42 | }) 43 | 44 | module.exports = pordWebpackConfig 45 | -------------------------------------------------------------------------------- /build/webpack.server.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const merge = require('webpack-merge') 4 | const nodeExternals = require('webpack-node-externals') 5 | const baseWebpackConfig = require('./webpack.base.conf') 6 | const VueServerPlugin = require('vue-server-renderer/server-plugin') 7 | 8 | module.exports = merge(baseWebpackConfig, { 9 | mode: 'production', 10 | target: 'node', 11 | devtool: 'source-map', 12 | entry: path.join(__dirname, '../src/entry-server.js'), 13 | output: { 14 | libraryTarget: 'commonjs2', 15 | filename: 'server-bundle.js', 16 | }, 17 | externals: nodeExternals({ 18 | whitelist: /\.css$/ 19 | }), 20 | plugins: [ 21 | new webpack.DefinePlugin({ 22 | 'process.env.VUE_ENV': '"server"' 23 | }), 24 | // 默认文件名为 `vue-ssr-server-bundle.json` 25 | new VueServerPlugin() 26 | ] 27 | }) 28 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | dev: { 5 | // Paths 6 | assetsSubDirectory: 'static', 7 | assetsPublicPath: '/', 8 | proxyTable: {}, // 可以配置跨域请求 9 | // Dev Server settings 10 | host: '0.0.0.0', // 0.0.0.0可以进行移动端测试 11 | port: 8080, 12 | autoOpenBrowser: false, 13 | errorOverlay: true, 14 | notifyOnErrors: true, 15 | poll: false, // 获取文件变化是否采用轮询 16 | useEslint: true, 17 | showEslintErrorsInOverlay: false, 18 | devtool: 'cheap-module-eval-source-map', 19 | cssSourceMap: true 20 | }, 21 | 22 | build: { 23 | // Template for index.html 24 | index: path.resolve(__dirname, '../dist/index.html'), 25 | // Paths 26 | assetsRoot: path.resolve(__dirname, '../dist'), 27 | assetsSubDirectory: 'static', 28 | assetsPublicPath: '/', 29 | productionSourceMap: false, 30 | devtool: '#source-map', 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ssr-todo 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-vue-ssr", 3 | "version": "1.0.0", 4 | "main": "server/index.js", 5 | "repository": "https://github.com/lhz960904/vue-ssr-todo.git", 6 | "author": "lihaozecq@gmail.com", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "node server/index", 10 | "build": "rimraf dist && npm run build:client && npm run build:server", 11 | "build:client": "webpack --config build/webpack.prod.conf.js", 12 | "build:server": "webpack --config build/webpack.server.conf.js", 13 | "start": "cross-env NODE_ENV=production node server/index.js", 14 | "lint": "eslint --ext .js,.vue src" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.1.0", 18 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 19 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 20 | "@babel/preset-env": "^7.1.0", 21 | "autoprefixer": "^9.1.5", 22 | "babel-eslint": "^10.0.1", 23 | "babel-loader": "^8.0.2", 24 | "cross-env": "^5.2.0", 25 | "css-loader": "^1.0.0", 26 | "cssnano": "^4.1.3", 27 | "eslint": "^5.6.1", 28 | "eslint-config-standard": "^12.0.0", 29 | "eslint-friendly-formatter": "^4.0.1", 30 | "eslint-loader": "^2.1.1", 31 | "eslint-plugin-html": "^4.0.6", 32 | "eslint-plugin-import": "^2.14.0", 33 | "eslint-plugin-node": "^7.0.1", 34 | "eslint-plugin-promise": "^4.0.1", 35 | "eslint-plugin-standard": "^4.0.0", 36 | "file-loader": "^2.0.0", 37 | "friendly-errors-webpack-plugin": "^1.7.0", 38 | "koa-webpack-dev-middleware": "^2.0.2", 39 | "koa-webpack-hot-middleware": "^1.0.3", 40 | "memory-fs": "^0.4.1", 41 | "mini-css-extract-plugin": "^0.4.4", 42 | "node-notifier": "^5.2.1", 43 | "postcss-import": "^12.0.0", 44 | "postcss-loader": "^3.0.0", 45 | "postcss-preset-env": "^6.0.6", 46 | "rimraf": "^2.6.2", 47 | "style-loader": "^0.23.0", 48 | "stylus": "^0.54.5", 49 | "stylus-loader": "^3.0.2", 50 | "url-loader": "^1.1.1", 51 | "vue-loader": "^15.4.2", 52 | "vue-style-loader": "^4.1.2", 53 | "vue-template-compiler": "^2.5.17", 54 | "webpack": "^4.20.2", 55 | "webpack-cli": "^3.1.1", 56 | "webpack-dev-server": "^3.1.9", 57 | "webpack-merge": "^4.1.4", 58 | "webpack-node-externals": "^1.7.2" 59 | }, 60 | "dependencies": { 61 | "koa": "^2.5.3", 62 | "koa-router": "^7.4.0", 63 | "koa-send": "^5.0.0", 64 | "vue": "^2.5.17", 65 | "vue-router": "^3.0.1", 66 | "vue-server-renderer": "^2.5.17", 67 | "vuex": "^3.0.1", 68 | "vuex-router-sync": "^5.0.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'postcss-preset-env': {}, 5 | 'cssnano': {}, 6 | 'autoprefixer': {} 7 | } 8 | } -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | var ar = 2 2 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const Koa = require('koa') 3 | const path = require('path') 4 | const chalk = require('chalk') 5 | const LRU = require('lru-cache') 6 | const send = require('koa-send') 7 | const Router = require('koa-router') 8 | const setupDevServer = require('../build/setup-dev-server') 9 | const { createBundleRenderer, createRenderer } = require('vue-server-renderer') 10 | 11 | // 缓存 12 | const microCache = LRU({ 13 | max: 100, 14 | maxAge: 1000 * 60 // 重要提示:条目在 1 秒后过期。 15 | }) 16 | 17 | const isCacheable = ctx => { 18 | // 实现逻辑为,检查请求是否是用户特定(user-specific)。 19 | // 只有非用户特定(non-user-specific)页面才会缓存 20 | console.log(ctx.url) 21 | if (ctx.url === '/b') { 22 | return true 23 | } 24 | return false 25 | } 26 | 27 | // 第 1 步:创建koa、koa-router 实例 28 | const app = new Koa() 29 | const router = new Router() 30 | 31 | let renderer 32 | const templatePath = path.resolve(__dirname, './index.template.html') 33 | 34 | // 第 2步:根据环境变量生成不同BundleRenderer实例 35 | if (process.env.NODE_ENV === 'production') { 36 | // 获取客户端、服务器端打包生成的json文件 37 | const serverBundle = require('../dist/vue-ssr-server-bundle.json') 38 | const clientManifest = require('../dist/vue-ssr-client-manifest.json') 39 | // 赋值 40 | renderer = createBundleRenderer(serverBundle, { 41 | runInNewContext: false, 42 | template: fs.readFileSync(templatePath, 'utf-8'), 43 | clientManifest 44 | }) 45 | // 静态资源 46 | router.get('/static/*', async (ctx, next) => { 47 | await send(ctx, ctx.path, { root: __dirname + '/../dist' }); 48 | }) 49 | } else { 50 | // 开发环境 51 | setupDevServer(app, templatePath, (bundle, options) => { 52 | console.log('重新bundle~~~~~') 53 | const option = Object.assign({ 54 | runInNewContext: false 55 | }, options) 56 | renderer = createBundleRenderer(bundle, option) 57 | } 58 | ) 59 | } 60 | 61 | 62 | const render = async (ctx, next) => { 63 | ctx.set('Content-Type', 'text/html') 64 | 65 | const handleError = err => { 66 | if (err.code === 404) { 67 | ctx.status = 404 68 | ctx.body = '404 Page Not Found' 69 | } else { 70 | ctx.status = 500 71 | ctx.body = '500 Internal Server Error' 72 | console.error(`error during render : ${ctx.url}`) 73 | console.error(err.stack) 74 | } 75 | } 76 | 77 | const context = { 78 | url: ctx.url 79 | } 80 | 81 | // 判断是否可缓存,可缓存并且缓存中有则直接返回 82 | const cacheable = isCacheable(ctx) 83 | if (cacheable) { 84 | const hit = microCache.get(ctx.url) 85 | if (hit) { 86 | console.log('从缓存中取', hit) 87 | return ctx.body = hit 88 | } 89 | } 90 | 91 | try { 92 | const html = await renderer.renderToString(context) 93 | ctx.body = html 94 | if (cacheable) { 95 | console.log('设置缓存: ', ctx.url) 96 | microCache.set(ctx.url, html) 97 | } 98 | } catch (error) { 99 | handleError(error) 100 | } 101 | 102 | } 103 | 104 | router.get('*', render) 105 | 106 | app 107 | .use(router.routes()) 108 | .use(router.allowedMethods()) 109 | 110 | 111 | 112 | const port = process.env.PORT || 3000 113 | app.listen(port, () => { 114 | console.log(chalk.green(`server started at localhost:${port}`)) 115 | }) 116 | -------------------------------------------------------------------------------- /server/index.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ title }} 8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import TitleMixin from './mixins/title-mixins' 4 | import { createRouter } from './router' 5 | import { createStore } from './store' 6 | import { sync } from 'vuex-router-sync' 7 | 8 | Vue.mixin(TitleMixin) 9 | 10 | export function createApp (context) { 11 | // 创建 router 和 store 实例 12 | const router = createRouter() 13 | const store = createStore() 14 | 15 | // 同步路由状态(route state)到 store 16 | sync(store, router) 17 | 18 | const app = new Vue({ 19 | router, 20 | store, 21 | render: h => h(App) 22 | }) 23 | 24 | return { app, router, store } 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/images/code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lhz960904/webpack-vue-ssr/678bc859cfe79c08799e94081b0846bfd3fdc6b9/src/assets/images/code.png -------------------------------------------------------------------------------- /src/assets/styles/common.styl: -------------------------------------------------------------------------------- 1 | body 2 | background: #eee 3 | -------------------------------------------------------------------------------- /src/components/A/A.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 24 | -------------------------------------------------------------------------------- /src/components/B/B.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | 22 | -------------------------------------------------------------------------------- /src/entry-client.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { createApp } from './app' 3 | 4 | Vue.mixin({ 5 | beforeRouteUpdate (to, from, next) { 6 | const { asyncData } = this.$options 7 | if (asyncData) { 8 | asyncData({ 9 | store: this.$store, 10 | route: to 11 | }).then(next).catch(next) 12 | } else { 13 | next() 14 | } 15 | } 16 | }) 17 | 18 | const { app, router, store } = createApp() 19 | 20 | if (window.__INITIAL_STATE__) { 21 | store.replaceState(window.__INITIAL_STATE__) 22 | } 23 | 24 | router.onReady(() => { 25 | router.beforeResolve((to, from, next) => { 26 | const matched = router.getMatchedComponents(to) 27 | const prevMatched = router.getMatchedComponents(from) 28 | 29 | // 我们只关心非预渲染的组件 30 | // 所以我们对比它们,找出两个匹配列表的差异组件 31 | let diffed = false 32 | const activated = matched.filter((c, i) => { 33 | return diffed || (diffed = (prevMatched[i] !== c)) 34 | }) 35 | 36 | if (!activated.length) { 37 | return next() 38 | } 39 | // 这里如果有加载指示器(loading indicator),就触发 40 | Promise.all(activated.map(c => { 41 | if (c.asyncData) { 42 | return c.asyncData({ store, route: to }) 43 | } 44 | })).then(() => { 45 | // 停止加载指示器(loading indicator) 46 | next() 47 | }).catch(next) 48 | }) 49 | 50 | app.$mount('#app') 51 | }) 52 | -------------------------------------------------------------------------------- /src/entry-server.js: -------------------------------------------------------------------------------- 1 | import { createApp } from './app' 2 | 3 | export default context => { 4 | // 返回Promise 等待异步路由钩子函数或组件 5 | return new Promise((resolve, reject) => { 6 | const { app, router, store } = createApp() 7 | 8 | // 设置服务端router的位置 9 | router.push(context.url) 10 | 11 | // 等到 router 将可能的异步组件和钩子解析完 12 | router.onReady(() => { 13 | const matchedComponents = router.getMatchedComponents() 14 | // 如果匹配不到, 返回404 15 | if (!matchedComponents.length) { 16 | /* eslint-disable */ 17 | return reject({ code: 404 }) 18 | } 19 | // 对所有匹配的路由组件调用 `asyncData()` 20 | Promise.all(matchedComponents.map(Component => { 21 | if (Component.asyncData) { 22 | return Component.asyncData({ 23 | store, 24 | route: router.currentRoute 25 | }) 26 | } 27 | })).then(() => { 28 | // 在所有预取钩子(preFetch hook) resolve 后, 29 | // 我们的 store 现在已经填充入渲染应用程序所需的状态。 30 | // 当我们将状态附加到上下文, 31 | // 并且 `template` 选项用于 renderer 时, 32 | // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 33 | context.state = store.state 34 | resolve(app) 35 | }).catch(reject) 36 | }, reject) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/mixins/title-mixins.js: -------------------------------------------------------------------------------- 1 | function getTitle (vm) { 2 | // 组件可以提供一个 `title` 选项 3 | // 此选项可以是一个字符串或函数 4 | const { title } = vm.$options 5 | if (title) { 6 | return typeof title === 'function' 7 | ? title.call(vm) 8 | : title 9 | } else { 10 | return 'Vue SSR Demo' 11 | } 12 | } 13 | 14 | // 服务器端mixin 15 | const serverTitleMixin = { 16 | created () { 17 | const title = getTitle(this) 18 | if (title && this.$ssrContext) { 19 | this.$ssrContext.title = title 20 | } 21 | } 22 | } 23 | 24 | // 客户端mixin 25 | const clientTitleMixin = { 26 | mounted () { 27 | const title = getTitle(this) 28 | if (title) { 29 | document.title = title 30 | } 31 | } 32 | } 33 | 34 | // 可以通过 `webpack.DefinePlugin` 注入 `VUE_ENV` 35 | export default process.env.VUE_ENV === 'server' 36 | ? serverTitleMixin 37 | : clientTitleMixin 38 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | const A = () => import('components/A/A') 4 | const B = () => import('components/B/B') 5 | 6 | Vue.use(Router) 7 | 8 | export function createRouter () { 9 | return new Router({ 10 | mode: 'history', 11 | linkActiveClass: 'active', 12 | linkExactActiveClass: 'exact-active', 13 | routes: [ 14 | { 15 | path: '/', 16 | redirect: '/a' 17 | }, 18 | { 19 | path: '/a', 20 | name: 'A', 21 | component: A 22 | }, 23 | { 24 | path: '/b', 25 | name: 'B', 26 | component: B 27 | } 28 | ] 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | Vue.use(Vuex) 5 | 6 | export function createStore () { 7 | return new Vuex.Store({ 8 | state: { 9 | movie: {} 10 | }, 11 | actions: { 12 | fetchMovie ({ commit }, id) { 13 | return new Promise((resolve, reject) => { 14 | setTimeout(() => { 15 | resolve({ id }) 16 | }, 500) 17 | }).then(res => { 18 | commit('setMoive', { res }) 19 | }) 20 | } 21 | }, 22 | mutations: { 23 | setMoive (state, { res }) { 24 | Vue.set(state, 'movie', res) 25 | } 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lhz960904/webpack-vue-ssr/678bc859cfe79c08799e94081b0846bfd3fdc6b9/static/favicon.ico --------------------------------------------------------------------------------