摘要:二服務端渲染初體驗使用的服務端渲染功能,需要引入提供的服務端渲染模塊,其作用是創建一個渲染器,該渲染器可以將實例渲染成字符串。
詳解Vue服務端渲染 一、服務端渲染 - 簡介
所謂服務端渲染就是將代碼的渲染交給服務器,服務器將渲染好的html字符串返回給客戶端,再由客戶端進行顯示。
服務器端渲染的優點有利于SEO搜索引擎優化,因為服務端渲染是將渲染好的html字符串返回給了客戶端,所以其可以被爬蟲爬取到;
加快首屏渲染時間,不會出現白屏;
服務器端渲染的缺點SSR會占用更多的CPU和內存資源
Vue中一些常用的瀏覽器API可能無法使用,比如Vue的生命周期在服務器端渲染只能使用beforeCreate()和created(),因為服務端呈現的僅僅是html字符串是沒有所謂的mount的。
二、服務端渲染 - 初體驗使用Vue的服務端渲染功能,需要引入Vue提供的服務端渲染模塊vue-server-renderer,其作用是創建一個渲染器,該渲染器可以將Vue實例渲染成html字符串。
用Koa來搭建一個web服務器來實現:
① 目錄結構
② 創建一個server.js 文件
const Koa = require("koa"); const Router = require("koa-router"); const fs = require("fs"); const app = new Koa(); // 創建服務器端app實例 const router = new Router(); // 創建服務器端路由 const Vue = require("vue"); const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 const vm = new Vue({ // 創建Vue實例 data() { return {msg: "hello vm"} }, template: `{{msg}}` // 渲染器會將vue實例中的數據填入模板中并渲染成對應的html字符串 }); const template = fs.readFileSync("./server.template.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createRenderer({ template }); // 創建渲染器并以server.template.html作為html頁面的基本結構 router.get("/", async ctx => { // ctx.body = await render.renderToString(vm); ctx.body = await new Promise((resolve, reject) => { render.renderToString(vm, (err, html) => { // 將vm實例渲染成html并插入到server.template.html模板中 console.log(`${html}`); }); ); }); app.use(router.routes()); // 添加路由中間件 app.listen(3000, () => { console.log("node server listening on port 3000."); }); // 監聽3000端口
注意:
server.template.html文件中必須有 占位符,即將Vue實例vm渲染成的html字符串插入到占位符所在的位置;
render.renderToString(vm)方法不傳回調函數的時候返回的是Promise對象,但是如果傳入了回調函數,那么就返回void了, 推薦自己創建一個Promise函數;
Vue服務端渲染出來的字符串中會包含data-server-rendered="true"這樣一個標識,標識這是由Vue服務端渲染的結果字符
三、服務端渲染 - 引入Vue項目hello vm
上面初體驗中,我們已經實現了一個簡單的Vue服務端渲染,但是我們實際中Vue是一個很大的項目,里面是包含了很多組件的大型應用,而不是像初體驗中的一個簡單的Vue實例,所以我們必須引入一個Vue項目,包括Vue的入口文件main.js、App.vue、components、public/index.html等,如:
通過webpack來打包我們的整個Vue項目,webpack將以Vue的根實例main.js作為入口文件,打包出一個合并的最終的bundle.js和一個頁面入口index.html文件,該index.html文件引入bundle.js后就能加載整個Vue項目中的頁面以及頁面中的事件等等,這里我們的Vue項目是一個很簡單的模板項目,關鍵在于webpack的配置
// webpack.config.js
const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const VueLoader = require("vue-loader/lib/plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { entry: resolve("./src/main.js"), // webpack 入口, 即Vue的入口文件main.js output: { filename: "bundle.js", // 打包后輸出的結果文件名 path: resolve("./dist") // 打包后輸出結果存放目錄 }, resolve: { extensions: [".js", ".vue"] // 沒有寫擴展名的時候,解析順序 }, module: { rules: [ { test: /.js$/, use: { loader: "babel-loader", // 將所有的js文件通過babel-loader轉換為ES5代碼 options: { presets: ["@babel/preset-env"] } }, exclude: /node_modules/ }, { test: /.css$/, // 解析.vue文件中的css use: [ "vue-style-loader", "css-loader" ] }, { test: /.vue$/, // 解析.vue文件,需要配合其中的插件進行使用 use: "vue-loader" } ] }, plugins: [ new VueLoader(), // 解析.vue文件的插件 new HtmlWebpackPlugin({ filename: "index.html", // 打包后輸出的html文件名 template: resolve("./public/index.html") // 該模板文件在哪 }) ] }
打包輸出后的dist目錄中會出現兩個文件: bundle.js和index.html, 直接在本地點擊index.html文件即可執行并呈現整個Vue項目四、服務端渲染 - 將Vue項目分割為客戶端和服務端
① 在非服務端渲染的時候,我們使用的打包入口文件是main.js,其主要就是創建了一個Vue實例,并且渲染App.vue,然后將渲染好的App.vue掛載到index.html文件#app元素中,但是我們的服務端渲染是無法mount的,也就是說無法將渲染結果渲染到#app元素上,所以需要改造main.js文件
// 改造后的main.js文件
import Vue from "vue"; import App from "./App"; /** 1. main.js在服務端渲染中的作用就是提供一個Vue項目的根實例,所以導出一個函數 2. 讓客戶端和服務端都能獲取到Vue項目的根實例,然后根據需要, 3. 客戶端通過手動調用$mount()進行掛載 4. */ export default () => { const app = new Vue({ render: h => h(App) }); return {app}; // 返回整個Vue根實例 }
② 新建兩個入口文件: client-entry.js 和 server-entry.js
// client-entry.js
import createApp from "./main"; const {app} = createApp(); // 獲取到Vue項目根實例 app.$mount("#app"); // 將根實例掛載到#app上
此時將webpack.config.js的入口文件改成client-entry.js應該和之前是一樣的
// server-entry.js
import createApp from "./main"; /** * 服務端需要調用當前這個文件產生一個Vue項目的根實例 * 由于服務端與客戶端是1對多的關系,所以不能每個客戶端訪問都返回同一個Vue項目根實例 * 所以需要返回一個函數,該函數返回一個新的Vue項目根實例 * */ export default () => { const {app} = createApp(); // 獲取到Vue項目根實例 return app; }
為什么客戶端入口文件就不需要暴露一個一個函數?因為客戶端可以被訪問多次,即多次執行,每次執行返回的都是一個新的Vue項目實例了。而服務器只會啟動一次,但是卻需要每次客戶端訪問都返回一個新的Vue項目實例,所以必須放到函數中
③ 拆分webapck.config.js, 將其分成兩個配置文件,同樣一個用于客戶端,一個用于服務端打包
由于客戶端和服務端的webpack配置文件有很多是相同的,所以可以抽取出一個webpack.base.js
// webpack.base.js
const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const VueLoader = require("vue-loader/lib/plugin"); module.exports = { output: { filename: "[name].bundle.js", // 打包后輸出的結果文件名 path: resolve("./../dist/") // 打包后輸出結果存放目錄 }, resolve: { extensions: [".js", ".vue"] // 沒有寫擴展名的時候,解析順序 }, module: { rules: [ { test: /.js$/, use: { loader: "babel-loader", // 將所有的js文件通過babel-loader轉換為ES5代碼 options: { presets: ["@babel/preset-env"] } }, exclude: /node_modules/ }, { test: /.css$/, // 解析.vue文件中的css use: [ "vue-style-loader", "css-loader" ] }, { test: /.vue$/, // 解析.vue文件,需要配合其中的插件進行使用 use: "vue-loader" } ] }, plugins: [ new VueLoader(), // 解析.vue文件的插件 ] }
// webpack-client.js
const merge = require("webpack-merge"); const base = require("./webpack.base"); const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = merge(base, { entry: { client: resolve("./../src/client-entry.js"), // 給客戶端入口文件取名client,output的時候可以獲取到該名字動態輸出 }, plugins: [ new HtmlWebpackPlugin({ filename: "index.html", // 打包后輸出的html文件名 template: resolve("./../public/index.html") // 該模板文件在哪 }) ] });
// webpack-server.js
const merge = require("webpack-merge"); const base = require("./webpack.base"); const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = merge(base, { entry: { server: resolve("./../src/server-entry.js"), // 給客戶端入口文件取名client,output的時候可以獲取到該名字動態輸出 }, target: "node", // 給node使用 output: { libraryTarget: "commonjs2" // 把最終這個文件導出的結果放到module.exports上 }, plugins: [ new HtmlWebpackPlugin({ filename: "index.server.html", // 打包后輸出的html文件名 template: resolve("./../public/index.server.html"), // 該模板文件在哪 excludeChunks: ["server"] // 排除某個模塊, 不讓打包輸出后的server.bundle.js文件引入到index.server.html文件中 }) ] });
服務端webpack配置文件比較特殊,在output的時候需要配置一個libraryTarget,因為默認webpack輸出的時候是將打包輸出結果放到一個匿名自執行函數中的,通過將libraryTarget設置為commonjs2,就會將整個打包結果放到module.exports上;
服務端webpack打包后輸出的server.bundle.js文件不是直接引入到index.server.html文件中使用的,還需要經過處理渲染成html字符串才能插入到index.server.html文件中,所以打包輸出后,要在html-webpack-plugin中排除對該模塊的引用
由于webpack配置文件被分割,所以啟動webapck-dev-server的時候需要指定配置文件,在package.json文件中添加腳本
"scripts": { "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development", "client:build": "webpack --config ./build/webpack.client.js --mode development", "server:build": "webpack --config ./build/webpack.server.js --mode development" },
此時分別指向npm run client:build 和 npm run server:build即可在dist目錄下生成index.html、client.bundle.js, index.server.html、server.bundle.js,其中client.bundel.js被index.html引用,server.bundle.js沒有被index.server.html引入,index.server.html僅僅是拷貝到了dist目錄下,同時server.bundle.js的整個輸出結果是掛在module.exports下的
④ 將打包好的server.bundle.js交給服務器進行渲染并生成html字符串返回給客戶端,和之前初體驗一樣,創建一個web服務器,只不過,這次不是渲染一個簡單的Vue實例,而是渲染整個打包好的server.bundle.js
vue-server-renderer提供了兩種渲染方式:
和初體驗中的一樣,把server.bundle.js當作簡單Vue實例進行渲染,我們打包后server.bundle.js的內容都是掛到了module.exports上,所以我們可以直接require,require返回的結果是一個對象,該對象上只有一個屬性即default,屬性值為一個函數,執行該函數即可獲取整個Vue項目對應的Vue實例。
// 獲取server.bundle.js中的Vue實例進行渲染 const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 const template = fs.readFileSync("./server.template.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createRenderer({ template }); // 創建渲染器并以server.template.html作為html頁面的基本結構 router.get("/", async ctx => { const vm = require("./dist/server.bundle").default(); // 執行server.budle的default方法獲取Vue實例,每次請求獲取一個新的Vue實例 ctx.body = await new Promise((resolve, reject) => { render.renderToString(vm, (err, html) => { // 將vm實例渲染成html并插入到server.template.html模板中 if (err) reject(err); console.log(`${html}`); resolve(html); }); }); });
require server.bunlde.js之后調用default屬性獲取的方法,其實就是server.entry.js中導出的方法,這個方法可以接收路由參數,后面集成路由的時候會用到
通過vue-server-renderer提供的createBundleRenderer()方法進行渲染,該方法需要傳入server.bundle.js中的文件內容字符串, 再傳入模板html即可,所以需要讀取server.bundle.js中的內容:
// 直接渲染server.bundle.js const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 // 讀取server.bundle.js中的內容,即文件中的字符串 const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8"); const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createBundleRenderer(ServerBundle, { // 傳入server.bundle.js字符串創建渲染器 template }); router.get("/", async ctx => { ctx.body = await new Promise((resolve, reject) => { render.renderToString((err, html) => { // 將server.bundle.js渲染成html字符串 if (err) reject(err); resolve(html); }); }); });
render.renderToString()執行的時候內部也是要通過ServerBundle獲取到server.entry.js中導出的default()方法獲取到Vue項目實例進行渲染的,總之就是要獲取到Vue項目的實例進行渲染
重啟服務器,再次訪問,查看源碼,可以看到頁面已經不是一個空的基礎頁面了,而是真實包含html內容的頁面,但是仍然存在一個問題,那就是之前的事件并不起作用了,因為服務器將sever.bundle.js渲染成的是html字符串返回給客戶端的,是不包含事件的,其中的事件執行函數在client.bundle.js中,所以我們可以在index.server.html文件中通過script標簽顯式地引入client.bundle.js,如:
注意: 當訪問頁面的時候,就會向服務器請求client.bundle.js文件,所以服務器需要將client.bundle.js以靜態資源的方式發布出去。
剛才我們是手動在index.server.html中通過script標簽引入client.bundle.js, 非常的不方便,vue-server-renderer給我們提供了兩個插件,vue-server-renderer/client-plugin和vue-server-renderer/server-plugin,可以在webpack配置文件中引入,那么打包的時候,會分別生成兩個json文件,vue-ssr-client-manifest.json和vue-ssr-server-bundle.json,這兩個文件主要是生成客戶端和服務端bundle的對應關系,這樣就不需要我們收到引入client.bundle.js了。
之前是通過讀取server.bundle.js的內容來渲染的,現在可以直接requirevue-ssr-server-bundle.json文件即可,同時在渲染的時候再添加vue-ssr-client-manifest.json即可,如:
// 直接渲染server.bundle.js const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 // 讀取server.bundle.js中的內容,即文件中的字符串 // const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8"); const ServerBundle = require("./dist/vue-ssr-server-bundle.json"); const clientManifest = require("./dist/vue-ssr-client-manifest.json"); const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createBundleRenderer(ServerBundle, { // 傳入server.bundle.js字符串創建渲染器 template, clientManifest });
使用者兩個插件之后,就不會生成server.bundle.js文件了五、服務端渲染 - 集成路由
要集成路由,那么需要在Vue項目中加入路由功能,和客戶端路由配置一樣,只不過不是直接導出路由實例,而是和main.js一樣導出一個方法返回一個新的路由實例,如:
import Vue from "vue"; import VueRouter from "vue-router"; import Foo from "./components/Foo"; Vue.use(VueRouter); export default () => { // 導出函數返回路由實例 const router = new VueRouter({ mode: "history", routes: [ { path: "/", component: Foo }, { path: "/bar", component: () => import("./components/Bar.vue") } ] }); return router; }
然后在main.js中調用路由方法獲取路由實例并掛到Vue實例上,同時對外暴露,如:
export default () => { const router = createRouter(); const app = new Vue({ router, // 掛在路由實例到Vue實例上 render: h => h(App) }); return {app, router}; // 對外暴露路由實例 }
此時Vue項目已經實現路由功能,但是訪問的時候卻會報錯,The client-side rendered virtual DOM tree is not matching server-rendered content,即客戶端和服務端渲染的頁面不一致,之所以出現這種情況是因為,客戶端加了路由功能進行了相應的路由跳轉,但是服務端沒有進行路由跳轉,所以頁面會不一致,解決方法就是,服務器也要進行相應的路由跳轉
前面提到過createBundleRenderer()方法創建的渲染器在執行renderToString()方法的時候,可以傳遞一個context上下文對象,可以將客戶端的訪問url保存到context對象上,而這個context對象會傳到server.entry.js對外暴露函數中,然后在該函數中獲取路由進行相應跳轉即可,如:
// server.entry.js
export default (context) => { const {app, router} = createApp(); // 獲取到Vue項目根實例server console.log("相當于新創建了一個服務端"); router.push(context.url); // 在服務端進行路由跳轉 return app; }
此時再訪問頁面,就不會出現上述客戶端和服務端渲染頁面不一致的情況了,但是還有一個問題,那就是我們在瀏覽器中直接訪問路由路徑的時候,會提示404,因為我們服務器并沒有配置相應的路由,所以客戶端定義的路由路徑,需要在服務器端進行相應的配置
還有就是異步組件渲染的問題,我們現在的server.entry.js中是直接返回Vue實例的,同時在其中進行router跳轉,如果路由跳轉的那個是異步組件,可能還沒跳轉完成,就返回了Vue實例,而出現渲染異常的情況,所以我們要返回一個Promise對象,等路由跳轉完成后再返回Vue實例,如:
// 改造后的sever.entry.js
export default (context) => { return new Promise((resolve, reject) => { const {app, router} = createApp(); // 獲取到Vue項目根實例server router.push(context.url); router.onReady(() => { // 等路由跳轉完成 let matchs = router.getMatchedComponents(); if (matchs.length === 0) { reject({code: 404}); } resolve(app); }, reject); }); }
404頁面的處理,我們可以在router.onReady回調中進行處理,可以根據路由匹配結果進行提示,如果路由匹配結果為0,那么就是沒有匹配成功則reject一個錯誤,服務器捕獲到錯誤后進行404提示即可六、服務端渲染 - 集成Vuex
同樣,要集成Vuex,首先和客戶端渲染一樣,引入Vuex并創建store,只不過是對外暴露一個函數,然后在函數中返回新的store對象,如:
// store.js
import Vue from "vue"; import Vuex from "vuex"; Vue.use(Vuex); export default () => { const store = new Vuex.Store({ state: { name: "even" }, mutations: { changeName(state) { state.name = "lhb"; } }, actions: { changeName({commit}) { console.log("changeName action"); return new Promise((resolve, reject) => { setTimeout(() => { commit("changeName"); resolve(); }, 3000); }); } } }); return store; }
然后在main.js中引入并注入到Vue實例中,跟Vue根實例和路由一樣對外暴露。服務端渲染集成Vuex關鍵在于服務端渲染的時候執行mutaion或者action后,Vuex中數據僅在服務器端改變,所以需要將服務器端的狀態數據保存起來,實際上會保存到window對象的__INITIAL_STATE__屬性上,客戶端渲染的時候只需要從window.__INITIAL_STATE__數據中獲取到服務端Vuex的狀態然后進行替換即可。
① 在Foo.vue組件中添加一個asyncData()方法,用于派發action,如:
// Foo.vue
export default { asyncData(store) { // asyncData只在服務端執行 console.log("asyncData"); return store.dispatch("changeName"); } }
② 在server-entry.js中,如果匹配到了Foo.Vue組件,那么執行該組件的asyncData()方法,此時服務器端的Vuex的狀態就會發生改變,如:
// server-entry.js
export default (context) => { return new Promise((resolve, reject) => { console.log(context.url); const {app, router, store} = createApp(); // 獲取到Vue項目根實例server router.push(context.url); router.onReady(() => { // 等路由跳轉完成 let matchs = router.getMatchedComponents(); Promise.all(matchs.map((component) => { if (component.asyncData) { // 如果匹配的組件中含有asyncData方法則執行 return component.asyncData(store); // 服務器端Vuex狀態會發生改變 } })).then(() => { console.log("success"); context.state = store.state; // 服務器端store狀態改變后將其掛載到context上,然后會掛載到window的__INITIAL_STATE__上 resolve(app); }); if (matchs.length === 0) { reject({code: 404}); } }, reject); }); }
將服務器Vuex狀態保存的時候,必須是保存到context的state屬性上,服務器端渲染完成后,會添加一個script標簽其中只有一行代碼,就是將服務器端Vuex狀態保存到window.__INITIAL_STATE__上
③ 接下來就是需要客戶端去取出window.__INITIAL_STATE__中的狀態數據并替換,在store.js中返回store對象前進行判斷,如果是客戶端執行Vuex,那么取出window.__INITIAL_STATE__中的狀態數據并替換,如:
if(typeof window !== "undefined" && window.__INITIAL_STATE__) { // 如果是客戶端執行 store.replaceState(window.__INITIAL_STATE__); // 將服務器端store狀態替換掉客戶端狀態 } return store;
將Vuex中的數據顯示出來,此時再訪問Foo.vue就可以看到name數據的變化了,我們現在只有在進行服務器端渲染Foo.vue的時候才會執行asyncData()方法,數據才會發生變化,如果在客戶端進行渲染Foo.vue組,那么不會執行asyncData(),所以可以在Foo.vue組件mounted的時候派發一個相同的action進行數據改變即可
// Foo.vue
export default { mounted () { this.$store.dispatch("changeName"); } }
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/106368.html
摘要:依舊采取傳統的開發技術棧進行開發,同時在終端的運行體驗不輸。首先來看下前端開發框架目前與構成了三大最流行的前端開發框架,具有組件化以及三大特性,還學習的,引入了狀態管理模塊。 摘要: WEEX依舊采取傳統的web開發技術棧進行開發,同時app在終端的運行體驗不輸native app。其同時解決了開發效率、發版速度以及用戶體驗三個核心問題。那么WEEX是如何實現的?目前WEEX已經完全開...
摘要:后端主要使用的框架,數據庫采用。后臺管理登錄采用與后端進行登陸狀態的確認。本文首發于小站,這是一個積累和分享知識的個人博客 這篇文章擱置了很長時間,最終決定還是把它寫出來,給剛開始學習vue并且想用vue寫個人博客的同學一個參考。因為當初我也是參考了其他人分享的知識,從一個vue小白變成了一個入門級選手,并最終完成了這個個人博客的搭建工作,代碼已托管在Github-justJokee。...
摘要:無需使用服務器實時動態編譯,而是使用預渲染方式,在構建時簡單地生成針對特定路由的靜態文件。與可以部署在任何靜態文件服務器上的完全靜態單頁面應用程序不同,服務器渲染應用程序,需要處于運行環境。更多的服務器端負載。 目錄結構 -no-ssr-demo 未做ssr之前的項目代碼用于對比 -vuecli2ssr 將vuecli生成的項目轉為ssr -prerender-demo 使用prer...
閱讀 2219·2019-08-30 15:53
閱讀 2444·2019-08-30 12:54
閱讀 1187·2019-08-29 16:09
閱讀 718·2019-08-29 12:14
閱讀 746·2019-08-26 10:33
閱讀 2461·2019-08-23 18:36
閱讀 2950·2019-08-23 18:30
閱讀 2111·2019-08-22 17:09