重学webpack系列(五) -- webpack的devServer实践与原理

1,444 阅读6分钟

上一章重学webpack系列(四) -- webpack的plugins机制的解读我们讲解了webpack的插件系统的基本使用与插件的基本原理,并去讨论了实现一个插件的必要要求,其实webpack除了集成了loaderplugin还提供了,webpack-devServer功能,用于启动一个本地服务快速构建应用。那么本章我们就来一起讨论一下这个特性吧。

为什么要使用devServer

如果没有devServer,我们的开发方式将会是编写源码->webpack打包->文件引入->浏览器查看,这种方式整个周期比较长,而且容易出错devServer能够提供功能在于这几点。

  • http服务形式加载文件,而非文件手动引入。
  • 更加贴近生产环境,能够解决一些webApi形式在单文件下产生的问题。
  • 提供sourceMap支持,能够帮助我们更快速的定位错误。
  • 开发环境下能够自动编译自动刷新浏览器界面,提高开发效率。

devServer的核心原理

devServer可以启动一个http服务,在webpack构建的时候,监听文件,如果文件发生变化,将会启动webpack的自动编译。

image.png

关于devServer打包产物存放位置

devServer为了提高效率,webpack打包的结果会放在内存里面,httpServer能够从内存中读取这些文件。倘若放在硬盘中,那么每一次的读写会带来这些问题。

  • 硬盘读写需要消耗时间,而且比内存消耗的时间要很多,明显提高效率。
  • 多次读写硬盘,会给硬盘造成大量的磁盘碎片,减少使用寿命。

devServer的基础实践

devServer作为一个第三方的开发工具,自然也需要安装后再使用了。

安装: npm i webpack-dev-server -S

启动: npx webpack-dev-server

// webpack.config.js
module.export = {
    ...
    const path = require('path');
    module.exports = {
      //...
      devServer: {
        // 目录  
        static: {
          directory: path.join(__dirname, 'public'),
        },
        // 启用gzip压缩
        compress: true,
        // httpServer端口
        port: 9000,
        proxy: {
           "/api": {},
           ...
        },
        // 允许访问域名,设置白名单,all为全部允许
        allowedHosts: [
            'host.com',
            'xxx.com',
        ],
        // 日志设置,设置reconnect: n可以设置重连次数,
        client: {
            logging: 'info',
        },
        // 更多属性请查看:https://webpack.js.org/configuration/dev-server
      },
    };
}

// package.json
{
    ...
    "scripts":{
        "build": "webpack || webpack-cli --watch",
        "server": "webpack-dev-server || npx webpack-dev-server --open" // 启动命令
    }
    ...
    "devDependencies":{
        ...
        "webpack-dev-server": "^4.11.1" // 版本
        ...
    }
}

根据上面的配置你就可以在npm run server之后,开启一个本地httpServer

image.png

proxy

因为在实际开发中,肯定会去调用后端接口,根据同源策略,此时一定会有跨域的问题。所以在开发环境下我们一般处理跨域问题的方式是跨域资源共享CROS跨域中间件devServerApplymiddleWare,那么后者就是webpack-dev-server提供的功能。

module.export = {
    ...
    devServer: {
        ...
        proxy: {
            "/api": {
                // 目标地址
                target: "https://api.xx.xx.xx",
                // 必要时重写路径
                pathReWrite: {
                    "^/api":''
                },
                // 确保请求主机名是target中的主机名
                changeOrigin: true
            },
            ...
        }
    }
}

devServer源码

我们可以以几种方式启动devServer

  • webpack server
  • webpack-cli server
  • webpack-dev-server
  • npx webpack-dev-server
// package.json
{
    ...
    "scripts":{
        "build": "webpack || webpack-cli --watch",
        "server": "webpack server",
        // "server": "webpack-cli server"
        // "server": "webpack-dev-server"
        // "server": "npx webpack-dev-server"
    }
    ...
}

很明显上述启动分为两种,第一种使用webpack去启动devServer,第二种使用webpack-dev-server包启动devServer,两种的区别在于后者在使用之前会检查是否有安装过cli,没有的话会提醒你去安装。

// 如果没安装cli
if (!cli.installed) {
  const path = require("path");
  const fs = require("graceful-fs");
  const readLine = require("readline");
  
  // 给一个notify
  const notify = `CLI for webpack must be installed.\n  ${cli.name} (${cli.url})\n`;
  console.error(notify);
  

  // 检查本地.lock文件,确定包安装方式,npm yard pnpm
  /**
   * @type {string}
   */
  let packageManager;

  if (fs.existsSync(path.resolve(process.cwd(), "yarn.lock"))) {
    packageManager = "yarn";
  } else if (fs.existsSync(path.resolve(process.cwd(), "pnpm-lock.yaml"))) {
    packageManager = "pnpm";
  } else {
    packageManager = "npm";
  }

  const installOptions = [packageManager === "yarn" ? "add" : "install", "-D"];

  // 给个notice
  console.error(
    `We will use "${packageManager}" to install the CLI via "${packageManager} ${installOptions.join(
      " "
    )} ${cli.package}".`
  );

  // 用户选择yes or no
  const question = `Do you want to install 'webpack-cli' (yes/no): `;

  const questionInterface = readLine.createInterface({
    input: process.stdin,
    output: process.stderr,
  });
  
  process.exitCode = 1;
  
  // answer 回调
  questionInterface.question(question, (answer) => {
    questionInterface.close();
    
    // 输入 y 或者yes
    const normalizedAnswer = answer.toLowerCase().startsWith("y");

    if (!normalizedAnswer) {
      console.error(
        "You need to install 'webpack-cli' to use webpack via CLI.\n" +
          "You can also install the CLI manually."
      );

      return;
    }
    process.exitCode = 0;

    console.log(
      `Installing '${
        cli.package
      }' (running '${packageManager} ${installOptions.join(" ")} ${
        cli.package
      }')...`
    );
    
    // 执行runCommand => runCli
    runCommand(packageManager, installOptions.concat(cli.package))
      .then(() => {
        runCli(cli);
      })
      .catch((error) => {
        console.error(error);
        process.exitCode = 1;
      });
  });
} else {
  // 如果安装过了cli,直接执行runCli
  runCli(cli);
}

runCli

runCli的作用就是,找到依赖模块webpack-cli的路径,加载webpack-cli,所以webpack-dev-server还是依赖webpack-cli去执行的。

const runCli = (cli) => {
  if (cli.preprocess) {
    cli.preprocess();
  }
  const path = require("path"); 
  
  //node_modules/webpack-cli/package.json
  const pkgPath = require.resolve(`${cli.package}/package.json`);
  // 资源包信息
  const pkg = require(pkgPath);
  // 导入执行
  require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));
};

webpack-cli和webpack中的devServer处理

// webpack-cli.js
// 根据路径加载配置
devServer = await this.loadJSONFile("webpack-dev-server/package.json", false);

// webpack/bin/webpack.js
const CreateCompiler = rawOptions => {
    // 这里的rawOptions 得到的就是webpack.config.json里面的配置
    // entry:{}.output:{},module:{},devServer:{}...
    // 在getNormalizedWebpackOptions函数里面对所有配置文件雨webpack模块进行了处理
    const options = getNormalizedWebpackOptions(rawOptions);
    ...
    
    //getNormalizedWebpackOptions
    ...
    dependencies: config.dependencies,
    devServer: optionalNestedConfig(config.devServer, devServer => ({
        ...devServer
    })),
    devtool: config.devtool,
    entry:{...},
    ...
}

开启httpServer服务

webpack构建过程当中,devServer创建httpServernode本地服务器,与client建立socket联系,满足serverclient通信需要,当文件的hash变化的时候,会重新触发Compiler流程。在compilerdone钩子函数里调用sendStats发放向client发送okwarning消息,并同时发送向client发送hash值,在client保存下来。

  1. client接收到okwarning消息后调用reloadApp发布客户端检查更新事件(webpackHotUpdate
  // webpack/node_modules/webpack-dev-server/client/index.js
  ok: function ok() {
    sendMessage("Ok");  // 收到来自server的ok
    ...
    reloadApp(options, status);
  },
  
  warnings: function warnings(_warnings, params) {
    ...
    sendMessage("Warnings", x); // 收到来自server的warnings
    ...
    reloadApp(options, status);
  },

调用reloadApp,实际上调用的webpackHotUpdate事件,当然还会有一些额外的处理,比如本地刷新,是否允许热更新等。

function reloadApp(_ref, status) {
var hot = _ref.hot,
    liveReload = _ref.liveReload;
// status为isUnloading,不更新
if (status.isUnloading) {
  return;
}

// 更新client的文件的hash值
var currentHash = status.currentHash,
    previousHash = status.previousHash;

// 用去重的方法,检测每次发过来的hash是否还在维护,如果在维护表示文件未更新。
var isInitial = currentHash.indexOf(
/** @type {string} */
previousHash) >= 0;

if (isInitial) {
  return;
}
/**
 * @param {Window} rootWindow
 * @param {number} intervalId
 */

// 更新浏览器界面  
function applyReload(rootWindow, intervalId) {
  clearInterval(intervalId);
  log.info("App updated. Reloading...");
  rootWindow.location.reload();
}
// 额外的变量,用户判断是否需要热更新,刷新啥的
var search = self.location.search.toLowerCase();
var allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;
var allowToLiveReload = search.indexOf("webpack-dev-server-live-reload=false") === -1;

if (hot && allowToHot) {
  log.info("App hot update...");
  // 这里用到了发布订阅,发布一个webpackHotUpdate事件执行。
  hotEmitter.emit("webpackHotUpdate", status.currentHash);

  if (typeof self !== "undefined" && self.window) {
    // broadcast update to window
    self.postMessage("webpackHotUpdate".concat(status.currentHash), "*");
  }
} // 页面更新
else if (liveReload && allowToLiveReload) {
  var rootWindow = self; // use parent window for reload (in case we're in an iframe with no valid src)
  // 以异步宏任务的形式去更新window,最大程度不占用js线程,提高效率。
  var intervalId = self.setInterval(function () {
    if (rootWindow.location.protocol !== "about:") {
      // reload immediately if protocol is valid
      applyReload(rootWindow, intervalId);
    } else {
      rootWindow = rootWindow.parent;

      if (rootWindow.parent === rootWindow) {
        // if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
        applyReload(rootWindow, intervalId);
      }
    }
  });
}
}

处理到这里的时候,webpackhot部分监听到webpackHotUpdate事件去完成热更新流程了,直通车 >>> 重学webpack系列(七) -- webpack的HMR的实践

总结

本文讲述了webpack提供的devServer的功能与原理。

image.png webpack除了这些功能,还支持了sourceMap,他能够帮助开发者快速定位问题在源码中的位置。下一章我们就一起来看看这个特性吧, 直通车 >>> 重学webpack系列(六) -- webpack的sourceMap实践与原理