如何在Express应用中设置webpack-hot-middleware?

8
我希望能为我的 Express 应用启用 webpack HMR,但这并不是一个SPA应用。在视图方面,我使用的是 EJS 和 Vue,因此没有 vue-cli 的优势,所以必须手动配置 webpack 中的 SFC(.vue 文件)的 vue-loader。值得一提的是,我的工作流非常典型:我把主要的客户端资源(scss、js、vue 等)放在“resources”目录下,希望将它们打包到“public”目录中。
我的“webpack.config.js”文件:
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const webpack = require('webpack');

module.exports = {
    mode: 'development',
    entry: [
        './resources/css/app.scss',
        './resources/js/app.js',
        'webpack-hot-middleware/client'
    ],
    output: {
        path: path.resolve(__dirname, 'public/js'),
        publicPath: '/',
        filename: 'app.js',
        hotUpdateChunkFilename: "../.hot/[id].[hash].hot-update.js",
        hotUpdateMainFilename: "../.hot/[hash].hot-update.json"
    },
    module: {
        rules: [
            {
                test: /\.(sa|sc|c)ss$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            hmr: process.env.NODE_ENV === 'development'
                        }
                    },
                    'css-loader',
                    'sass-loader'
                ],
            },
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            }
        ]
    },
    plugins: [
        new VueLoaderPlugin(),
        new MiniCssExtractPlugin({
            filename: '../css/app.css'
        }),
        new webpack.HotModuleReplacementPlugin(),
        new webpack.NoEmitOnErrorsPlugin()
    ]
};

我的app/index.js文件:
import express from 'express';
import routes from './routes';
import path from 'path';
import webpack from 'webpack';
import devMiddleware from 'webpack-dev-middleware';
import hotMiddleware from 'webpack-hot-middleware';
const config = require('../webpack.config');
const compiler = webpack(config);

const app = express();

app.use(express.static('public'));
app.use(devMiddleware(compiler, {
    noInfo: true,
    publicPath: config.output.publicPath
}));
app.use(hotMiddleware(compiler));

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../resources/views'))

routes(app);

app.listen(4000);

export default app;

我的 package.json 文件中的 scripts 部分:
"scripts": {
    "start": "nodemon app --exec babel-node -e js",
    "watch": "./node_modules/.bin/webpack --mode=development --watch",
    "build": "./node_modules/.bin/webpack --mode=production"
}

我正在使用nodemon来重启服务器以应用服务器端代码的更改。 我保持一个标签页上开着npm run start,并在另一个标签页上开着npm run watch

在我的控制台中,我看到了HMR连接:

enter image description here

它只能第一次捕捉到更改,并显示以下警告:

忽略未被接受的模块更新 ./resources/css/app.scss -> 0

并且不会捕捉到后续更改。 我该如何解决这个问题?

复制存储库:https://bitbucket.org/tanmayd/express-test


我已经研究了很多次,但都不符合我的要求。然后我使用了nodemon,虽然我不知道它是否有帮助,但我还是抱有希望... - Ahmet Zeybek
是的,你应该。 - Ahmet Zeybek
抱歉,我不明白,我应该做什么? - Tanmay
你应该重新加载浏览器以查看更改。 - Ahmet Zeybek
@AmerllicA 线程已更新! - Tanmay
显示剩余5条评论
3个回答

5

由于它不是 SPA,并且您想要使用 EJS,这将需要服务器端渲染。在您的情况下并不容易,首先您需要重写 render 方法,然后需要添加那些由 webpack 生成的文件。

根据您描述中的存储库 https://bitbucket.org/tanmayd/express-test,您已经在正确的轨道上,但是您在 webpack 配置中合并了开发和生产环境设置。

由于我无法推送更改到您的存储库,我将列出以下受到更改或新建的文件。

1. 脚本和包

"scripts": {
    "start": "cross-env NODE_ENV=development nodemon app --exec babel-node -e js",
    "watch": "./node_modules/.bin/webpack --mode=development --watch",
    "build": "cross-env NODE_ENV=production ./node_modules/.bin/webpack --mode=production",
    "dev": "concurrently --kill-others \"npm run watch\" \"npm run start\"",
    "production": "cross-env NODE_ENV=production babel-node ./app/server.js"
  },

我安装了cross-env(因为我使用的是windows系统),cheerio(一个类似于nodejs jquery版本的工具,不错),以及style-loader(在使用webpack进行开发时必不可少)。

脚本:

  • start - 启动开发服务器
  • build - 生成生产文件
  • production - 使用从“build”生成的文件启动服务器

2. webpack.config.js - 已更改

添加了style-loader,以便webpack可以从捆绑包中提供您的CSS(请参见./resources/js/app.js - 第1行)。如果需要将样式提取到单独的文件中,则应在生产环境下使用MiniCssExtractPlugin

const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const webpack = require('webpack');

// Plugins
let webpackPlugins = [
    new VueLoaderPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin(),
];
// Entry points
let webpackEntryPoints = [
    './resources/js/app.js',
];

if (process.env.NODE_ENV === 'production') {

    webpackPlugins = [
        new VueLoaderPlugin()
    ];
    // MiniCssExtractPlugin should be used in production
    webpackPlugins.push(
        new MiniCssExtractPlugin({
            filename: '../css/app.css',
            allChunks: true
        })
    )

}else{

    // Development
    webpackEntryPoints.push('./resources/css/app.scss');
    webpackEntryPoints.push('webpack-hot-middleware/client');
}


module.exports = {
    mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
    entry: webpackEntryPoints,
    devServer: {
        hot: true
    },
    output: {
        path: path.resolve(__dirname, 'public/js'),
        filename: 'app.js',
        publicPath: '/'
    },
    module: {
        rules: [
            {
                test: /\.(sa|sc|c)ss$/,
                use: [
                    // use style-loader in development
                    (process.env.NODE_ENV === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader),
                    'css-loader',
                    'sass-loader',
                ],
            },
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            }
        ]
    },
    plugins: webpackPlugins
};

3. ./resources/js/app.js - 已更改

现在样式已经添加在第一行import "../css/app.scss";

4. ./app/middlewares.js - 新增

在这里你会发现2个中间件,overwriteRendererwebpackAssets

overwriteRenderer必须是路由之前的第一个中间件。它用于开发和生产环境。在开发环境中,它将抑制渲染后请求的结束,并将响应(res.body)填充为您的文件的已呈现字符串。在生产环境中,您的视图将作为布局,因此生成的文件将被添加到head(link)和body(script)中。

webpackAssets仅在开发环境中使用,必须是最后一个中间件。它将在res.body中添加webpack(app.css & app.js)内存中生成的文件。这是一个自定义版本的示例,可以在这里找到webpack-dev-server-ssr

const cheerio = require('cheerio');
let startupID = new Date().getTime();

exports.overwriteRenderer = function (req, res, next) {
    var originalRender = res.render;
    res.render = function (view, options, fn) {
        originalRender.call(this, view, options, function (err, str) {
            if (err) return fn(err, null); // Return the original callback passed on error

            if (process.env.NODE_ENV === 'development') {

                // Force webpack in insert scripts/styles only on text/html
                // Prevent webpack injection on XHR requests
                // You can tweak this as you see fit
                if (!req.xhr) {
                    // We need to set this header now because we don't use the original "fn" from above which was setting the headers for us.
                    res.setHeader('Content-Type', 'text/html');
                }

                res.body = str; // save the rendered string into res.body, this will be used later to inject the scripts/styles from webpack
                next();

            } else {

                const $ = cheerio.load(str.toString());
                if (!req.xhr) {

                    const baseUrl = req.protocol + '://' + req.headers['host'] + "/";
                    // We need to set this header now because we don't use the original "fn" from above which was setting the headers for us.
                    res.setHeader('Content-Type', 'text/html');

                    $("head").append(`<link rel="stylesheet" href="${baseUrl}css/app.css?${startupID}" />`)
                    $("body").append(`<script type="text/javascript" src="${baseUrl}js/app.js?${startupID}"></script>`)

                }

                res.send($.html());

            }

        });
    };
    next();
};
exports.webpackAssets = function (req, res) {

    let body = (res.body || '').toString();

    let h = res.getHeaders();

    /**
     * Inject scripts only when Content-Type is text/html
     */
    if (
        body.trim().length &&
        h['content-type'] === 'text/html'
    ) {

        const webpackJson = typeof res.locals.webpackStats.toJson().assetsByChunkName === "undefined" ?
            res.locals.webpackStats.toJson().children :
            [res.locals.webpackStats.toJson()];

        webpackJson.forEach(item => {

            const assetsByChunkName = item.assetsByChunkName;
            const baseUrl = req.protocol + '://' + req.headers['host'] + "/";
            const $ = require('cheerio').load(body.toString());

            Object.values(assetsByChunkName).forEach(chunk => {

                if (typeof chunk === 'string') {
                    chunk = [chunk];
                }
                if (typeof chunk === 'object' && chunk.length) {

                    chunk.forEach(item => {

                        console.log('File generated by webpack ->', item);

                        if (item.endsWith('js')) {

                            $("body").append(`<script type="text/javascript" src="${baseUrl}${item}"></script>`)

                        }

                    });

                }

                body = $.html();

            });

        });

    }

    res.end(body.toString());

}

5. ./app/index.js - 变更

这个文件主要用于开发。在这里,我添加了4中的中间件,并向devMiddleware添加了serverSideRender: true选项,使得webpack能够为我们提供在4中使用的资源。

import express from 'express';
import routes from './routes';
import path from 'path';
import devMiddleware from 'webpack-dev-middleware';
import hotMiddleware from 'webpack-hot-middleware';
import webpack from 'webpack';
const {webpackAssets, overwriteRenderer} = require('./middlewares');
const config = require('../webpack.config');
const compiler = webpack(config);
const app = express();

app.use(express.static('public'));
app.use(devMiddleware(compiler, {
    publicPath: config.output.publicPath,
    serverSideRender: true // enable serverSideRender, https://github.com/webpack/webpack-dev-middleware
}));
app.use(hotMiddleware(compiler));

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../resources/views'));

// This new renderer must be loaded before your routes.
app.use(overwriteRenderer); // Local render

routes(app);

// This is a custom version for server-side rendering from here https://github.com/webpack/webpack-dev-middleware
app.use(webpackAssets);

app.listen(4000, '0.0.0.0', function () {
    console.log(`Server up on port ${this.address().port}`)
    console.log(`Environment: ${process.env.NODE_ENV}`);
});

export default app;

6. ./app/server.js - 新版

这是生产版本。它主要是5的清理版本,所有开发工具都被删除了,只剩下overwriteRenderer

import express from 'express';
import routes from './routes';
import path from 'path';

const {overwriteRenderer} = require('./middlewares');
const app = express();

app.use(express.static('public'));
app.use(overwriteRenderer); // Live render

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../resources/views'));

routes(app);

app.listen(5000, '0.0.0.0', function() {
    if( process.env.NODE_ENV === 'development'){
        console.error(`Incorrect environment, "production" expected`);
    }
    console.log(`Server up on port ${this.address().port}`);
    console.log(`Environment: ${process.env.NODE_ENV}`);
});

嗨,我暂时离开所有设备。我会尽快验证更改。虽然从手机上看你的代码,目前看起来很好。唯一我无法理解的是cheerio + overwriteRenderer的目的。我猜我必须运行代码才能看到它的作用。我会回复你的,谢谢。 - Tanmay
cheerio 充当虚拟 DOM,它可以加载您的 HTML 字符串,然后您可以轻松选择/更改这些 HTML 元素,如果您了解一些基本的 jQuery,那么这很容易。在这种情况下,我仅使用它来添加来自 webpack 的脚本。但是,如果没有 cheerio,我将不得不使用某种替换来添加这些脚本,这将是一个麻烦事。overwriteRenderer 用于防止默认的 res.render 结束(并添加头)请求,我们需要在使用 cheerio 包含 webpack 脚本之后 手动 完成这个步骤。 - darklightcode

1

我曾经遇到过类似的问题,通过在 Node.js 中结合使用 xdotoolexec 解决了这个问题。 这可能也会对您有所帮助。

以下是摘要:

  • 编写一个 bash 脚本以重新加载浏览器。该脚本使用 xdotool 获取 Chrome 窗口并重新加载(该脚本也可用于 Firefox 和其他浏览器)。 相关的 SO 问题: 如何从终端重新加载 Google Chrome 标签页?
  • 在主文件(app/index.js)中,使用 exec 运行该脚本(在 app.listen 回调函数内)。当进行任何更改时,nodemon 将重新加载,导致脚本执行并重新加载浏览器。

Bash脚本:reload.sh
BID=$(xdotool search --onlyvisible --class Chrome)
xdotool windowfocus $BID key ctrl+r


app/index.js

...
const exec = require('child_process').exec;

app.listen(4000, () => {
    exec('sh script/reload.sh',
        (error, stdout, stderr) => {
            console.log(stdout);
            console.log(stderr);
            if (error !== null) {
                console.log(`exec error: ${error}`);
            }
        }
    );
});

export default app;

希望这有所帮助。如果有任何疑问,请回复。

1

实际上,您的复制品在声明方面存在一些问题,它们与您当前的问题无关,但请注意以下几点:

  1. 不要将构建文件推送到git服务器,只发送源文件。
  2. 在生产构建中,在webpack上设置清理器以清除public文件夹。
  3. 将文件夹和文件重命名为它们确切执行的名称。
  4. 在开发依赖项中安装nodemon

至于您的问题,我对您的复制品结构进行了许多更改,如果您没有时间阅读此答案,请查看this repo并获取您想要的内容。

  1. app/index.js更改为以下内容:
import express from 'express';
import routes from './routes';
import hotServerMiddleware from 'webpack-hot-server-middleware';
import devMiddleware from 'webpack-dev-middleware';
import hotMiddleware from 'webpack-hot-middleware';
import webpack from 'webpack';
const config = require('../webpack.config');
const compiler = webpack(config);

const app = express();

app.use(devMiddleware(compiler, {
    watchOptions: {
        poll: 100,
        ignored: /node_modules/,
    },
    headers: { 'Access-Control-Allow-Origin': '*' },
    hot: true,
    quiet: true,
    noInfo: true,
    writeToDisk: true,
    stats: 'minimal',
    serverSideRender: true,
    publicPath: '/public/'
}));
app.use(hotMiddleware(compiler.compilers.find(compiler => compiler.name === 'client')));
app.use(hotServerMiddleware(compiler));

const PORT = process.env.PORT || 4000;

routes(app);

app.listen(PORT, error => {
    if (error) {
        return console.error(error);
    } else {
        console.log(`Development Express server running at http://localhost:${PORT}`);
    }
});

export default app;
  1. 在项目中安装webpack-hot-server-middlewarenodemonvue-server-renderer,并将start脚本更改为以下package.json格式:
{
  "name": "express-test",
  "version": "1.0.0",
  "main": "index.js",
  "author": "Tanmay Mishu (tanmaymishu@gmail.com)",
  "license": "MIT",
  "scripts": {
    "start": "NODE_ENV=development nodemon app --exec babel-node -e ./app/index.js",
    "watch": "./node_modules/.bin/webpack --mode=development --watch",
    "build": "./node_modules/.bin/webpack --mode=production",
    "dev": "concurrently --kill-others \"npm run watch\" \"npm run start\""
  },
  "dependencies": {
    "body-parser": "^1.19.0",
    "csurf": "^1.11.0",
    "dotenv": "^8.2.0",
    "ejs": "^3.0.1",
    "errorhandler": "^1.5.1",
    "express": "^4.17.1",
    "express-validator": "^6.3.1",
    "global": "^4.4.0",
    "mongodb": "^3.5.2",
    "mongoose": "^5.8.10",
    "multer": "^1.4.2",
    "node-sass-middleware": "^0.11.0",
    "nodemon": "^2.0.2",
    "vue": "^2.6.11",
    "vue-server-renderer": "^2.6.11"
  },
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-preset-env": "^1.7.0",
    "babel-preset-stage-0": "^6.24.1",
    "concurrently": "^5.1.0",
    "css-loader": "^3.4.2",
    "mini-css-extract-plugin": "^0.9.0",
    "node-sass": "^4.13.1",
    "nodemon": "^2.0.2",
    "sass-loader": "^8.0.2",
    "vue-loader": "^15.8.3",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.11",
    "webpack": "^4.41.5",
    "webpack-cli": "^3.3.10",
    "webpack-dev-middleware": "^3.7.2",
    "webpack-hot-middleware": "^2.25.0",
    "webpack-hot-server-middleware": "^0.6.0"
  }
}
  1. 将整个webpack配置文件更改为以下内容:
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const webpack = require('webpack');

module.exports = [
    {
        name: 'client',
        target: 'web',
        mode: 'development',
        entry: [
            'webpack-hot-middleware/client?reload=true',
            './resources/js/app.js',
        ],
        devServer: {
            hot: true
        },
        output: {
            path: path.resolve(__dirname, 'public'),
            filename: 'client.js',
            publicPath: '/',
        },
        module: {
            rules: [
                {
                    test: /\.(sa|sc|c)ss$/,
                    use: [
                        {
                            loader: MiniCssExtractPlugin.loader,
                            options: {
                                hmr: process.env.NODE_ENV === 'development'
                            }
                        },
                        'css-loader',
                        'sass-loader'
                    ],
                },
                {
                    test: /\.vue$/,
                    loader: 'vue-loader'
                }
            ]
        },
        plugins: [
            new VueLoaderPlugin(),
            new MiniCssExtractPlugin({
                filename: 'app.css'
            }),
            new webpack.HotModuleReplacementPlugin(),
            new webpack.NoEmitOnErrorsPlugin(),
        ]
    },
    {
        name: 'server',
        target: 'node',
        mode: 'development',
        entry: [
            './resources/js/appServer.js',
        ],
        devServer: {
            hot: true
        },
        output: {
            path: path.resolve(__dirname, 'public'),
            filename: 'server.js',
            publicPath: '/',
            libraryTarget: 'commonjs2',
        },
        module: {
            rules: [
                {
                    test: /\.(sa|sc|c)ss$/,
                    use: [
                        {
                            loader: MiniCssExtractPlugin.loader,
                            options: {
                                hmr: process.env.NODE_ENV === 'development'
                            }
                        },
                        'css-loader',
                        'sass-loader'
                    ],
                },
                {
                    test: /\.vue$/,
                    loader: 'vue-loader'
                }
            ]
        },
        plugins: [
            new VueLoaderPlugin(),
            new MiniCssExtractPlugin({
                filename: 'app.css'
            }),
            new webpack.HotModuleReplacementPlugin(),
            new webpack.NoEmitOnErrorsPlugin(),
        ]
    }
];
  1. 资源文件夹内添加一个名为htmlRenderer.js的文件:
export default html => `
<!DOCTYPE html>
<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>Tanmay Mishu</title>
    <link rel="stylesheet" href="/app.css">
</head>
<body>
    <div id="app">${html}</div>
    <script src="/client.js"></script>
</body>
</html>`;
  1. 添加一个名为 appServer.js 的新文件,其代码应如下所示:
import Vue from 'vue';
import App from './components/App.vue';
import htmlRenderer from "../htmlRenderer";

const renderer = require('vue-server-renderer').createRenderer()

export default function serverRenderer({clientStats, serverStats}) {
    Vue.config.devtools = true;

    return (req, res, next) => {
        const app = new Vue({
            render: h => h(App),
        });

        renderer.renderToString(app, (err, html) => {
            if (err) {
                res.status(500).end('Internal Server Error')
                return
            }
            res.end(htmlRenderer(html))
        })
    };
}

现在,只需运行yarn start,即可同时享受服务器端渲染和热重载。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接