Webpack-devServer、resolve解析规则、区分开发环境

194 阅读16分钟

本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。

为什么要搭建本地服务器?

目前我们开发的代码,为了运行需要有两个操作:

  1. 操作一:npm run build,编译相关的代码;
  2. 操作二:通过live server或者直接通过浏览器,打开index.html代码,查看效果;

这个过程经常操作会影响我们的开发效率,我们希望可以做到,当文件发生变化时,可以自动的完成编译和展示。为了完成自动编译,webpack提供了几种可选的方式:

  • webpack watch 模式;
  • webpack-dev-server(常用);
  • webpack-dev-middleware;

Webpack watch

webpack给我们提供了watch模式,在该模式下,webpack依赖图中的所有文件,只要有一个发生了更新,那么代码将被重新编译,我们不需要手动去运行 npm run build 指令了。

如何开启watch呢?两种方式:

  • 方式一:在webpack.config.js导出的配置中,添加 watch: true;
  • 方式二:在启动webpack的命令中,添加 --watch的标识;

这里我们选择方式二,在package.json的 scripts 中添加一个 watch 的脚本:

webpack-dev-server

上面的方式可以监听到文件的变化,但是事实上它本身是没有自动刷新浏览器的功能的,当然,目前我们可以在VSCode中使用live-server插件来完成这样的功能。但是,我们希望在不使用live-server插件的情况下,可以具备live reloading(实时重新加载)的功能。

安装webpack-dev-server:

npm install webpack-dev-server -D

新增一个serve脚本:

  "scripts": {
    "build": "webpack",
    "serve": "webpack serve"
  },

运行 npm run serve,webpack就会帮我们自动创建一个本地服务,打印如下:

浏览器打开http://localhost:8080/即可访问我们的项目,这个服务就不是live server帮我们开启的了,而是webpack-dev-server帮我们开启的本地服务器。

这时候webpack-dev-server会帮我们的src文件夹进行打包,但是打包后并没有输出到build文件夹,所以build文件夹是空的。打包之后的资源存放到内存里面了,这时候webpack-dev-server再访问内存中打包后的资源文件效率会更高。 事实上webpack-dev-server使用了一个库叫memfs(memory-fs webpack自己写的)。

contentBase

在vue的打包过程中,如果我们将一些文件放到public的目录下,那么这个目录会被复制到dist文件夹中,这个复制的功能,我们可以使用CopyWebpackPlugin插件来完成。如果没有使用这个插件,那么就找不到一些资源了。我们就需要在webpack.config.js新增devServer,告知 dev server,从public里面查找文件。

devServer: {
  contentBase: "./public",
}

解释一下为什么要这样,因为开发过程中,一些资源(比如MP4文件)很大,如果每次都拷贝,这样编译的速度会很慢,所以开发阶段我们一般不使用CopyWebpackPlugin插件,打包阶段我们才拷贝。也就是:

上面我们实现了修改源码,自动打包,浏览器刷新的操作。但是有时候我们不希望浏览器刷新,因为浏览器中可能缓存了用户的操作信息,一刷新就没了,所以我们需要HMR。

认识模块热替换(HMR)

  • 什么是HMR呢? HMR的全称是Hot Module Replacement,翻译为模块热替换; 模块热替换是指在应用程序运行过程中,替换、添加、删除模块,而无需重新刷新整个页面;
  • HMR通过如下几种方式,来提高开发的速度: 不重新加载整个页面,这样可以保留某些应用程序的状态不丢失; 只更新需要变化的内容,节省开发的时间; 修改了css、js源代码,会立即在浏览器更新,相当于直接在浏览器的devtools中直接修改样式;
  • 如何使用HMR呢? 默认情况下,webpack-dev-server已经支持HMR,我们只需要开启即可; 在不开启HMR的情况下,当我们修改了源代码之后,整个页面会自动刷新,使用的是live reloading;

开启HMR

修改webpack的devServer配置,添加hot: true。

devServer: {
  hot: true,
}

另外还需要设置target,告诉webpack打包的时候是为了什么打包的:

module.exports = {
  target: "web",
  ......
}

浏览器可以看到如下效果:

但是你会发现,当我们修改了某一个模块的代码时,依然是刷新的整个页面,这是因为我们需要去指定哪些模块发生更新时,进行HMR。在main.js里面新增如下代码:

import "./js/element";

if (module.hot) {
  module.hot.accept("./js/element.js", () => {
    console.log("element模块发生更新了");
  })
}

框架的HMR

有一个问题:在开发其他项目时,我们是否需要经常手动去写入 module.hot.accpet相关的代码呢?比如开发Vue、React项目,我们修改了组件,希望进行热更新,这个时候应该如何去操作呢?

事实上社区已经针对这些有很成熟的解决方案了。比如vue开发中,我们使用vue-loader,此loader支持vue组件的HMR,提供开箱即用的体验。比如react开发中,有React Hot Loader,实时调整react组件(目前React官方已经弃用了,改成使用react refresh)。

HMR的原理

那么HMR的原理是什么呢?如何可以做到只更新一个模块中的内容呢? webpack-dev-server会创建两个服务:提供静态资源的服务(express)和Socket服务(net.Socket);

express server负责直接提供静态资源的服务(打包后的资源直接被浏览器请求和解析),HMR Socket Server,是一个socket的长连接。 长连接有一个最好的好处是建立连接后双方可以通信(服务器可以直接发送文件到客户端),当服务器监听到对应的模块发生变化时,会生成两个文件.json(manifest文件)和.js文件(update chunk),通过长连接,可以直接将这两个文件主动发送给客户端(浏览器),浏览器拿到两个新的文件后,通过HMR runtime机制,加载这两个文件,并且针对修改的模块进行更新。

HMR的原理图

关于devServer的配置,除了contentBase和hot,还有其他配置,如下:

host配置

  • host设置主机地址: 默认值是localhost,如果希望其他地方也可以访问,可以设置为 0.0.0.0;
  • localhost 和 0.0.0.0 的区别:
    • localhost:本质上是一个域名,通常情况下会被解析成127.0.0.1;
    • 127.0.0.1:回环地址(Loop Back Address),表达的意思其实是我们主机自己发出去的包,直接被自己接收; 正常的数据库包经过应用层 - 传输层 - 网络层 - 数据链路层 - 物理层,而回环地址是在网络层直接就被获取到了,是不会经过数据链路层和物理层的。比如我们监听 127.0.0.1时,在同一个网段下的主机中,通过ip地址是不能访问的。
    • 0.0.0.0:监听IPV4上所有的地址,再根据端口找到不同的应用程序,比如我们监听 0.0.0.0时,在同一个网段下的主机中,通过ip地址是可以访问的;

port、open、compress

  • port设置监听的端口,默认情况下是8080
  • open是否打开浏览器 默认值是false,设置为true会打开浏览器; 也可以设置为类似于 Google Chrome等值;

可能你也看过这样的写法:"serve": "webpack serve --open",其实写--open最后也会被解析成open: true

  • compress是否为静态文件开启gzip compression压缩 默认值是false,可以设置为true; 如果资源过大可以开启,这时候浏览器发现是gzip文件会自动解压。

Proxy

proxy [ˈprɑːksi] n. 代理; 代理人

proxy是我们开发中非常常用的一个配置选项,它的目的设置代理来解决跨域访问的问题。比如我们的一个api请求是 http://localhost:8888 ,但是本地启动服务器的域名是 http://localhost:8000 ,这个时候发送网络请求就会出现跨域的问题。

一般实际项目中,部署阶段跨域的问题都是和后端人员一块解决的,但是在开发阶段我们要自己解决。那么我们可以将请求先发送到一个代理服务器,代理服务器和API服务器没有跨域的问题,就可以解决我们的跨域问题了。

比如当前源地址是http://localhost:7777 ,访问的地址是http://localhost:8888/moment ,我们可以这么写:

proxy: { 
  "/api": "http://localhost:8888"
}

意思就是,当我们请求的地址是/api,就会被代理到http://localhost:8888 ,所以这时候我们发送的地址就要是/api/moment,但是这么写会报错:404 Not Found。这是因为我们请求/api/moment的时候默认会给我们加上域名,也就是http://localhost:8888/api/moment ,这时候肯定请求不到,如果不希望传递/api,则要重写路径pathRewrite: { "^/api": "" },意思就是/api开头的路径(也就是/api/moment)替换为空字符串,这时候http://localhost:8888/api/moment 就变成了http://localhost:8888/moment ,就能请求到了。

devServer: {
  contentBase: "./public",
  hot: true,
  // host: "0.0.0.0",
  port: 7777,
  open: true,
  compress: true,
  historyApiFallback: true
  proxy: {
    "/api": {
      target: "http://localhost:8888",
      pathRewrite: {
        "^/api": ""
      },
      secure: false,
      changeOrigin: true
    }
  }
}
  • target:表示的是代理到的目标地址,比如 /api/moment会被代理到 http://localhost:8888/api/moment
  • pathRewrite:默认情况下,我们的 /api 也会被写入到URL中,如果希望删除,可以使用pathRewrite;
  • secure:默认true,是安全的,表示默认情况下不接收在HTTPS上运行且证书无效的服务器。如果我们没证书,并且还想代理到HTTPS的服务器,可以设置false。
  • changeOrigin:它表示是否更新代理后请求的headers中host地址; 意思就是虽然我们通过代理转发了,但是我们的源还是http://localhost:7777 ,有些服务器会对源进行校验的,如果不是合适的源就会拒绝,所以我们设置改变源(changeOrigin: true),这时候我们的源就变成了http://localhost:8888了 ,就没问题了。

注意:生产环境我们就换其他方式去解决跨域了,就不需要proxy这些东西了。

historyApiFallback

在知道historyApiFallback作用之前,先要明白,为什么SPA页面在路由跳转之后,进行页面刷新时,返回404的错误?

:当我们在浏览器中输入地址的时候,会向服务器请求资源,资源请求下来后,浏览器会执行js代码,然后构建前端路由,显示相应的组件,这时候如果我们进行页面跳转了,也会渲染对应的组件,浏览器的url地址也会改变,这时候我们再刷新界面,由于url地址改变了,服务器就没有这些资源,所以就会显示404错误。

解决办法就是服务端进行Nginx配置,Nginx配置的截图如下:

如果服务端匹配不到对应的路径就会返回index.html文件,这样我们的界面就不会404了。

但是我们开发中启动的是本地服务器,SPA页面在路由跳转之后,进行页面刷新,也会返回404的错误。所以开发中我们把historyApiFallback设置为true,这样本地服务器如果找不到资源的时候,默认就会返回根路径的index.html文件,这就和服务端Nginx配置的效果是一样的。

事实上devServer中实现historyApiFallback功能是通过 connect-history-api-fallback 库实现的,可以自己查看文档。

实际我们开发中也没有进行配置,但是刷新的时候也不会有404错误,这是因为webpack的devServer默认帮我们配置了historyApiFallback: true,如下:

那么如果我们真想把historyApiFallback改成false,还要去修改源码吗?修改源码固然可以,但是不推荐,我们可以新建vue.config.js文件,这个文件的内容会被读取最后合并到webpack内部,代码如下:

module.exports = {
  configureWebpack: {
    devServer: {
      historyApiFallback: true
    }
  }
}

如果把true改成false,重新运行项目,刷新,就会发现报错了:

historyApiFallback可以传如下值:

  • boolean值,默认是false,如果设置为true,那么在刷新时,返回404错误时,会自动返回 index.html 的内容。
  • object类型的值,可以配置rewrites属性(了解),可以配置from来匹配路径,决定要跳转到哪个页面。

resolve模块解析规则

resolve用于设置模块如何被解析: 在开发中我们会有各种各样的模块依赖,这些模块可能来自于自己编写的代码,也可能来自第三方库。resolve可以帮助webpack从每个 require/import 语句中,找到需要引入到合适的模块代码。webpack 使用 enhanced-resolve 来解析文件路径。

webpack能解析三种文件路径:

  1. 绝对路径:由于已经获得文件的绝对路径,因此不需要再做进一步解析。
  2. 相对路径:在这种情况下,使用 import/require 的资源文件所处的目录,被认为是上下文目录。在 import/require 中给定的相对路径,会拼接此上下文路径,来生成模块的绝对路径。
  3. 模块路径:在 resolve.modules中指定的所有目录检索模块,默认值是 ['node_modules'],所以默认会从node_modules中查找文件。

比如:import { createApp } from 'vue';一看是个模块,就直接去从node_modules中查找文件了。

resolve的一些属性:


  resolve: {
    mainFiles: ['index'], // 默认值,上面讲过了
    modules: ['node_modules'], // 默认值,上面讲过了
    extensions: [".js", ".json", ".mjs", ".vue", ".ts", ".jsx", ".tsx"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
      "js": path.resolve(__dirname, "./src/js")
    }
  },

extensions和alias配置

extensions是解析到文件时自动添加扩展名: 默认值是 ['.wasm', '.mjs', '.js', '.json'],所以如果我们代码中想要添加加载 .vue 或者 jsx 或者 ts 等文件时,我们必须自己写上扩展名。

另一个非常好用的功能是配置别名alias: 特别是当我们项目的目录结构比较深的时候,或者一个文件的路径可能需要 ../../../这种路径片段,我们可以给某些常见的路径起一个别名。

详细使用如上。

确定是文件还是文件夹

  • 如果是一个文件:
    • 如果文件具有扩展名,则直接打包文件;
    • 否则,将使用 resolve.extensions选项作为文件扩展名解析;
  • 如果是一个文件夹:
    • 会在文件夹中根据 resolve.mainFiles配置选项中指定的文件顺序查找文件;resolve.mainFiles的默认值是 ['index']; 再根据 resolve.extensions来解析扩展名。

比如我们想引入我们自己写的math.js文件,通过import { sum } from "js/math;",使用的是相对路径,虽然没有写math.js,但是一看它是个文件,就去extensions的默认值 ['.wasm', '.mjs', '.js', '.json']里面一个一个查找后缀名,如果能查找能匹配到就加载。 如果一看是一个文件夹,就会在文件夹中根据 resolve.mainFiles 配置选项中指定的文件顺序查找,resolve.mainFiles的默认值是 ['index'],再根据 resolve.extensions来解析扩展名。

最终的webpack.config.js文件如下:

const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { DefinePlugin } = require("webpack");
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader/dist/index');

module.exports = {
  target: "web",
  mode: "development",
  devtool: "source-map",
  entry: "./src/main.js",
  output: {
    path: path.resolve(__dirname, "./build"),
    filename: "js/bundle.js",
  },
  devServer: {
    contentBase: "./public",
    hot: true,
    host: "0.0.0.0",
    port: 7777,
    open: true,
    // compress: true,
    proxy: {
      "/api": {
        target: "http://localhost:8888",
        pathRewrite: {
          "^/api": ""
        },
        secure: false,
        changeOrigin: true
      }
    }
  },
  resolve: {
    // mainFiles: ['index'],
    // modules: ['node_modules'],
    extensions: [".js", ".json", ".mjs", ".vue", ".ts", ".jsx", ".tsx"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
      "js": path.resolve(__dirname, "./src/js")
    }
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        test: /\.less$/,
        use: ["style-loader", "css-loader", "less-loader"],
      },
      // },
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        type: "asset",
        generator: {
          filename: "img/[name]_[hash:6][ext]",
        },
        parser: {
          dataUrlCondition: {
            maxSize: 10 * 1024,
          },
        },
      },
      {
        test: /\.(eot|ttf|woff2?)$/,
        type: "asset/resource",
        generator: {
          filename: "font/[name]_[hash:6][ext]",
        },
      },
      {
        test: /\.js$/,
        loader: "babel-loader"
      },
      {
        test: /\.vue$/,
        loader: "vue-loader"
      }
    ],
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: "./public/index.html",
      title: "哈哈哈哈"
    }),
    new DefinePlugin({
      BASE_URL: "'./'",
      __VUE_OPTIONS_API__: true,
      __VUE_PROD_DEVTOOLS__: false
    }),
    // new CopyWebpackPlugin({
    //   patterns: [
    //     {
    //       from: "public",
    //       to: "./",
    //       globOptions: {
    //         ignore: [
    //           "**/index.html"
    //         ]
    //       }
    //     }
    //   ]
    // }),
    new VueLoaderPlugin()
  ],
};

如何区分开发环境

现在我们的脚本是:

"scripts": {
  "build": "webpack",
  "serve": "webpack serve"
}

当我们执行npm run build进行打包的时候,会首先加载webpack.config.js,但是某些配置是在开发环境需要使用的,某些配置是在生成环境需要使用的,当然某些配置是在开发和生成环境都会使用的。

所以,我们最好对配置进行划分,方便我们维护和管理。那么,在启动时如何可以区分不同的配置呢?

  • 方案一:编写两个不同的配置文件,开发和生成时,分别加载不同的配置文件即可;
  • 方式二:使用相同的一个入口配置文件,通过设置参数来区分它们;

一般我们使用方案一,新建一个config文件夹,编写三个文件,如下:

然后修改脚本如下:

"scripts": {
  "build": "webpack --config ./config/webpack.prod.config.js",
  "serve": "webpack serve --config ./config/webpack.dev.config.js"
},

先把只属于开发、只属于生产、公共的代码编写好。然后安装一个webpack-merge插件进行合并,安装插件:

npm install webpack-merge -D

然后在webpack.dev.config.js里面编写如下代码进行合并:

const { merge } = require('webpack-merge');
const commonConfig = require('./webpack.comm.config');

module.exports = merge(commonConfig, {
  mode: "development",
  devtool: "source-map",
  devServer: {
    contentBase: "./public",
    hot: true,
    // host: "0.0.0.0",
    port: 7777,
    open: true,
    // compress: true,
    proxy: {
      "/api": {
        target: "http://localhost:8888",
        pathRewrite: {
          "^/api": ""
        },
        secure: false,
        changeOrigin: true
      }
    }
  },
})

webpack.prod.config.js文件:

const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const CopyWebpackPlugin = require('copy-webpack-plugin');
const {merge} = require('webpack-merge');
const commonConfig = require('./webpack.comm.config');

module.exports = merge(commonConfig, {
  mode: "production",
  plugins: [
    new CleanWebpackPlugin(),
    new CopyWebpackPlugin({
      patterns: [
        {
          from: "./public",
          globOptions: {
            ignore: [
              "**/index.html"
            ]
          }
        }
      ]
    }),
  ]
})

webpack.comm.config.js文件:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { DefinePlugin } = require("webpack");
const { VueLoaderPlugin } = require('vue-loader/dist/index');

module.exports = {
  target: "web",
  entry: "./src/main.js",
  output: {
    path: path.resolve(__dirname, "../build"),
    filename: "js/bundle.js",
  },
  resolve: {
    extensions: [".js", ".json", ".mjs", ".vue", ".ts", ".jsx", ".tsx"],
    alias: {
      "@": path.resolve(__dirname, "../src"),
      "js": path.resolve(__dirname, "../src/js")
    }
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        test: /\.less$/,
        use: ["style-loader", "css-loader", "less-loader"],
      },
      // },
      {
        test: /\.(jpe?g|png|gif|svg)$/,
        type: "asset",
        generator: {
          filename: "img/[name]_[hash:6][ext]",
        },
        parser: {
          dataUrlCondition: {
            maxSize: 10 * 1024,
          },
        },
      },
      {
        test: /\.(eot|ttf|woff2?)$/,
        type: "asset/resource",
        generator: {
          filename: "font/[name]_[hash:6][ext]",
        },
      },
      {
        test: /\.js$/,
        loader: "babel-loader"
      },
      {
        test: /\.vue$/,
        loader: "vue-loader"
      }
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./public/index.html",
      title: "哈哈哈哈"
    }),
    new DefinePlugin({
      BASE_URL: "'./'",
      __VUE_OPTIONS_API__: true,
      __VUE_PROD_DEVTOOLS__: false
    }),
    new VueLoaderPlugin()
  ],
};

这时候执行npm run buildnpm run serve就可以区分环境了。

修改相对路径

路径我们改了,所以一些./需要改成../,但是不是绝对的,就会如入口的就不用改,因为这和它解析的规则相关,有可能是项目根路径,有可能是文件当前路径,具体根据上面代码为准,或者试一试。

我们之前编写入口文件的规则是这样的:./src/main.js,但是如果我们的配置文件所在的位置变成了 config 目录,我们是否应该变成 ../src/main.js呢? 如果我们这样编写,会发现是报错的,依然要写成 ./src/main.js,这是因为入口文件其实是和另一个 context 属性有关。

context 的作用是用于解析入口(entry point)和加载器(loader)。 官方说法:默认是当前路径(但是经过我测试,默认应该是webpack的启动目录),另外推荐在配置中传入一个context值。