本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。
为什么要搭建本地服务器?
目前我们开发的代码,为了运行需要有两个操作:
- 操作一:npm run build,编译相关的代码;
- 操作二:通过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能解析三种文件路径:
- 绝对路径:由于已经获得文件的绝对路径,因此不需要再做进一步解析。
- 相对路径:在这种情况下,使用 import/require 的资源文件所处的目录,被认为是上下文目录。在 import/require 中给定的相对路径,会拼接此上下文路径,来生成模块的绝对路径。
- 模块路径:在 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 build和npm run serve就可以区分环境了。
修改相对路径
路径我们改了,所以一些./需要改成../,但是不是绝对的,就会如入口的就不用改,因为这和它解析的规则相关,有可能是项目根路径,有可能是文件当前路径,具体根据上面代码为准,或者试一试。
我们之前编写入口文件的规则是这样的:./src/main.js,但是如果我们的配置文件所在的位置变成了 config 目录,我们是否应该变成 ../src/main.js呢? 如果我们这样编写,会发现是报错的,依然要写成 ./src/main.js,这是因为入口文件其实是和另一个 context 属性有关。
context 的作用是用于解析入口(entry point)和加载器(loader)。 官方说法:默认是当前路径(但是经过我测试,默认应该是webpack的启动目录),另外推荐在配置中传入一个context值。