美文网首页前端开发专题
webpack-dev-server 核心概念

webpack-dev-server 核心概念

作者: Dabao123 | 来源:发表于2018-05-01 11:39 被阅读88次

    webpack-dev-server 核心概念

    Webpack 的 ContentBase vs publicPath vs output.path

    webpack-dev-server 会使用当前的路径作为请求的资源路径(所谓

    当前的路径

    就是运行 webpack-dev-server 这个命令的路径,如果对 webpack-dev-server 进行了包装,比如 wcf,那么当前路径指的就是运行 wcf 命令的路径,一般是项目的根路径),但是读者可以通过指定 content-base 来修改这个默认行为:

    webpack-dev-server --content-base build/
    

    这样 webpack-dev-server 就会使用 build 目录下的资源来处理静态资源的请求,如 css/ 图片等。content-base 一般不要和 publicPath、output.path 混淆掉。其中 content-base 表示静态资源的路径是什么,比如下面的例子:

    <!DOCTYPE html>
    <html>
    <head>
      <title></title>
      <link rel="stylesheet" type="text/css" href="index.css">
    </head>
    <body>
      <div id="react-content">这里要插入 js 内容</div>
    </body>
    </html>
    

    在作为 html-webpack-plugin 的 template 以后,那么上面的 index.css 路径到底是什么?是相对于谁来说?上面已经强调了:如果在没有指定 content-base 的情况下就是相对于当前路径来说的,所谓的当前路径就是在运行 webpack-dev-server 目录来说的,所以假如在项目根路径运行了这个命令,那么就要保证在项目根路径下存在该 index.css 资源,否则就会存在 html-webpack-plugin 的 404 报错。当然,为了解决这个问题,可以将 content-base 修改为和 html-webpack-plugin的html 模板一样的目录。

    上面讲到 content-base 只是和静态资源的请求有关,那么我们将其 publicPath 和 output.path 做一个区分。
    首先:假如将 output.path 设置为build(这里的 build 和 content-base 的 build 没有任何关系,请不要混淆),要知道 webpack-dev-server 实际上并没有将这些打包好的 bundle 写到这个目录下,而是存在于内存中的,但是我们可以假设(注意这里是假设)其是写到这个目录下的。
    然后:这些打包好的 bundle 在被请求的时候,其路径是相对于配置的publicPath来说的,publicPath 相当于虚拟路径,其映射于指定的output.path。假如指定的 publicPath 为 "/assets/",而且 output.path 为 "build",那么相当于虚拟路径 "/assets/" 对应于 "build"(前者和后者指向的是同一个位置),而如果 build 下有一个 "index.css",那么通过虚拟路径访问就是/assets/index.css。
    最后:如果某一个内存路径(文件写在内存中)已经存在特定的 bundle,而且编译后内存中有新的资源,那么我们也会使用新的内存中的资源来处理该请求,而不是使用旧的 bundle!比如有一个如下的配置:

    module.exports = {
      entry: {
        app: ["./app/main.js"]
      },
      output: {
        path: path.resolve(__dirname, "build"),
        publicPath: "/assets/",
        //此时相当于/assets/路径对应于 build 目录,是一个映射的关系
        filename: "bundle.js"
      }
    }
    

    那么我们要访问编译后的资源可以通过 localhost:8080/assets/bundle.js 来访问。如果在 build 目录下有一个 html 文件,那么可以使用下面的方式来访问 js 资源:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Document</title>
    </head>
    <body>
      <script src="assets/bundle.js"></script>
    </body>
    </html>
    

    此时会看到控制台输出如下内容:

    enter image description here

    主要关注下面两句输出:

    Webpack result is served from /assets/
    Content is served from /users/…./build
    

    之所以是这样的输出结果是因为设置了 contentBase 为 build,因为运行的命令为webpack-dev-server --content-base build/。所以,一般情况下:如果在 html 模板中不存在对外部相对资源的引用,我们并不需要指定 content-base,但是如果存在对外部相对资源 css/ 图片的引用,可以通过指定 content-base 来设置默认静态资源加载的路径,除非所有的静态资源全部在当前目录下

    webpack-dev-server 热加载(HMR)

    为 webpack-dev-server 开启 HMR 模式只需要在命令行中添加--hot,它会将 HotModuleReplacementPlugin 这个插件添加到 webpack 的配置中去,所以开启 HotModuleReplacementPlugin 最简单的方式就是使用 inline 模式。在 inline 模式下,只需要在命令行中添加--inline --hot就可以自动实现。
    这时候 webpack-dev-server 就会自动添加 webpack/hot/dev-server 入口文件到配置中,只是需要访问下面的路径就可以了 http://«host»:«port»/«path»。在控制台中可以看到如下的内容
    其中以 [HMR] 开头的部分来自于 webpack/hot/dev-server 模块,而以[WDS]开头的部分来自于 webpack-dev-server 的客户端。下面的部分来自于 webpack-dev-server/client/index.js 内容,其中的 log 都是以 [WDS] 开头的:

    function reloadApp() {
      if(hot) {
        log("info", "[WDS] App hot update...");
        window.postMessage("webpackHotUpdate" + currentHash, "*");
      } else {
        log("info", "[WDS] App updated. Reloading...");
        window.location.reload();
      }
    }
    

    而在 webpack/hot/dev-server 中的 log 都是以 [HMR] 开头的(它是来自于 Webpack 本身的一个 plugin):

    if(!updatedModules) {
            console.warn("[HMR] Cannot find update. Need to do a full reload!");
            console.warn("[HMR] (Probably because of restarting the webpack-dev-server)");
            window.location.reload();
            return;
          }
    

    那么如何在 nodejs 中使用 HMR 功能呢?此时需要修改三处配置文件:

    1.添加一个 Webpack 的入口点,也就是 webpack/hot/dev-server
    2.添加一个 new webpack.HotModuleReplacementPlugin() 到 webpack 的配置中
    3.添加 hot:true 到 webpack-dev-server 配置中,从而在服务端启动 HMR(可以在 cli 中使用 webpack-dev-server --hot)
    比如下面的代码就展示了 webpack-dev-server 为了实现 HMR 是如何处理入口文件的:

    if(options.inline) {
      var devClient = [require.resolve("../client/") + "?" + protocol + "://" + (options.public || (options.host + ":" + options.port))];
      //将 webpack-dev-server 的客户端入口添加到的 bundle 中,从而达到自动刷新
      if(options.hot)
        devClient.push("webpack/hot/dev-server");
        //这里是 webpack-dev-server 中对 hot 配置的处理
      [].concat(wpOpt).forEach(function(wpOpt) {
        if(typeof wpOpt.entry === "object" && !Array.isArray(wpOpt.entry)) {
          Object.keys(wpOpt.entry).forEach(function(key) {
            wpOpt.entry[key] = devClient.concat(wpOpt.entry[key]);
          });
        } else {
          wpOpt.entry = devClient.concat(wpOpt.entry);
        }
      });
    }
    

    满足上面三个条件的 nodejs 使用方式如下:

    var config = require("./webpack.config.js");
    config.entry.app.unshift("webpack-dev-server/client?http://localhost:8080/", "webpack/hot/dev-server");
    //条件一(添加了 webpack-dev-server 的客户端和 HMR 的服务端)
    var compiler = webpack(config);
    var server = new webpackDevServer(compiler, {
      hot: true //条件二(--hot 配置,webpack-dev-server 会自动添加 HotModuleReplacementPlugin)
      ...
    });
    server.listen(8080);
    

    webpack-dev-server 启动 proxy 代理

    webpack-dev-server 使用

    http-proxy-middleware

    去把请求代理到一个外部的服务器,配置的样例如下:

    proxy: {
      '/api': {
        target: 'https://other-server.example.com',
        secure: false
      }
    }
    // In webpack.config.js
    {
      devServer: {
        proxy: {
          '/api': {
            target: 'https://other-server.example.com',
            secure: false
          }
        }
      }
    }
    // Multiple entry
    proxy: [
      {
        context: ['/api-v1/**', '/api-v2/**'],
        target: 'https://other-server.example.com',
        secure: false
      }
    ]
    

    这种代理在很多情况下是很重要的,比如可以把一些静态文件通过本地的服务器加载,而一些 API 请求全部通过一个远程的服务器来完成。还有一个情景就是在两个独立的服务器之间进行请求分割,如一个服务器负责授权而另外一个服务器负责应用本身。下面给出日常开发中遇到的一个例子:
    (1)有一个请求是通过相对路径来完成的,比如地址是 "/msg/show.htm"。但是,在日常和生产环境下前面会加上不同的域名,如日常是 you.test.com 而生产环境是 you.inc.com。

    (2)那么比如现在想在本地启动一个 webpack-dev-server,然后通过 webpack-dev-server 来访问日常的服务器,而且日常的服务器地址是 11.160.119.131,所以会通过如下的配置来完成:

    devServer: {
        port: 8000,
        proxy: {
          "/msg/show.htm": {
            target: "http://11.160.119.131/",
            secure: false
          }
        }
      }
    

    此时当请求 "/msg/show.htm" 的时候,其实请求的真实 URL 地址为 "http//11.160.119.131/msg/show.htm"。

    (3)在开发环境中遇到一个问题,那就是:如果本地的 devServer 启动的地址为: "http://30.11.160.255:8000/" 或者常见的 "http://0.0.0.0:8000/" ,那么真实的服务器会返回一个 URL 要求登录,但是,将本地 devServer 启动到 localhost 上就不存在这个问题了(一个可能的原因在于 localhost 种上了后端需要的 cookie,而其他的域名没有种上 cookie,导致代理服务器访问日常服务器的时候没有相应的 cookie,从而要求权限验证)。其中指定 localhost 的方式可以通过

    wcf

    来完成,因为 wcf 默认可以支持 IP 或者 localhost 方式来访问。当然也可以通过添加下面的代码来完成:

    devServer: {
        port: 8000,
        host:'localhost',
        proxy: {
          "/msg/show.htm": {
            target: "http://11.160.119.131/",
            secure: false
          }
        }
      }
    

    (4)关于 webpack-dev-server 的原理,读者可以查看“反向代理为何叫反向代理”等资料来了解,其实正向代理和反向代理用一句话来概括就是:“正向代理隐藏了真实的客户端,而反向代理隐藏了真实的服务器”。而 webpack-dev-server 其实扮演了一个代理服务器的角色,服务器之间通信不会存在前端常见的同源策略,这样当请求 webpack-dev-server 的时候,它会从真实的服务器中请求数据,然后将数据发送给你的浏览器。

      browser => localhost:8080(webpack-dev-server无代理) => http://you.test.com
      browser => localhost:8080(webpack-dev-server有代理) => http://you.test.com
    

    上面的第一种情况就是没有代理的情况,在 localhost:8080 的页面通过前端策略去访问 http://you.test.com 会存在同源策略,即第二步是通过前端策略去访问另外一个地址的。但是对于第二种情况,第二步其实是通过代理去完成的,即服务器之间的通信,不存在同源策略问题。而我们变成了直接访问代理服务器,代理服务器返回一个页面,对于页面中某些满足特定条件前端请求(proxy、rewrite配置)全部由代理服务器来完成,这样同源问题就通过代理服务器的方式得到了解决。

    (5)上面讲述的是 target 是 IP 的情况,如果 target 要指定为域名的方式,可能需要绑定 host。比如下面绑定的 host:
    11.160.119.131 youku.min.com
    那么下面的 proxy 配置就可以采用域名了:

    devServer: {
        port: 8000,
        proxy: {
          "/msg/show.htm": {
            target: "http://youku.min.com/",
            secure: false
          }
        }
      }
    

    这和 target 绑定为 IP 地址的效果是完全一致的。总结一句话:“target 指定了满足特定 URL 的请求应该对应到哪台主机上,即代理服务器应该访问的真实主机地址”。
    其实 proxy 还可以通过配置一个 bypass() 函数的返回值视情况绕开一个代理。这个函数可以查看 HTTP 请求和响应及一些代理的选项。它返回要么是 false 要么是一个 URL 的 path,这个 path 将会用于处理请求而不是使用原来代理的方式完成。下面例子的配置将会忽略来自于浏览器的 HTTP 请求,它和 historyApiFallback 配置类似。浏览器请求可以像往常一样接收到 html 文件,但是 API 请求将会被代理到另外的服务器:

    proxy: {
      '/some/path': {
        target: 'https://other-server.example.com',
        secure: false,
        bypass: function(req, res, proxyOptions) {
          if (req.headers.accept.indexOf('html') !== -1) {
            console.log('Skipping proxy for browser request.');
            return '/index.html';
        }
      }
     }
    }
    

    对于代理的请求也可以通过提供一个函数来重写,这个函数可以查看或者改变 HTTP 请求。下面的例子就会重写 HTTP 请求,其主要作用就是移除 URL 前面的 /api 部分。

    proxy: {
      '/api': {
        target: 'https://other-server.example.com',
        pathRewrite: {'^/api' : ''}
      }
    }
    

    其中 pathRewrite 配置来自于 http-proxy-middleware。更多配置可以查看

    http-proxy-middleware 官方文档

    historyApiFallback 选项

    当使用 HTML 5 的 history API 的时候,当 404 出现的时候可能希望使用 index.html 来作为请求的资源,这时候可以使用这个配置 :historyApiFallback:true。然而,如果修改了 output.publicPath,就需要指定重定向的 URL,可以使用 historyApiFallback.index 选项。

    // output.publicPath: '/foo-app/'
    historyApiFallback: {
      index: '/foo-app/'
    }
    

    使用 rewrite 选项可以重新设置静态资源

    historyApiFallback: {
        rewrites: [
            // shows views/landing.html as the landing page
            { from: /^\/$/, to: '/views/landing.html' },
            // shows views/subpage.html for all routes starting with /subpage
            { from: /^\/subpage/, to: '/views/subpage.html' },
            // shows views/404.html on all other pages
            { from: /./, to: '/views/404.html' },
        ],
    },
    

    使用 disableDotRule 来满足一个需求,即如果一个资源请求包含一个
    .符号,那么表示是对某一个特定资源的请求,也就满足 dotRule。我们看看
    connect-history-api-fallback 内部是如何处理的:

    if (parsedUrl.pathname.indexOf('.') !== -1 &&
            options.disableDotRule !== true) {
          logger(
            'Not rewriting',
            req.method,
            req.url,
            'because the path includes a dot (.) character.'
          );
          return next();
        }
        rewriteTarget = options.index || '/index.html';
        logger('Rewriting', req.method, req.url, 'to', rewriteTarget);
        req.url = rewriteTarget;
        next();
      };
    

    也就是说,如果是对绝对资源的请求,也就是满足 dotRule,但是 disableDotRule(disable dot rule file request)为 false,表示我们会自己对满足 dotRule 的资源进行处理,所以不用定向到 index.html 中!如果 disableDotRule 为 true 表示不会对满足 dotRule 的资源进行处理,所以直接定向到 index.html!

    history({
      disableDotRule: true
    })
    

    webpack-dev-server 更多配置

    var server = new WebpackDevServer(compiler, {
      contentBase: "/path/to/directory",
      //content-base 配置 
      hot: true,
      //开启 HMR,由 webpack-dev-server 发送 "webpackHotUpdate" 消息到客户端代码
      historyApiFallback: false,
      //单页应用 404 转向 index.html
      compress: true,
      //开启资源的 gzip 压缩
      proxy: {
        "**": "http://localhost:9090"
      },
      //代理配置,来源于 http-proxy-middleware
      setup: function(app) {
         //webpack-dev-server 本身是 Express 服务器可以添加自己的路由
        // app.get('/some/path', function(req, res) {
        //   res.json({ custom: 'response' });
        // });
      },
      //为 Express 服务器的 express.static 方法配置参数 http://expressjs.com/en/4x/api.html#express.static
      staticOptions: {
      },
      //在 inline 模式下用于控制在浏览器中打印的 log 级别,如`error`, `warning`, `info` or `none`.
      clientLogLevel: "info",
      //不在控制台打印任何 log
      quiet: false,
      //不输出启动 log
      noInfo: false,
      //webpack 不监听文件的变化,每次请求来的时候重新编译
      lazy: true,
      //文件名称
      filename: "bundle.js",
      //webpack 的 watch 配置,每隔多少秒检查文件的变化
      watchOptions: {
        aggregateTimeout: 300,
        poll: 1000
      },
      //output.path 的虚拟路径映射
      publicPath: "/assets/",
      //设置自定义 http 头
      headers: { "X-Custom-Header": "yes" },
      //打包状态信息输出配置
      stats: { colors: true },
      //配置 https 需要的证书等
      https: {
        cert: fs.readFileSync("path-to-cert-file.pem"),
        key: fs.readFileSync("path-to-key-file.pem"),
        cacert: fs.readFileSync("path-to-cacert-file.pem")
      }
    });
    server.listen(8080, "localhost", function() {});
    // server.close();
    

    上面其他配置中,除了 filename 和 lazy 外都是容易理解的,那么下面继续分析下 lazy 和 filename 的具体使用场景。我们知道,在 lazy 阶段 webpack-dev-server 不是调用 compiler.watch 方法,而是等待请求到来的时候才会编译。源代码如下:

    startWatch: function() {
          var options = context.options;
          var compiler = context.compiler;
          // start watching
          if(!options.lazy) {
            var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
            context.watching = watching;
            //context.watching 得到原样返回的 Watching 对象
          } else {
           //如果是 lazy,表示我们不是 watching 监听,而是请求的时候才编译
            context.state = true;
          }
        }
    

    调用 rebuild 的时候会判断 context.state。每次重新编译后在 compiler.done 中会将 context.state 重置为 true!

    rebuild: function rebuild() {
          //如果没有通过 compiler.done 产生过 Stats 对象,那么设置 forceRebuild 为 true
          //如果已经有 Stats 表明以前 build 过,那么调用 run 方法
          if(context.state) {
            context.state = false;
            //lazy 状态下 context.state 为 true,重新 rebuild
            context.compiler.run(share.handleCompilerCallback);
          } else {
            context.forceRebuild = true;
          }
        },
    

    下面是当请求到来的时候我们调用上面的 rebuild 继续重新编译:

    handleRequest: function(filename, processRequest, req) {
          // in lazy mode, rebuild on bundle request
          if(context.options.lazy && (!context.options.filename || context.options.filename.test(filename)))
            share.rebuild();
          //如果 filename 里面有 hash,那么通过 fs 从内存中读取文件名,同时回调就是直接发送消息到客户端!!!
          if(HASH_REGEXP.test(filename)) {
            try {
              if(context.fs.statSync(filename).isFile()) {
                processRequest();
                return;
              }
            } catch(e) {
            }
          }
          share.ready(processRequest, req);
          //回调函数将文件结果发送到客户端
        },
    

    其中 processRequest 就是直接把编译好的资源发送到客户端:

    function processRequest() {
          try {
            var stat = context.fs.statSync(filename);
            //获取文件名
            if(!stat.isFile()) {
              if(stat.isDirectory()) {
                filename = pathJoin(filename, context.options.index || "index.html");
                //文件名
                stat = context.fs.statSync(filename);
                if(!stat.isFile()) throw "next";
              } else {
                throw "next";
              }
            }
          } catch(e) {
            return goNext();
          }
          // server content
          // 直接访问的是文件那么读取,如果是文件夹那么要访问文件夹
          var content = context.fs.readFileSync(filename);
          content = shared.handleRangeHeaders(content, req, res);
          res.setHeader("Access-Control-Allow-Origin", "*"); 
          // To support XHR, etc.
          res.setHeader("Content-Type", mime.lookup(filename) + "; charset=UTF-8");
          res.setHeader("Content-Length", content.length);
          if(context.options.headers) {
            for(var name in context.options.headers) {
              res.setHeader(name, context.options.headers[name]);
            }
          }
          // Express automatically sets the statusCode to 200, but not all servers do (Koa).
          res.statusCode = res.statusCode || 200;
          if(res.send) res.send(content);
          else res.end(content);
        }
      }
    

    所以,在 lazy 模式下如果我们没有指定文件名 filename,即每次请求的是那个 Webpack 输出文件(chunk),那么每次都是会重新 rebuild 的!但是如果指定了文件名,那么只有访问该文件名的时候才会 rebuild!

    相关文章

      网友评论

        本文标题:webpack-dev-server 核心概念

        本文链接:https://www.haomeiwen.com/subject/wdcsvxtx.html