SSR服務(wù)-nodejs+ express4 + webpack4 +vue2(vue角度)

we* 目錄

一、SSR與CSR對比
二、各環(huán)境準(zhǔn)備與插件安裝
三、express服務(wù)
四、SSR服務(wù)渲染實(shí)現(xiàn)
五、webpack與解析loader配置(本文重點(diǎn))
六、問題記錄
  • 開始正文
一、SSR與CSR對比
渲染模式 原理 優(yōu)點(diǎn) 缺點(diǎn)
SSR-服務(wù)端渲染 客戶端發(fā)送請求到服務(wù)端,服務(wù)端返回整個頁面的HTML字符串給瀏覽器 SEO優(yōu)化,首屏渲染,性能優(yōu)化 性能全都依賴于服務(wù)器,前端界面開發(fā)可操作性不高
CSR-客戶端渲染 接口請求數(shù)據(jù),前端動態(tài)處理和生成頁面需要的結(jié)構(gòu)和頁面展示 用戶交互多場景,vue的生命周期全 整體加載完速度慢,HTTP請求損耗嚴(yán)重等

選型適用場景
SSR適用于首頁或者靜態(tài)網(wǎng)頁, CSR適用于交互頁面

二、各環(huán)境準(zhǔn)備與插件安裝

我們使用的是nodejs,所以需要準(zhǔn)備node 于 npm 一般開發(fā)都會有,本文使用

 node -v   // v14.5.0
 npm -v   // 6.14.5
  1. 初始化項(xiàng)目并安裝所需要插件

    npm init       // 初始化項(xiàng)目
    

    創(chuàng)建package.json文件

  2. 安裝插件
    express - 服務(wù)端框架
    vue
    vue-router - vue 框架
    vue-server-renderer - vue SSR渲染的核心框架

     "dependencies": {
     "express": "^4.17.1",
     "vue": "^2.6.11",
     "vue-router": "^3.3.4",
     "vue-server-renderer": "^2.6.11"
     }
    

注意:

  • 推薦使用 Node.js 版本 6+。
  • vue-server-renderer 和 vue 必須匹配版本。
  • vue-server-renderer 依賴一些 Node.js 原生模塊,因此只能在 Node.js 中使用
三、express服務(wù)
  1. 在根目錄創(chuàng)建一個server.js

    // 后臺服務(wù)serve
     const express = require("express");
     const app = express();
    
     app.get('*',(request,response) => {
         response.end("start server ok");
       })
    
    //  3000 端口號   192.168.18.83 本機(jī)IP
     const server = app.listen(3000, "192.168.18.83",  () => {
          const host = server.address().address;
          const port = server.address().port;
          console.log("服務(wù)已啟動,訪問地址為 http://%s:%s", host, port)
     })
    
  2. 在package.json中配置啟動服務(wù)腳本

      "scripts": {
         "serve": "node server.js"
       },
    
  3. 運(yùn)行腳本

     npm  run  serve
    
  4. 結(jié)果 :瀏覽器輸入http://192.168.18.83:3000/ 地址可以看到 “start server ok”
    說明我們的后臺服務(wù)啟動OK啦

注意:過程中出現(xiàn)服務(wù)連接不上,請切換端口號,又可以你的端口被暫用

四、SSR服務(wù)渲染實(shí)現(xiàn)

SSR服務(wù)渲染分為簡單版與加強(qiáng)版
簡單版: 修改serve.js 文件 簡單實(shí)現(xiàn)渲染

 // 后臺服務(wù)serve
const express = require("express");
const app = express();

// 0. 導(dǎo)入vue 與 vue SSR渲染插件
const Vue = require("vue");
const vueServerRender = require("vue-server-renderer").createRenderer();


app.get('*',(request,response) => {

// 1. 創(chuàng)建vue
const vueApp = new Vue({
    data:{
        message:"hello world,一切從hello world 開始"
    },
    template:`<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Document</title>
    </head>
    <body>
          這是SSR頁面
         <h2>{{message}}</h2>
    </body>
    </html>`
});

// 2.轉(zhuǎn)化為html
vueServerRender.renderToString(vueApp).then((html) => {
    response.end(html);
}).catch(error => console.log(error));
})

const server = app.listen(3000, "192.168.18.83",  () => {
    const host = server.address().address;
    const port = server.address().port;
    console.log("服務(wù)已啟動,訪問地址為 http://%s:%s", host, port)
})

結(jié)果:瀏覽器輸入http://192.168.18.83:3000/ 地址可以看到 頁面渲染成功

image.png

加強(qiáng)版-最終實(shí)現(xiàn)(直接看代碼)
代碼目錄


image.png

serve.js 服務(wù)文件

 // 編譯服務(wù)
 const compilerServer =  require("./build/compiler-server.js");

// 后臺服務(wù)serve
const express = require("express");
const app = express();

 // // vue --> html
 const vueRender = require("vue-server-renderer");

app.get('*',(request,response) => {
const {url} = request;
response.status(200);
response.header("Content-Type","text/html;charset-utf-8;");

// 運(yùn)行webpack 編譯
compilerServer((serverBundle,template) => {
    // console.log('serve ----',serverBundle);
    let render = vueRender.createBundleRenderer(serverBundle,{
        template,
        //  每次創(chuàng)建一個獨(dú)立的上下文
        renInNewContext:false
    });
    render.renderToString({
        url:request.url
    }).then((html) => {
        response.end(html);
    }).catch(error => {
        if (error) {
            if (error.code === 404) {
                response.status(404).end('Page not found')
            } else {
                response.status(500).end('Internal Server Error')
            }
        } else {
            // response.end(html)
            response.end(JSON.stringify(error));
        }

    });
 });
});

 // 端口號
const config = require("./config/config.js");

const server = app.listen(config.server.port, config.server.host, function () {
const host = server.address().address;
const port = server.address().port;
console.log("服務(wù)已啟動,訪問地址為 http://%s:%s", host, port)
})

compiler-server.js 運(yùn)行webpack 文件

  const webpackServeConfig = require("./webpack.server.conf.js");
  const webpack = require("webpack");
  const fs = require("fs");
  const path = require("path");
  //  讀取內(nèi)存中的.json文件
  const MFS = require("memory-fs");

  module.exports = (cb) =>{
  const webpackCompiler = webpack(webpackServeConfig);
  const mfs = new MFS();
   // 把文件保存到內(nèi)存中
  webpackCompiler.outputFileSystem = mfs; 

webpackCompiler.watch( {}, async (error, stats ) => {
    if(error) return console.log(error);
    stats = stats.toJson();
    stats.errors.forEach(error => console.log(error));
    stats.warnings.forEach(warning => console.log(warning));

    // //  獲取server bundle的json文件 -  為什么要從從這里取文件,為什么是這個文件 原因參考https://ssr.vuejs.org/zh/guide/bundle-renderer.html
    const serverBundlePath = path.join(webpackServeConfig.output.path,'vue-ssr-server-bundle.json');

    const serverBundle = JSON.parse(mfs.readFileSync(serverBundlePath,"utf-8"));
    // 獲取html模板路徑讀取文件
    const templateIndexPath =  path.join(__dirname,"../src/template/index.template.html");
    const template = fs.readFileSync(templateIndexPath,"utf-8");
    if(cb){
        cb(serverBundle,template);
    }
});
 };

entry-server.js 服務(wù)端入口文件

 import {createApp}  from "./app.js";

export default (context) => {
    return  new Promise( (resolve, reject) => {
      const { url } = context;
      let { app, router } = createApp(context);
      router.push(url);
    //  router回調(diào)函數(shù)
    // 等到 router 將可能的異步組件和鉤子函數(shù)解析完
    router.onReady(()=> {
        const matchedComponents = router.getMatchedComponents();
        if(!matchedComponents.length){
            return reject({
                code:404,
            });
        }
        //   // Promise 應(yīng)該 resolve 應(yīng)用程序?qū)嵗员闼梢凿秩?        resolve(app);
    },reject);
});
}

index.template.html 模板文件

 <!DOCTYPE html>
  <html>
<head>
  <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>模板</title>
 </head>
<body>
<div id="app">
      <!--vue-ssr-outlet-->
</div>
</body>
</html>

webpack.serve.conf.js 服務(wù)配置

const webpack = require("webpack");
const merge = require("webpack-merge").merge;
const base = require("./webpack.base.conf.js");
const utils = require('./utils');

  //  在服務(wù)端渲染中,所需要的文件都是使用require引入,不需要把node_modules文件打包
const webpackNodeExternals = require("webpack-node-externals");
const vueSSRServerPlugin = require("vue-server-renderer/server-plugin");

module.exports = merge(base,{
//  告知webpack,需要在node端運(yùn)行
target:"node",
entry:"./src/entry-server.js",
devtool:"source-map",
output:{
    filename:'server-bundle.js',
    libraryTarget: "commonjs2"
},
module: {
    rules: utils.styleLoader({ sourceMap:true, usePostCSS: true })
},
externals:[
    webpackNodeExternals()
],
plugins:[
    new vueSSRServerPlugin()
]
});

webpack.base.conf.js 基礎(chǔ)配置

'use strict'
const path = require("path");
const config = require("../config/config.js");
const VueLoaderPlugin = require('vue-loader/lib/plugin');
function resolve (dir) {
      return path.join(__dirname, '..', dir)
}
module.exports = {
    mode: 'development',
    entry: {
        app:"./src/entry-server.js",
    },
    output: {
        path: config.build.assetsRoot,
        publicPath: config.build.assetsPublicPath,
        filename: '[name].js',
},
resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
        'vue$': 'vue/dist/vue.esm.js',
        '@': resolve('src'),
    }
},
module: {
    rules: [
        {
            test: /\.vue$/,
            loader: 'vue-loader',
            options: {
                compilerOptions: {
                    preserveWhitespace: false
                },
                // 配置哪些引入路徑按照模塊方式查找
                transformAssetUrls: {
                    video: ['src', 'poster'],
                    source: 'src',
                    img: 'src',
                    image: ['xlink:href', 'href'],
                    use: ['xlink:href', 'href']
                }
            }
        },
        {
            // 它會應(yīng)用到普通的 `.js` 文件
            // 以及 `.vue` 文件中的 `<script>` 塊
            test: /\.js$/,
            loader: 'babel-loader',
        },
        {
          test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
          loader: 'url-loader',
          options: {
            esModule: false, // 必須配置 不讓出現(xiàn) src加載圖片為object
          }
        },

    ]
},
plugins:[
    new VueLoaderPlugin(),
],
 }

utils.js loader 加載工具類

const path = require("path");
const config = require("../config/config.js");

 // 加載路徑
exports.assetsPath = function (_path) {
    const assetsSubDirectory = process.env.NODE_ENV === 'production'
       ? config.build.assetsSubDirectory
        : config.dev.assetsSubDirectory

const resultPath = path.posix.join(assetsSubDirectory, _path);
console.log('------',resultPath);
return resultPath;
 };

// 加載所有的cssloader
exports.cssLoaders= function (options) {
 options = options || { sourceMap : false};

// 它會應(yīng)用到普通的 `.css` 文件
// // 以及 `.vue` 文件中的 `<style>` 塊
 const cssLoader = {
     loader: 'css-loader',   // 解析 CSS 文件后,使用 import 加載,并且返回 CSS 代碼
     options: {
        sourceMap: options.sourceMap,
         // 開啟 CSS Modules 設(shè)置之后會被生成唯一id
         modules: false,
     }
 };

const postcssLoader = {
    loader: 'postcss-loader', // 使用 PostCSS 加載和轉(zhuǎn)譯 CSS/SSS 文件
    options: {
        sourceMap: options.sourceMap,
        config: {
            path: 'postcss.config.js'
        }
    }
};

function generalLoader(loaderName, generalOptions) {
   const loaderList = [cssLoader];
   if(loaderName){
       const item = {
           loader: loaderName + '-loader',
           options:Object.assign({}, generalOptions, {
               //sourceMap: options.sourceMap
           })
       };
       loaderList.push(item);
   }
    if(options.usePostCSS){
        loaderList.push(postcssLoader);
    }
   // 是否分離css
    if(options.extract){

    }else {
        const item = {
            loader: "vue-style-loader"
        };
        return [item].concat(loaderList)
    }
}

const resultLoader = {
    css: generalLoader(),
    // 適配瀏覽器 增加前綴
    postcss: generalLoader('postcss',{

    }),
    less: generalLoader('less'),
    // 普通的 `.scss` 文件和 `*.vue` 文件中的 `<style lang="scss">` 塊都應(yīng)用它
    sass: generalLoader('sass', {
        indentedSyntax: true,
        sassOptions: {
            indentedSyntax: true
        }
    }),
    // 普通的 `.scss` 文件和 `*.vue` 文件中的 `<style lang="scss">` 塊都應(yīng)用它
    scss: generalLoader('sass'),
    // stylus: generalLoader('stylus'),
    // styl: generalLoader('stylus')
};
return resultLoader;
 };

exports.styleLoader = function (options) {
const outRules = [];
const loaders = exports.cssLoaders(options);
for (const extension in loaders) {
    const loader = loaders[extension];
    const  item = {
        test: new RegExp('\\.' + extension + '$'),
        use: loader
    };
    outRules.push(item);
    console.log('---------\n',item.test, item.use);
}

return outRules;
}

其次還有app.js App.vue 比較簡單

app.js

import Vue from "vue";
import createRouter from "./router/router.js";
import App from "./App.vue";
export function createApp(context) {
    const router = createRouter();
    const app = new Vue({
          router,
        // 注入 router 到根 Vue 實(shí)例
        render: h => h(App),
    });
    return {
        app,
        router
    };
  }

App.vue

<template>
<div id="app">
    <div class="app-title">我就是一個頁面</div>
    <div style="color: yellow">這個是測試style</div>
    <router-link to="/">首頁</router-link>-->
    <router-link to="/about">關(guān)于</router-link>
    <router-view/>
</div>
</template>

<script>
export default {
    name: 'App'
}
</script>
<style lang="scss" >
#app{
 text-align: center;
 color: red;
 .app-title{
     color: purple;
 }
}
</style>

基本所有的核心代碼都已經(jīng)貼出來,原理可有通過官網(wǎng)的一張圖搞定,我沒有復(fù)制

五、webpack與解析loader配置(本文的核心)

package.json文件說明:

 {
  "name": "ssr",
  "version": "1.0.0",
  "description": "ssr",
  "scripts": {
      "build-server": "webpack --config build/webpack.server.conf.js",
      "http": "node server.js"
   },
 "author": "ddd",
 "license": "ISC",
 "dependencies": {
      "express": "^4.17.1",
      "vue": "^2.6.11",
      "vue-router": "^3.3.4",
      "vue-server-renderer": "^2.6.11"
    },
  "devDependencies": {
      "@babel/core": "^7.10.5",
      "@babel/plugin-transform-runtime": "^7.10.5",
      "@babel/polyfill": "^7.10.4",
      "@babel/preset-env": "^7.10.4",
      "@babel/runtime": "^7.10.5",
      "autoprefixer": "^9.8.5",
      "babel-loader": "^8.1.0",
      "css-loader": "^3.6.0",
      "file-loader": "^6.0.0",
      "less": "^3.12.2",
      "less-loader": "^6.2.0",
      "memory-fs": "^0.5.0",
      "mini-css-extract-plugin": "^0.9.0",
      "node-sass": "^4.14.1",
      "postcss-loader": "^3.0.0",
      "postcss-scss": "^2.1.1",
      "sass-loader": "^9.0.2",
      "style-loader": "^1.2.1",
      "url-loader": "^4.1.0",
      "vue-loader": "^15.9.3",
      "vue-style-loader": "^4.1.2",
      "vue-template-compiler": "^2.6.11",
      "webpack": "^4.43.0",
      "webpack-cli": "^3.3.12",
      "webpack-dev-server": "^3.11.0",
      "webpack-merge": "^5.0.9",
       "webpack-node-externals": "^2.5.0"
 },
}

使用了這么多插件,這么多的loader 具體的作用是是什么,今天就來好好整整
里面存在4類

    1. babel 編譯
      babel是一個包含語法轉(zhuǎn)換等諸多功能的工具鏈,通過這個工具鏈的使用可以使低版本的瀏覽器兼容最新的javascript語法

"@babel/core": "^7.10.5",
"@babel/plugin-transform-runtime": "^7.10.5",
"@babel/runtime": "^7.10.5",
"@babel/polyfill": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"babel-loader": "^8.1.0",

  • @babel/core是babel的核心庫,所有的核心Api都在這個庫里,這些Api供babel-loader調(diào)用

  • @babel/plugin-transform-runtime: "^7.10.5", // 減少包的體積
    @babel/runtime: "^7.10.5",
    使用plugin-transform-runtime. transform-runtime的轉(zhuǎn)換是非侵入性的,也就是它不會污染你的原有的方法。遇到需要轉(zhuǎn)換的方法它會另起一個名字,否則會直接影響使用庫的業(yè)務(wù)代碼

  • @babel/preset-env這是一個預(yù)設(shè)的插件集合,包含了一組相關(guān)的插件,Bable中是通過各種插件來指導(dǎo)如何進(jìn)行代碼轉(zhuǎn)換。該插件包含所有es6轉(zhuǎn)化為es5的翻譯規(guī)則, 比如箭頭函數(shù)轉(zhuǎn)換插件

  • @babel/polyfill: "^7.10.4", @babel/preset-env只是提供了語法轉(zhuǎn)換的規(guī)則,但是它并不能彌補(bǔ)瀏覽器缺失的一些新的功能,如一些內(nèi)置的方法和對象,如Promise,Array.from等,此時就需要polyfill來做js得墊片,彌補(bǔ)低版本瀏覽器缺失的這些新功能

  • babel-loader : "^8.1.0", // babel-loader了,它作為一個中間橋梁

參考: https://www.tangshuang.net/7427.html

    1. loader 相關(guān)

"css-loader": "^3.6.0", // 解析 CSS 文件后,使用 import 加載,并且返回 CSS 代碼
"file-loader": "^6.0.0", // ,生成的文件的文件名就是文件內(nèi)容的 MD5 哈希值并會保留所引用資源的原始擴(kuò)展名。
"less": "^3.12.2",
"less-loader": "^6.2.0", // 加載和轉(zhuǎn)譯 LESS 文件 與less 一起出現(xiàn)
"node-sass": "^4.14.1", // python 環(huán)境
"postcss-loader": "^3.0.0", // 適配瀏覽器 增加前綴
"postcss-scss": "^2.1.1",
"autoprefixer": "^9.8.5", // 與postcss-loader 一起出現(xiàn) 增加前綴
"sass-loader": "^9.0.2", // 加載和轉(zhuǎn)譯 SASS/SCSS 文件 與node-sass 一起
"style-loader": "^1.2.1",
"url-loader": "^4.1.0", // url-loader 功能類似于 file-loader,但是在文件大?。▎挝?byte)低于指定的限制時,可以返回一個 DataURL。
比如加載圖片
"vue-loader": "^15.9.3", // 加載和轉(zhuǎn)譯 Vue 組件
"vue-style-loader": "^4.1.2", // 加載和轉(zhuǎn)譯 Vue 組件 style 與style-loader 使用一個就可以

    1. webpack 相關(guān)

"webpack": "^4.43.0",
"webpack-cli": "^3.3.12", // 腳手架
"webpack-dev-server": "^3.11.0", // 開發(fā)環(huán)境
"webpack-merge": "^5.0.9", // 合并webpack配置
"webpack-node-externals": "^2.5.0" // 所需要的文件都是使用require引入,不需要把node_modules文件打包

    1. 其他

"memory-fs": "^0.5.0", // 內(nèi)存操作
"mini-css-extract-plugin": "^0.9.0", // 分離css

六、問題記錄

問題一: style 沒有效果 class 變?yōu)槲ㄒ蛔R別id


image.png

原因:css-loader 的 modules = true // 開啟 CSS Modules 設(shè)置之后會被生成唯一id
解決版本: 修改為false

  const cssLoader = {
     loader: 'css-loader',   // 解析 CSS 文件后,使用 import 加載,并且返回 CSS 代碼
     options: {
        sourceMap: options.sourceMap,
         // 開啟 CSS Modules 設(shè)置之后會被生成唯一id
         modules: true,
     }
 };
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容