作者:京東零售 姜欣
關于服務器端渲染方案,之前只接觸了基于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 -yyarn add vue vue-server-renderer -Syarn add express -Syarn 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 -Dyarn add clean-webpack-plugin @babel/preset-env -Dyarn add rimraf // 模擬linx的刪除命令,在build時先刪除distyarn add webpack-dev-middleware webpack-hot-middleware -Dyarn add chokidar -D //監聽變化yarn add memory-fs -Dyarn 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.$optionsif (asyncData) {this.dataPromise = asyncData({store: this.$store,route: this.$routeconst { 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 = falseconst 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.stateresolve(app)}).catch(reject)}, reject)
(3)在根目錄下創建server.js 文件
其中一個非常重要的api:createBundleRenderer,這個api上面有一個方法renderToString將代碼轉化成html字符串,主要功能就是把用webpack把打包后的服務端代碼渲染出來。具體了解可看官網bundle renderer指引。
// server.jsconst 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, readyPromiseconst 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: `charset="UTF-8">`,url: req.urlrenderer.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.configconst 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.configconst {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.configconst {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 bundlelet clientManifestlet templatelet readyconst readyPromise = new Promise(r => ready = r)template = fs.readFileSync(templatePath, 'utf8')const update = () => {if (bundle && clientManifest) {// 通知 server 進行渲染// 執行 createRenderer -> RenderToStringready()cb(bundle, {template,clientManifest// webpack -> entry-server -> bundleconst 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) returnbundle = 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) returnclientManifest = JSON.parse(readFile(devMiddleware.fileSystem,'vue-ssr-client-manifest.json'update()// fs -> templatePath -> templatechokidar.watch(templatePath).on('change', () => {template = fs.readFileSync(templatePath, 'utf8')console.log('template is updated');update()return readyPromisemodule.exports = setupServer
(5)配置搞完了接下來是代碼渲染
在src目錄下,新增index.template.html文件,將官網中的例子(地址:使用一個頁面模板 )復制,并進行一些更改
{{ title }}{{{ meta }}}
(6)再搞個store和api模擬一下數據請求
這里介紹一下一個很重要的東西asyncData 預取數據,預取數據是在vue掛載前,所以下文這里用了上下文來獲取store而不是this
asyncData: ({ store }) => { return store.dispatch('getDataAction') },
在src下創建api文件夾,并在下面創建data.js文件
// data.jsconst getData = () => new Promise((resolve) => {setTimeout(() => {resolve([id: 1,item: '測試1'},id: 2,item: '測試2'},}, 1000)export {getData
在src下創建store文件夾,并在下面創建index.js文件
// store.jsimport 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
這里是測試頁面一
{{item}}
鏈接到測試頁面二
Hello1.vue
這里是測試頁面二{{item}}
(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
(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
附上文件整體目錄結構
方案二:基于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.js5.傳統不易與管理底層框架邏輯(nuxt支持中間件管理,雖然我還沒探索過這里)
顯而易見這個上手就快多了,也不需要安裝一大堆依賴,如果用了sass需要安裝sass和sass-loader,反正我是用了
(1)創建一個項目 可選npm,npx,yarn,具體看官方文檔npm init nuxt-app
(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(不加會端口沖突),就可以看到
因為是get請求,可以直接點擊訪問可以看到mock的數據已經返回了
(4)頁面調用
先配置一下axIOS,推薦使用nuxt.js封裝的axios:"@nuxtjs/axios": "^5.13.6",然后再在nuxt.config.js文件中modules下面配置一下就可以使用了
modules: [ '@nuxtjs/axios'],
隨便找個接口調用一下
這里是測試頁面一
接口返回數據:{{posts}}
刷新下頁面就可以看到效果了,這里注意$axios有兩個get方法,一個$axios.get還會返回頭部等信息,另一個$axios.$get只返回結果
總結:
從頁面篇幅上應該也能看到哪個容易上手了,nuxt相對于插件來說限定了文件夾的結構,并通過此預定了一些功能,更好上手。預設了利用vue.js開發服務端渲染所需要的各種配置,并且提供了提供了靜態站點,異步數據加載,中間件支持,布局支持等