SSR服务-nodejs+ express4 + webpack4 +vue2(vue角度)

we* 目录

一、SSR与CSR对比
二、各环境准备与插件安装
三、express服务
四、SSR服务渲染实现
五、webpack与解析loader配置(本文重点)
六、问题记录
  • 开始正文
一、SSR与CSR对比
渲染模式 原理 优点 缺点
SSR-服务端渲染 客户端发送请求到服务端,服务端返回整个页面的HTML字符串给浏览器 SEO优化,首屏渲染,性能优化 性能全都依赖于服务器,前端界面开发可操作性不高
CSR-客户端渲染 接口请求数据,前端动态处理和生成页面需要的结构和页面展示 用户交互多场景,vue的生命周期全 整体加载完速度慢,HTTP请求损耗严重等

选型适用场景
SSR适用于首页或者静态网页, CSR适用于交互页面

二、各环境准备与插件安装

我们使用的是nodejs,所以需要准备node 于 npm 一般开发都会有,本文使用

 node -v   // v14.5.0
 npm -v   // 6.14.5
  1. 初始化项目并安装所需要插件

    npm init       // 初始化项目
    

    创建package.json文件

  2. 安装插件
    express - 服务端框架
    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服务
  1. 在根目录创建一个server.js

    // 后台服务serve
     const express = require("express");
     const app = express();
    
     app.get('*',(request,response) => {
         response.end("start server ok");
       })
    
    //  3000 端口号   192.168.18.83 本机IP
     const server = app.listen(3000, "192.168.18.83",  () => {
          const host = server.address().address;
          const port = server.address().port;
          console.log("服务已启动,访问地址为 http://%s:%s", host, port)
     })
    
  2. 在package.json中配置启动服务脚本

      "scripts": {
         "serve": "node server.js"
       },
    
  3. 运行脚本

     npm  run  serve
    
  4. 结果 :浏览器输入http://192.168.18.83:3000/ 地址可以看到 “start server ok”
    说明我们的后台服务启动OK啦

注意:过程中出现服务连接不上,请切换端口号,又可以你的端口被暂用

四、SSR服务渲染实现

SSR服务渲染分为简单版与加强版
简单版: 修改serve.js 文件 简单实现渲染

 // 后台服务serve
const express = require("express");
const app = express();

// 0. 导入vue 与 vue SSR渲染插件
const Vue = require("vue");
const vueServerRender = require("vue-server-renderer").createRenderer();


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

// 1. 创建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.转化为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("服务已启动,访问地址为 http://%s:%s", host, port)
})

结果:浏览器输入http://192.168.18.83:3000/ 地址可以看到 页面渲染成功

image.png

加强版-最终实现(直接看代码)
代码目录


image.png

serve.js 服务文件

 // 编译服务
 const compilerServer =  require("./build/compiler-server.js");

// 后台服务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;");

// 运行webpack 编译
compilerServer((serverBundle,template) => {
    // console.log('serve ----',serverBundle);
    let render = vueRender.createBundleRenderer(serverBundle,{
        template,
        //  每次创建一个独立的上下文
        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("服务已启动,访问地址为 http://%s:%s", host, port)
})

compiler-server.js 运行webpack 文件

  const webpackServeConfig = require("./webpack.server.conf.js");
  const webpack = require("webpack");
  const fs = require("fs");
  const path = require("path");
  //  读取内存中的.json文件
  const MFS = require("memory-fs");

  module.exports = (cb) =>{
  const webpackCompiler = webpack(webpackServeConfig);
  const mfs = new MFS();
   // 把文件保存到内存中
  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 服务端入口文件

 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回调函数
    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(()=> {
        const matchedComponents = router.getMatchedComponents();
        if(!matchedComponents.length){
            return reject({
                code:404,
            });
        }
        //   // Promise 应该 resolve 应用程序实例,以便它可以渲染
        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 服务配置

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

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

module.exports = merge(base,{
//  告知webpack,需要在node端运行
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 基础配置

'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']
                }
            }
        },
        {
            // 它会应用到普通的 `.js` 文件
            // 以及 `.vue` 文件中的 `<script>` 块
            test: /\.js$/,
            loader: 'babel-loader',
        },
        {
          test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
          loader: 'url-loader',
          options: {
            esModule: false, // 必须配置 不让出现 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};

// 它会应用到普通的 `.css` 文件
// // 以及 `.vue` 文件中的 `<style>` 块
 const cssLoader = {
     loader: 'css-loader',   // 解析 CSS 文件后,使用 import 加载,并且返回 CSS 代码
     options: {
        sourceMap: options.sourceMap,
         // 开启 CSS Modules 设置之后会被生成唯一id
         modules: false,
     }
 };

const postcssLoader = {
    loader: 'postcss-loader', // 使用 PostCSS 加载和转译 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">` 块都应用它
    sass: generalLoader('sass', {
        indentedSyntax: true,
        sassOptions: {
            indentedSyntax: true
        }
    }),
    // 普通的 `.scss` 文件和 `*.vue` 文件中的 `<style lang="scss">` 块都应用它
    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 实例
        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">关于</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>

基本所有的核心代码都已经贴出来,原理可有通过官网的一张图搞定,我没有复制

五、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是一个包含语法转换等诸多功能的工具链,通过这个工具链的使用可以使低版本的浏览器兼容最新的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调用

  • @babel/plugin-transform-runtime: "^7.10.5", // 减少包的体积
    @babel/runtime: "^7.10.5",
    使用plugin-transform-runtime. transform-runtime的转换是非侵入性的,也就是它不会污染你的原有的方法。遇到需要转换的方法它会另起一个名字,否则会直接影响使用库的业务代码

  • @babel/preset-env这是一个预设的插件集合,包含了一组相关的插件,Bable中是通过各种插件来指导如何进行代码转换。该插件包含所有es6转化为es5的翻译规则, 比如箭头函数转换插件

  • @babel/polyfill: "^7.10.4", @babel/preset-env只是提供了语法转换的规则,但是它并不能弥补浏览器缺失的一些新的功能,如一些内置的方法和对象,如Promise,Array.from等,此时就需要polyfill来做js得垫片,弥补低版本浏览器缺失的这些新功能

  • babel-loader : "^8.1.0", // babel-loader了,它作为一个中间桥梁

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

    1. loader 相关

"css-loader": "^3.6.0", // 解析 CSS 文件后,使用 import 加载,并且返回 CSS 代码
"file-loader": "^6.0.0", // ,生成的文件的文件名就是文件内容的 MD5 哈希值并会保留所引用资源的原始扩展名。
"less": "^3.12.2",
"less-loader": "^6.2.0", // 加载和转译 LESS 文件 与less 一起出现
"node-sass": "^4.14.1", // python 环境
"postcss-loader": "^3.0.0", // 适配浏览器 增加前缀
"postcss-scss": "^2.1.1",
"autoprefixer": "^9.8.5", // 与postcss-loader 一起出现 增加前缀
"sass-loader": "^9.0.2", // 加载和转译 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", // 加载和转译 Vue 组件
"vue-style-loader": "^4.1.2", // 加载和转译 Vue 组件 style 与style-loader 使用一个就可以

    1. webpack 相关

"webpack": "^4.43.0",
"webpack-cli": "^3.3.12", // 脚手架
"webpack-dev-server": "^3.11.0", // 开发环境
"webpack-merge": "^5.0.9", // 合并webpack配置
"webpack-node-externals": "^2.5.0" // 所需要的文件都是使用require引入,不需要把node_modules文件打包

    1. 其他

"memory-fs": "^0.5.0", // 内存操作
"mini-css-extract-plugin": "^0.9.0", // 分离css

六、问题记录

问题一: style 没有效果 class 变为唯一识别id


image.png

原因:css-loader 的 modules = true // 开启 CSS Modules 设置之后会被生成唯一id
解决版本: 修改为false

  const cssLoader = {
     loader: 'css-loader',   // 解析 CSS 文件后,使用 import 加载,并且返回 CSS 代码
     options: {
        sourceMap: options.sourceMap,
         // 开启 CSS Modules 设置之后会被生成唯一id
         modules: true,
     }
 };
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342