Vue2 vue.config.js 配置
配置环境变量
- 在 package.json 里的 scripts 配置项 中添加 --mode xxx 来选择不同环境
- 在项目根目录中新建
.env
,.env.production
,.env.analyz
等文件。 - 只有以
VUE_APP_
开头的变量会被 webpack.DefinePlugin 静态嵌入到客户端侧的包中,代码中可以通过 process.env.VUE_APP_BASE_API 访问 - NODE_ENV 和 BASE_URL 是两个特殊变量,在代码中始终可用
- .env serve 默认的环境变量
NODE_ENV = "development" BASE_URL = "./" VUE_APP_PUBLIC_PATH = "./" VUE_APP_API = "https://test.staven630.com/api"
- .env.production build 默认的环境变量
NODE_ENV = "production" BASE_URL = "https://prod.staven630.com/" VUE_APP_PUBLIC_PATH = "https://prod.oss.com/staven-blog" VUE_APP_API = "https://prod.staven630.com/api" ACCESS_KEY_ID = "xxxxxxxxxxxxx" ACCESS_KEY_SECRET = "xxxxxxxxxxxxx" REGION = "oss-cn-hangzhou" BUCKET = "staven-prod" PREFIX = "staven-blog"
- .env.crm 用于自定义 build 环境配置(预发服务器)
NODE_ENV = "production" BASE_URL = "https://crm.staven630.com/" VUE_APP_PUBLIC_PATH = "https://crm.oss.com/staven-blog" VUE_APP_API = "https://crm.staven630.com/api" ACCESS_KEY_ID = "xxxxxxxxxxxxx" ACCESS_KEY_SECRET = "xxxxxxxxxxxxx" REGION = "oss-cn-hangzhou" BUCKET = "staven-crm" PREFIX = "staven-blog" IS_ANALYZE = true;
# package.json
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"crm": "vue-cli-service build --mode crm",
"lint": "vue-cli-service lint"
}
使用环境变量
<template>
<div class="home">
<!-- template 中使用环境变量 -->
API: {{ api }}
</div>
</template>
<script>
export default {
name: "home",
data() {
return {
api: process.env.VUE_APP_API
};
},
mounted() {
// js 代码中使用环境变量
console.log("BASE_URL: ", process.env.BASE_URL);
console.log("VUE_APP_API: ", process.env.VUE_APP_API);
}
};
</script>
vue.config.js 完整配置
const SpritesmithPlugin = require("webpack-spritesmith");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin;
const webpack = require("webpack");
const path = require("path");
const fs = require("fs");
const resolve = dir => path.join(__dirname, dir);
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
const glob = require('glob')
const pagesInfo = require('./pages.config')
const pages = {}
glob.sync('./src/pages/**/main.js').forEach(entry => {
let chunk = entry.match(/\.\/src\/pages\/(.*)\/main\.js/)[1];
const curr = pagesInfo[chunk];
if (curr) {
pages[chunk] = {
entry,
...curr,
chunk: ["chunk-vendors", "chunk-common", chunk]
}
}
})
let has_sprite = true;
let files = [];
const icons = {};
try {
fs.statSync(resolve("./src/assets/icons"));
files = fs.readdirSync(resolve("./src/assets/icons"));
files.forEach(item => {
let filename = item.toLocaleLowerCase().replace(/_/g, "-");
icons[filename] = true;
});
} catch (error) {
fs.mkdirSync(resolve("./src/assets/icons"));
}
if (!files.length) {
has_sprite = false;
} else {
try {
let iconsObj = fs.readFileSync(resolve("./icons.json"), "utf8");
iconsObj = JSON.parse(iconsObj);
has_sprite = files.some(item => {
let filename = item.toLocaleLowerCase().replace(/_/g, "-");
return !iconsObj[filename];
});
if (has_sprite) {
fs.writeFileSync(resolve("./icons.json"), JSON.stringify(icons, null, 2));
}
} catch (error) {
fs.writeFileSync(resolve("./icons.json"), JSON.stringify(icons, null, 2));
has_sprite = true;
}
}
// 雪碧图样式处理模板
const SpritesmithTemplate = function (data) {
// pc
let icons = {}
let tpl = `.ico {
display: inline-block;
background-image: url(${data.sprites[0].image});
background-size: ${data.spritesheet.width}px ${data.spritesheet.height}px;
}`
data.sprites.forEach(sprite => {
const name = '' + sprite.name.toLocaleLowerCase().replace(/_/g, '-')
icons[`${name}.png`] = true
tpl = `${tpl}
.ico-${name}{
width: ${sprite.width}px;
height: ${sprite.height}px;
background-position: ${sprite.offset_x}px ${sprite.offset_y}px;
}
`
})
return tpl
}
module.exports = {
publicPath: IS_PROD ? process.env.VUE_APP_PUBLIC_PATH : "./", // 默认'/',部署应用包时的基本 URL
// outputDir: process.env.outputDir || 'dist', // 'dist', 生产环境构建文件的目录
// assetsDir: "", // 相对于 outputDir 的静态资源(js、css、img、fonts) 目录
configureWebpack: config => {
const plugins = [];
if (has_sprite) {
// 生成雪碧图
plugins.push(
new SpritesmithPlugin({
src: {
cwd: path.resolve(__dirname, './src/assets/icons/'), // 图标根路径
glob: '**/*.png' // 匹配任意 png 图标
},
target: {
image: path.resolve(__dirname, './src/assets/images/sprites.png'), // 生成雪碧图目标路径与名称
// 设置生成 CSS 背景及其定位的文件或方式
css: [
[
path.resolve(__dirname, './src/assets/scss/sprites.scss'),
{
format: 'function_based_template'
}
]
]
},
customTemplates: {
function_based_template: SpritesmithTemplate
},
apiOptions: {
cssImageRef: '../images/sprites.png' // css 文件中引用雪碧图的相对位置路径配置
},
spritesmithOptions: {
padding: 2
}
})
)
}
config.externals = {
vue: "Vue",
"element-ui": "ELEMENT",
"vue-router": "VueRouter",
vuex: "Vuex",
axios: "axios"
};
config.plugins = [...config.plugins, ...plugins];
},
chainWebpack: config => {
// 修复 HMR
config.resolve.symlinks(true);
// config.plugins.delete('preload');
// config.plugins.delete('prefetch');
config
.plugin("ignore")
.use(
new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn$/)
);
// 添加别名
config.resolve.alias
.set("vue$", "vue/dist/vue.esm.js")
.set("@", resolve("src"))
.set("@apis", resolve("src/apis"))
.set("@assets", resolve("src/assets"))
.set("@scss", resolve("src/assets/scss"))
.set("@components", resolve("src/components"))
.set("@middlewares", resolve("src/middlewares"))
.set("@mixins", resolve("src/mixins"))
.set("@plugins", resolve("src/plugins"))
.set("@router", resolve("src/router"))
.set("@store", resolve("src/store"))
.set("@utils", resolve("src/utils"))
.set("@views", resolve("src/views"))
.set("@layouts", resolve("src/layouts"));
const cdn = {
// 访问 https://unpkg.com/element-ui/lib/theme-chalk/index.css 获取最新版本
css: ["//unpkg.com/element-ui@2.10.1/lib/theme-chalk/index.css"],
js: [
"//unpkg.com/vue@2.6.10/dist/vue.min.js", // 访问 https://unpkg.com/vue/dist/vue.min.js 获取最新版本
"//unpkg.com/vue-router@3.0.6/dist/vue-router.min.js",
"//unpkg.com/vuex@3.1.1/dist/vuex.min.js",
"//unpkg.com/axios@0.19.0/dist/axios.min.js",
"//unpkg.com/element-ui@2.10.1/lib/index.js"
]
};
// 如果使用多页面打包,使用 vue inspect --plugins 查看 html 是否在结果数组中
// config.plugin("html").tap(args => {
// // html 中添加 cdn
// args[0].cdn = cdn;
// // 修复 Lazy loading routes Error
// args[0].chunksSortMode = "none";
// return args;
// });
// 防止多页面打包卡顿
config => config.plugins.delete('named-chunks')
// 多页面 cdn 添加
Object.keys(pagesInfo).forEach(page => {
config.plugin(`html-${page}`).tap(args => {
// html 中添加 cdn
args[0].cdn = cdn;
// 修复 Lazy loading routes Error
args[0].chunksSortMode = "none";
return args;
});
})
if (IS_PROD) {
// 压缩图片
config.module
.rule("images")
.test(/\.(png|jpe?g|gif|svg)(\?.*)?$/)
.use("image-webpack-loader")
.loader("image-webpack-loader")
.options({
mozjpeg: { progressive: true, quality: 65 },
optipng: { enabled: false },
pngquant: { quality: [0.65, 0.90], speed: 4 },
gifsicle: { interlaced: false }
});
// 打包分析
config.plugin("webpack-report").use(BundleAnalyzerPlugin, [
{
analyzerMode: "static"
}
]);
}
// 使用 svg 组件
const svgRule = config.module.rule("svg");
svgRule.uses.clear();
svgRule.exclude.add(/node_modules/);
svgRule
.test(/\.svg$/)
.use("svg-sprite-loader")
.loader("svg-sprite-loader")
.options({
symbolId: "icon-[name]"
});
const imagesRule = config.module.rule("images");
imagesRule.exclude.add(resolve("src/icons"));
config.module.rule("images").test(/\.(png|jpe?g|gif|svg)(\?.*)?$/);
return config;
},
pages,
css: {
extract: IS_PROD,
sourceMap: false,
loaderOptions: {
scss: {
// 向全局 sass 样式传入共享的全局变量, $src 可以配置图片 cdn 前缀
// 详情: https://cli.vuejs.org/guide/css.html#passing-options-to-pre-processor-loaders
prependData: `
@import "@scss/variables.scss";
@import "@scss/mixins.scss";
@import "@scss/function.scss";
$src: "${process.env.VUE_APP_BASE_API}";
`
}
}
},
lintOnSave: false,
runtimeCompiler: true, // 是否使用包含运行时编译器的 Vue 构建版本
productionSourceMap: !IS_PROD, // 生产环境的 source map
parallel: require("os").cpus().length > 1,
pwa: {},
devServer: {
// overlay: { // 让浏览器 overlay 同时显示警告和错误
// warnings: true,
// errors: true
// },
// open: false, // 是否打开浏览器
// host: "localhost",
// port: "8080", // 代理断就
// https: false,
// hotOnly: false, // 热更新
proxy: {
"/api": {
target:
"https://www.easy-mock.com/mock/5bc75b55dc36971c160cad1b/sheets", // 目标代理接口地址
secure: false,
changeOrigin: true, // 开启代理,在本地创建一个虚拟服务端
// ws: true, // 是否启用 websockets
pathRewrite: {
"^/api": "/"
}
}
}
}
};
配置 vue.config.js
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
module.exports = {
publicPath: IS_PROD ? process.env.VUE_APP_PUBLIC_PATH : "./", // 默认'/',部署应用包时的基本 URL
// outputDir: process.env.outputDir || 'dist', // 'dist', 生产环境构建文件的目录
// assetsDir: "", // 相对于 outputDir 的静态资源(js、css、img、fonts) 目录
lintOnSave: false,
runtimeCompiler: true, // 是否使用包含运行时编译器的 Vue 构建版本
productionSourceMap: !IS_PROD, // 生产环境的 source map
parallel: require("os").cpus().length > 1,
pwa: {}
};
配置 proxy 代理解决跨域问题
假设 mock 接口为 https://www.easy-mock.com/mock/5bc75b55dc36971c160cad1b/sheets/1
module.exports = {
devServer: {
// overlay: { // 让浏览器 overlay 同时显示警告和错误
// warnings: true,
// errors: true
// },
// open: false, // 是否打开浏览器
// host: "localhost",
// port: "8080", // 代理断就
// https: false,
// hotOnly: false, // 热更新
proxy: {
'/api': {
// target: process.env.VUE_APP_BASE_API || 'http://127.0.0.1:8080',
target:
"https://www.easy-mock.com/mock/5bc75b55dc36971c160cad1b/sheets", // 目标代理接口地址
secure: false,
changeOrigin: true, // 开启代理,在本地创建一个虚拟服务端
// ws: true, // 是否启用 websockets
pathRewrite: {
"^/api": "/"
}
}
}
}
}
访问
<script>
import axios from "axios";
export default {
mounted() {
axios.get("/api/1").then(res => {
console.log('proxy:', res);
});
}
};
</script>
修复 HMR(热更新) 失效
module.exports = {
chainWebpack: config => {
// 修复 HMR
config.resolve.symlinks(true);
}
}
prefetch、preload 和路由懒加载
项目打包之后会出现很多 link 标签, 将你的所有打包好的文件都 预加载进来,导致路由懒加载失效。
<link href=js/chunk-cdfd6c6c.25e21cb5.js rel=prefetch>
<link href=css/app.b2839875.css rel=preload as=style>
<link href=css/chunk-vendors.779f7d1d.css rel=preload as=style>
<link href=js/app.1a2f81ff.js rel=preload as=script>
<link href=js/chunk-vendors.3a58506f.js rel=preload as=script>
通过配置 webpack 去掉这一行为
chainWebpack(config) {
config.plugins.delete('preload')
config.plugins.delete('prefetch')
}
prefetching
prefetch 是一种 resource hint,用来告诉浏览器在页面加载完成后,利用空闲时间提前获取用户未来可能会访问的内容。这和路由懒加载的理念冲突
// vue.config.js
module.exports = {
chainWebpack: config => {
// 移除 prefetch 插件
config.plugins.delete('prefetch')
// 或者
// 修改它的选项:
config.plugin('prefetch').tap(options => {
options[0].fileBlacklist = options[0].fileBlacklist || []
options[0].fileBlacklist.push(/myasyncRoute(.)+?\.js$/)
return options
})
}
}
这样就解决了路由无法懒加载的问题
但是 !!! 这个预加载是不会影响当前页面的加载性能的,因此预加载是可以被保留的,那在什么情况下我们需要禁用预加载呢?当然是对流量损耗敏感(移动端)的应用场景,在首页对子页面进行全面的预加载,而用户可能只需要跳转其中的一两个子页面甚至停留在首页,造成大量的流量浪费。因此,需要做到控制特定的路由预加载。首先,先移除 prefetch 插件,然后按需添加预加载。
import(/* webpackPrefetch: true */ './someAsyncComponent.vue')
preloading
preloading 是一种 resource hint,用来指定页面加载后很快会被用到的资源,所以在页面加载的过程中,我们希望在浏览器开始主体渲染之前尽早 preload。
preloading 用于提高资源加载的优先级,当页面开始加载时,我们总是想核心的代码或资源得到优先处理,因此可以通过 preloading 提高优先级。
import(/* webpackPreload: true */ 'ChartingLibrary');
总结
- 懒加载优化了首屏加载的速率
- prefetch 预加载优化了子页面加载的速率
修复 Lazy loading routes Error: Cyclic dependency
module.exports = {
chainWebpack: config => {
// 如果使用多页面打包,使用 vue inspect --plugins 查看 html 是否在结果数组中
config.plugin("html").tap(args => {
// 修复 Lazy loading routes Error
args[0].chunksSortMode = "none";
return args;
});
}
};
添加别名 alias
const path = require("path");
const resolve = dir => path.join(__dirname, dir);
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
module.exports = {
chainWebpack: config => {
// 添加别名
config.resolve.alias
.set("vue$", "vue/dist/vue.esm.js")
.set("@", resolve("src"))
.set("@assets", resolve("src/assets"))
.set("@scss", resolve("src/assets/scss"))
.set("@components", resolve("src/components"))
.set("@plugins", resolve("src/plugins"))
.set("@views", resolve("src/views"))
.set("@router", resolve("src/router"))
.set("@store", resolve("src/store"))
.set("@layouts", resolve("src/layouts"))
.set("@static", resolve("src/static"));
}
};
添加骨架屏(vue-skeleton-webpack-plugin)
npm install vue-skeleton-webpack-plugin
// skeleton.js
import Vue from 'vue';
import Skeleton from './Skeleton.vue';
export default new Vue({
components: {
Skeleton,
},
render: h => h(Skeleton),
});
// Skeleton.vue
<template>
<div class="skeleton-wrapper">
<section class="skeleton-block">
<!-- eslint-disable vue/max-len -->
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTA4MCAyNjEiPjxkZWZzPjxwYXRoIGlkPSJiIiBkPSJNMCAwaDEwODB2MjYwSDB6Ii8+PGZpbHRlciBpZD0iYSIgd2lkdGg9IjIwMCUiIGhlaWdodD0iMjAwJSIgeD0iLTUwJSIgeT0iLTUwJSIgZmlsdGVyVW5pdHM9Im9iamVjdEJvdW5kaW5nQm94Ij48ZmVPZmZzZXQgZHk9Ii0xIiBpbj0iU291cmNlQWxwaGEiIHJlc3VsdD0ic2hhZG93T2Zmc2V0T3V0ZXIxIi8+PGZlQ29sb3JNYXRyaXggaW49InNoYWRvd09mZnNldE91dGVyMSIgdmFsdWVzPSIwIDAgMCAwIDAuOTMzMzMzMzMzIDAgMCAwIDAgMC45MzMzMzMzMzMgMCAwIDAgMCAwLjkzMzMzMzMzMyAwIDAgMCAxIDAiLz48L2ZpbHRlcj48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIj48dXNlIGZpbGw9IiMwMDAiIGZpbHRlcj0idXJsKCNhKSIgeGxpbms6aHJlZj0iI2IiLz48dXNlIGZpbGw9IiNGRkYiIHhsaW5rOmhyZWY9IiNiIi8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCA0NGg1MzN2NDZIMjMweiIvPjxyZWN0IHdpZHRoPSIxNzIiIGhlaWdodD0iMTcyIiB4PSIzMCIgeT0iNDQiIGZpbGw9IiNGNkY2RjYiIHJ4PSI0Ii8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCAxMThoMzY5djMwSDIzMHpNMjMwIDE4MmgzMjN2MzBIMjMwek04MTIgMTE1aDIzOHYzOUg4MTJ6TTgwOCAxODRoMjQydjMwSDgwOHpNOTE3IDQ4aDEzM3YzN0g5MTd6Ii8+PC9nPjwvc3ZnPg==">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTA4MCAyNjEiPjxkZWZzPjxwYXRoIGlkPSJiIiBkPSJNMCAwaDEwODB2MjYwSDB6Ii8+PGZpbHRlciBpZD0iYSIgd2lkdGg9IjIwMCUiIGhlaWdodD0iMjAwJSIgeD0iLTUwJSIgeT0iLTUwJSIgZmlsdGVyVW5pdHM9Im9iamVjdEJvdW5kaW5nQm94Ij48ZmVPZmZzZXQgZHk9Ii0xIiBpbj0iU291cmNlQWxwaGEiIHJlc3VsdD0ic2hhZG93T2Zmc2V0T3V0ZXIxIi8+PGZlQ29sb3JNYXRyaXggaW49InNoYWRvd09mZnNldE91dGVyMSIgdmFsdWVzPSIwIDAgMCAwIDAuOTMzMzMzMzMzIDAgMCAwIDAgMC45MzMzMzMzMzMgMCAwIDAgMCAwLjkzMzMzMzMzMyAwIDAgMCAxIDAiLz48L2ZpbHRlcj48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIj48dXNlIGZpbGw9IiMwMDAiIGZpbHRlcj0idXJsKCNhKSIgeGxpbms6aHJlZj0iI2IiLz48dXNlIGZpbGw9IiNGRkYiIHhsaW5rOmhyZWY9IiNiIi8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCA0NGg1MzN2NDZIMjMweiIvPjxyZWN0IHdpZHRoPSIxNzIiIGhlaWdodD0iMTcyIiB4PSIzMCIgeT0iNDQiIGZpbGw9IiNGNkY2RjYiIHJ4PSI0Ii8+PHBhdGggZmlsbD0iI0Y2RjZGNiIgZD0iTTIzMCAxMThoMzY5djMwSDIzMHpNMjMwIDE4MmgzMjN2MzBIMjMwek04MTIgMTE1aDIzOHYzOUg4MTJ6TTgwOCAxODRoMjQydjMwSDgwOHpNOTE3IDQ4aDEzM3YzN0g5MTd6Ii8+PC9nPjwvc3ZnPg==">
</section>
</div>
</template>
<script>
export default {
name: 'Skeleton',
};
</script>
<style scoped>
.skeleton-block {
display: flex;
flex-direction: column;
padding: 16px;
}
</style>
const path = require('path');
const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin');
module.exports = {
configureWebpack: {
plugins: [
new SkeletonWebpackPlugin({
webpackConfig: {
entry: {
app: path.join(__dirname, './src/skeleton.js'),
},
},
minimize: true,
quiet: true,
}),
],
},
};
压缩图片(image-webpack-loader)
npm i -D image-webpack-loader
在某些版本的 OSX 上安装可能会因缺少 libpng 依赖项而引发错误。可以通过安装最新版本的 libpng 来解决。
brew install libpng
module.exports = {
chainWebpack: config => {
if (IS_PROD) {
config.module
.rule("images")
.use("image-webpack-loader")
.loader("image-webpack-loader")
.options({
bypassOnDebug: true
// 或
mozjpeg: { progressive: true, quality: 65 },
optipng: { enabled: false },
pngquant: { quality: [0.65, 0.9], speed: 4 },
gifsicle: { interlaced: false }
// webp: { quality: 75 } //ios 不支持
});
}
}
};
自动生成雪碧图(webpack-spritesmith 插件)
默认 src/assets/icons 中存放需要生成雪碧图的 png 文件。首次运行 npm run serve/build 会生成雪碧图,并在跟目录生成 icons.json 文件。再次运行命令时,会对比 icons 目录内文件与 icons.json 的匹配关系,确定是否需要再次执行 webpack-spritesmith 插件
npm i -D webpack-spritesmith
let has_sprite = true;
let files = [];
const icons = {};
try {
fs.statSync(resolve("./src/assets/icons"));
files = fs.readdirSync(resolve("./src/assets/icons"));
files.forEach(item => {
let filename = item.toLocaleLowerCase().replace(/_/g, "-");
icons[filename] = true;
});
} catch (error) {
fs.mkdirSync(resolve("./src/assets/icons"));
}
if (!files.length) {
has_sprite = false;
} else {
try {
let iconsObj = fs.readFileSync(resolve("./icons.json"), "utf8");
iconsObj = JSON.parse(iconsObj);
has_sprite = files.some(item => {
let filename = item.toLocaleLowerCase().replace(/_/g, "-");
return !iconsObj[filename];
});
if (has_sprite) {
fs.writeFileSync(resolve("./icons.json"), JSON.stringify(icons, null, 2));
}
} catch (error) {
fs.writeFileSync(resolve("./icons.json"), JSON.stringify(icons, null, 2));
has_sprite = true;
}
}
// 雪碧图样式处理模板
const SpritesmithTemplate = function(data) {
// pc
let icons = {};
let tpl = `.ico {
display: inline-block;
background-image: url(${data.sprites[0].image});
background-size: ${data.spritesheet.width}px ${data.spritesheet.height}px;
}`;
data.sprites.forEach(sprite => {
const name = "" + sprite.name.toLocaleLowerCase().replace(/_/g, "-");
icons[`${name}.png`] = true;
tpl = `${tpl}
.ico-${name}{
width: ${sprite.width}px;
height: ${sprite.height}px;
background-position: ${sprite.offset_x}px ${sprite.offset_y}px;
}
`;
});
return tpl;
};
module.exports = {
configureWebpack: config => {
const plugins = [];
if (has_sprite) {
plugins.push(
new SpritesmithPlugin({
src: {
cwd: path.resolve(__dirname, "./src/assets/icons/"), // 图标根路径
glob: "**/*.png" // 匹配任意 png 图标
},
target: {
image: path.resolve(__dirname, "./src/assets/images/sprites.png"), // 生成雪碧图目标路径与名称
// 设置生成 CSS 背景及其定位的文件或方式
css: [
[
path.resolve(__dirname, "./src/assets/scss/sprites.scss"),
{
format: "function_based_template"
}
]
]
},
customTemplates: {
function_based_template: SpritesmithTemplate
},
apiOptions: {
cssImageRef: "../images/sprites.png" // css 文件中引用雪碧图的相对位置路径配置
},
spritesmithOptions: {
padding: 2
}
})
);
}
config.plugins = [...config.plugins, ...plugins];
}
};
SVG 转 font 字体(svgtofont)
npm i -D svgtofont
根目录新增 scripts 目录,并新建 svg2font.js 文件:
const svgtofont = require("svgtofont");
const path = require("path");
const pkg = require("../package.json");
svgtofont({
src: path.resolve(process.cwd(), "src/assets/svg"), // svg 图标目录路径
dist: path.resolve(process.cwd(), "src/assets/fonts"), // 输出到指定目录中
fontName: "icon", // 设置字体名称
css: true, // 生成字体文件
startNumber: 20000, // unicode 起始编号
svgicons2svgfont: {
fontHeight: 1000,
normalize: true
},
// website = null, 没有演示 html 文件
website: {
title: "icon",
logo: "",
version: pkg.version,
meta: {
description: "",
keywords: ""
},
description: ``,
links: [
{
title: "Font Class",
url: "index.html"
},
{
title: "Unicode",
url: "unicode.html"
}
],
footerInfo: ``
}
}).then(() => {
console.log("done!");
});
添加 package.json scripts 配置,执行 npm run font :
"prebuild": "npm run font",
"font": "node scripts/svg2font.js",
使用 SVG 组件(svg-sprite-loader)
npm i -D svg-sprite-loader
新增 SvgIcon 组件:
<template>
<svg class="svg-icon"
aria-hidden="true">
<use :xlink:href="iconName" />
</svg>
</template>
<script>
export default {
name: 'SvgIcon',
props: {
iconClass: {
type: String,
required: true
}
},
computed: {
iconName() {
return `#icon-${this.iconClass}`
}
}
}
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
</style>
在 src 文件夹中创建 icons 文件夹。icons 文件夹中新增 svg 文件夹(用来存放 svg 文件)与 index.js 文件:
import SvgIcon from "@/components/SvgIcon";
import Vue from "vue";
// 注册到全局
Vue.component("svg-icon", SvgIcon);
const requireAll = requireContext => requireContext.keys().map(requireContext);
const req = require.context("./svg", false, /\.svg$/);
requireAll(req);
在 main.js 中导入 icons/index.js: import "@/icons";
修改 vue.config.js :
const path = require("path");
const resolve = dir => path.join(__dirname, dir);
module.exports = {
chainWebpack: config => {
const svgRule = config.module.rule("svg");
svgRule.uses.clear();
svgRule.exclude.add(/node_modules/);
svgRule
.test(/\.svg$/)
.use("svg-sprite-loader")
.loader("svg-sprite-loader")
.options({
symbolId: "icon-[name]"
});
const imagesRule = config.module.rule("images");
imagesRule.exclude.add(resolve("src/icons"));
config.module.rule("images").test(/\.(png|jpe?g|gif|svg)(\?.*)?$/);
}
};
去除多余无效的 css(purgecss-webpack-plugin)
注意: 谨慎使用。可能出现各种样式丢失现象
- 方案一 :@fullhuman/postcss-purgecss
npm i -D postcss-import @fullhuman/postcss-purgecss
更新 postcss.config.js
const autoprefixer = require("autoprefixer"); const postcssImport = require("postcss-import"); const purgecss = require("@fullhuman/postcss-purgecss"); const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV); let plugins = []; if (IS_PROD) { plugins.push(postcssImport); plugins.push( purgecss({ content: [ "./layouts/**/*.vue", "./components/**/*.vue", "./pages/**/*.vue" ], extractors: [ { extractor: class Extractor { static extract(content) { const validSection = content.replace( /<style([\s\S]*?)<\/style>+/gim, "" ); return ( validSection.match(/[A-Za-z0-9-_/:]*[A-Za-z0-9-_/]+/g) || [] ); } }, extensions: ["html", "vue"] } ], whitelist: ["html", "body"], whitelistPatterns: [ /el-.*/, /-(leave|enter|appear)(|-(to|from|active))$/, /^(?!cursor-move).+-move$/, /^router-link(|-exact)-active$/ ], whitelistPatternsChildren: [/^token/, /^pre/, /^code/] }) ); } module.exports = { plugins: [...plugins, autoprefixer] };
- 方案二 :purgecss-webpack-plugin
npm i -D glob-all purgecss-webpack-plugin
const path = require("path"); const glob = require("glob-all"); const PurgecssPlugin = require("purgecss-webpack-plugin"); const resolve = dir => path.join(__dirname, dir); const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV); module.exports = { configureWebpack: config => { const plugins = []; if (IS_PROD) { plugins.push( new PurgecssPlugin({ paths: glob.sync([resolve("./**/*.vue")]), extractors: [ { extractor: class Extractor { static extract(content) { const validSection = content.replace( /<style([\s\S]*?)<\/style>+/gim, "" ); return ( validSection.match(/[A-Za-z0-9-_/:]*[A-Za-z0-9-_/]+/g) || [] ); } }, extensions: ["html", "vue"] } ], whitelist: ["html", "body"], whitelistPatterns: [ /el-.*/, /-(leave|enter|appear)(|-(to|from|active))$/, /^(?!cursor-move).+-move$/, /^router-link(|-exact)-active$/ ], whitelistPatternsChildren: [/^token/, /^pre/, /^code/] }) ); } config.plugins = [...config.plugins, ...plugins]; } };
添加打包分析(webpack-bundle-analyzer)
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
chainWebpack: config => {
// 打包分析
if (IS_PROD) {
config.plugin('webpack-report')
.use(BundleAnalyzerPlugin, [{
analyzerMode: 'static',
}]);
}
}
}
配置 externals 引入 cdn 资源
防止将某些 import 的包 (package) 打包到 bundle 中,而是在运行时 (runtime) 再去从外部获取这些扩展依赖
module.exports = {
configureWebpack: config => {
config.externals = {
'vue': 'Vue',
'element-ui': 'ELEMENT',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'axios': 'axios'
};
},
chainWebpack: config => {
const cdn = {
// 访问 https://unpkg.com/element-ui/lib/theme-chalk/index.css 获取最新版本
css: ["//unpkg.com/element-ui@2.10.1/lib/theme-chalk/index.css"],
js: [
"//unpkg.com/vue@2.6.10/dist/vue.min.js", // 访问 https://unpkg.com/vue/dist/vue.min.js 获取最新版本
"//unpkg.com/vue-router@3.0.6/dist/vue-router.min.js",
"//unpkg.com/vuex@3.1.1/dist/vuex.min.js",
"//unpkg.com/axios@0.19.0/dist/axios.min.js",
"//unpkg.com/element-ui@2.10.1/lib/index.js"
]
};
// 如果使用多页面打包,使用 vue inspect --plugins 查看 html 是否在结果数组中
config.plugin("html").tap(args => {
// html 中添加 cdn
args[0].cdn = cdn;
return args;
});
}
}
在 html 中添加
<!-- 使用 CDN 的 CSS 文件 -->
<% for (var i in htmlWebpackPlugin.options.cdn &&
htmlWebpackPlugin.options.cdn.css) { %>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" />
<% } %>
<!-- 使用 CDN 的 JS 文件 -->
<% for (var i in htmlWebpackPlugin.options.cdn &&
htmlWebpackPlugin.options.cdn.js) { %>
<script
type="text/javascript"
src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"
></script>
<% } %>
多页面打包 multi-page
多入口页面打包,建议在 src 目录下新建 pages 目录存放多页面模块
pages.config.js:
配置多页面信息。src/main.js 文件对应 main 字段,其他根据参照 pages 为根路径为字段。如下:
module.exports = {
'admin': {
template: 'public/index.html',
filename: 'admin.html',
title: '后台管理',
},
'mobile': {
template: 'public/index.html',
filename: 'mobile.html',
title: '移动端',
},
'pc/crm': {
template: 'public/index.html',
filename: 'pc-crm.html',
title: '预发服务',
}
}
vue.config.js:
vue.config.js 的 pages 字段为多页面提供配置
const glob = require("glob");
const pagesInfo = require("./pages.config");
const pages = {};
glob.sync('./src/pages/**/main.js').forEach(entry => {
let chunk = entry.match(/\.\/src\/pages\/(.*)\/main\.js/)[1];
const curr = pagesInfo[chunk];
if (curr) {
pages[chunk] = {
entry,
...curr,
chunk: ["chunk-vendors", "chunk-common", chunk]
}
}
})
module.exports = {
chainWebpack: config => {
// 防止多页面打包卡顿
config => config.plugins.delete("named-chunks");
return config;
},
pages
};
如果多页面打包需要使用 CDN,使用 vue inspect --plugins 查看 html 是否在结果数组中的形式。上例中 plugins 列表中存在 'html-main','html-pages/admin','html-pages/mobile' , 没有'html'。因此不能再使用 config.plugin("html") 。
const path = require("path");
const resolve = dir => path.join(__dirname, dir);
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
const glob = require("glob");
const pagesInfo = require("./pages.config");
const pages = {};
glob.sync('./src/pages/**/main.js').forEach(entry => {
let chunk = entry.match(/\.\/src\/pages\/(.*)\/main\.js/)[1];
const curr = pagesInfo[chunk];
if (curr) {
pages[chunk] = {
entry,
...curr,
chunk: ["chunk-vendors", "chunk-common", chunk]
}
}
});
module.exports = {
publicPath: IS_PROD ? process.env.VUE_APP_PUBLIC_PATH : "./", //
configureWebpack: config => {
config.externals = {
vue: "Vue",
"element-ui": "ELEMENT",
"vue-router": "VueRouter",
vuex: "Vuex",
axios: "axios"
};
},
chainWebpack: config => {
const cdn = {
// 访问 https://unpkg.com/element-ui/lib/theme-chalk/index.css 获取最新版本
css: ["//unpkg.com/element-ui@2.10.1/lib/theme-chalk/index.css"],
js: [
"//unpkg.com/vue@2.6.10/dist/vue.min.js", // 访问 https://unpkg.com/vue/dist/vue.min.js 获取最新版本
"//unpkg.com/vue-router@3.0.6/dist/vue-router.min.js",
"//unpkg.com/vuex@3.1.1/dist/vuex.min.js",
"//unpkg.com/axios@0.19.0/dist/axios.min.js",
"//unpkg.com/element-ui@2.10.1/lib/index.js"
]
};
// 防止多页面打包卡顿
config => config.plugins.delete("named-chunks");
// 多页面 cdn 添加
Object.keys(pagesInfo).forEach(page => {
config.plugin(`html-${page}`).tap(args => {
// html 中添加 cdn
args[0].cdn = cdn;
// 修复 Lazy loading routes Error
args[0].chunksSortMode = "none";
return args;
});
});
return config;
},
pages
};
删除 moment 语言包
删除 moment 除 zh-cn 中文包外的其它语言包,无需在代码中手动引入 zh-cn 语言包
const webpack = require("webpack");
module.exports = {
chainWebpack: config => {
config
.plugin("ignore")
.use(
new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn$/)
);
return config;
}
};
去掉 console.log(uglifyjs-webpack-plugin)
方法一
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
configureWebpack: config => {
if (IS_PROD) {
const plugins = [];
plugins.push(
new UglifyJsPlugin({
uglifyOptions: {
compress: {
warnings: false,
drop_console: true,
drop_debugger: false,
pure_funcs: ['console.log']//移除 console
}
},
sourceMap: false,
parallel: true
})
);
config.plugins = [
...config.plugins,
...plugins
];
}
}
}
如果使用 uglifyjs-webpack-plugin 会报错,可能存在 node_modules 中有些依赖需要 babel 转译
而 vue-cli 的 transpileDependencies 配置默认为[], babel-loader 会忽略所有 node_modules 中的文件。如果你想要通过 Babel 显式转译一个依赖,可以在这个选项中列出来。配置需要转译的第三方库
方法二:使用 babel-plugin-transform-remove-console 插件
npm i --save-dev babel-plugin-transform-remove-console
在 babel.config.js 中配置:
const plugins = [];
if(['production', 'prod'].includes(process.env.NODE_ENV)) {
plugins.push("transform-remove-console")
}
module.exports = {
presets: [["@vue/app",{"useBuiltIns": "entry"}]],
plugins: plugins
};
利用 splitChunks 单独打包第三方模块
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
module.exports = {
configureWebpack: config => {
if (IS_PROD) {
config.optimization = {
splitChunks: {
cacheGroups: {
common: {
name: "chunk-common",
chunks: "initial",
minChunks: 2,
maxInitialRequests: 5,
minSize: 0,
priority: 1,
reuseExistingChunk: true,
enforce: true
},
vendors: {
name: "chunk-vendors",
test: /[\\/]node_modules[\\/]/,
chunks: "initial",
priority: 2,
reuseExistingChunk: true,
enforce: true
},
elementUI: {
name: "chunk-elementui",
test: /[\\/]node_modules[\\/]element-ui[\\/]/,
chunks: "all",
priority: 3,
reuseExistingChunk: true,
enforce: true
},
echarts: {
name: "chunk-echarts",
test: /[\\/]node_modules[\\/](vue-)?echarts[\\/]/,
chunks: "all",
priority: 4,
reuseExistingChunk: true,
enforce: true
}
}
}
};
}
},
chainWebpack: config => {
if (IS_PROD) {
config.optimization.delete("splitChunks");
}
return config;
}
};
开启 gzip 压缩(compression-webpack-plugin)
npm i --save-dev compression-webpack-plugin
const CompressionWebpackPlugin = require('compression-webpack-plugin');
const productionGzipExtensions = /\.(js|css|json|txt|html|ico|svg)(\?.*)?$/i;
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
module.exports = {
configureWebpack: config => {
if (IS_PROD) {
const plugins = [];
plugins.push(
new CompressionWebpackPlugin({
filename: '[path].gz[query]',
algorithm: 'gzip',
test: productionGzipExtensions,
threshold: 10240,
minRatio: 0.8
})
);
config.plugins = [
...config.plugins,
...plugins
];
}
}
}
还可以开启比 gzip 体验更好的 Zopfli 压缩详见 brotli-webpack-plugin
npm i --save-dev @gfx/zopfli brotli-webpack-plugin
const CompressionWebpackPlugin = require('compression-webpack-plugin');
const zopfli = require("@gfx/zopfli");
const BrotliPlugin = require("brotli-webpack-plugin");
const productionGzipExtensions = /\.(js|css|json|txt|html|ico|svg)(\?.*)?$/i;
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
module.exports = {
configureWebpack: config => {
if (IS_PROD) {
const plugins = [];
plugins.push(
new CompressionWebpackPlugin({
algorithm(input, compressionOptions, callback) {
return zopfli.gzip(input, compressionOptions, callback);
},
compressionOptions: {
numiterations: 15
},
minRatio: 0.99,
test: productionGzipExtensions
})
);
plugins.push(
new BrotliPlugin({
test: productionGzipExtensions,
minRatio: 0.99
})
);
config.plugins = [
...config.plugins,
...plugins
];
}
}
}
开启 stylelint 检测 scss、css 语法
npm i -D stylelint stylelint-config-standard stylelint-config-prettier stylelint-webpack-plugin
在文件夹创建 stylelint.config.js ,详细配置在 这里
module.exports = {
ignoreFiles: ["**/*.js", "src/assets/css/element-variables.scss", "theme/"],
extends: ["stylelint-config-standard", "stylelint-config-prettier"],
rules: {
"no-empty-source": null,
"at-rule-no-unknown": [
true,
{
ignoreAtRules: ["extend"]
}
]
}
};
启用 webpack 配置
const StylelintPlugin = require("stylelint-webpack-plugin");
module.exports = {
configureWebpack: config => {
const plugins = [];
if (IS_DEV) {
plugins.push(
new StylelintPlugin({
files: ["src/**/*.vue", "src/assets/**/*.scss"],
fix: true //打开自动修复(谨慎使用!注意上面的配置不要加入 js 或 html 文件,会发生问题,js 文件请手动修复)
})
);
}
config.plugins = [...config.plugins, ...plugins];
}
}
为 sass 提供全局样式,以及全局变量
可以通过在 main.js 中 Vue.prototype.src = process.env.VUE_APP_SRC ;挂载环境变量中的配置信息,然后在 js 中使用 src=process.env.VUEAPPSRC ;挂载环境变量中的配置信息,然后在 js 中使用 src 访问
css 中可以使用注入 sass 变量访问环境变量中的配置信息:
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
module.exports = {
css: {
extract: IS_PROD,
sourceMap: false,
loaderOptions: {
scss: {
// 向全局 sass 样式传入共享的全局变量, $src 可以配置图片 cdn 前缀
// 详情: https://cli.vuejs.org/guide/css.html#passing-options-to-pre-processor-loaders
prependData: `
@import "@scss/variables.scss";
@import "@scss/mixins.scss";
@import "@scss/function.scss";
$src: "${process.env.VUE_APP_OSS_SRC}";
`
}
}
}
};
在 scss 中引用
.home {
background: url($src + '/images/500.png');
}
为 stylus 提供全局变量(style-resources-loader)
npm i -D style-resources-loader
const path = require("path");
const resolve = dir => path.resolve(__dirname, dir);
const addStylusResource = rule => {
rule
.use("style-resouce")
.loader("style-resources-loader")
.options({
patterns: [resolve("src/assets/stylus/variable.styl")]
});
};
module.exports = {
chainWebpack: config => {
const types = ["vue-modules", "vue", "normal-modules", "normal"];
types.forEach(type =>
addStylusResource(config.module.rule("stylus").oneOf(type))
);
}
};
预渲染 (prerender-spa-plugin)
npm i -D prerender-spa-plugin
const PrerenderSpaPlugin = require("prerender-spa-plugin");
const path = require("path");
const resolve = dir => path.join(__dirname, dir);
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
module.exports = {
configureWebpack: config => {
const plugins = [];
if (IS_PROD) {
plugins.push(
new PrerenderSpaPlugin({
staticDir: resolve("dist"),
routes: ["/"],
postProcess(ctx) {
ctx.route = ctx.originalRoute;
ctx.html = ctx.html.split(/>[\s]+</gim).join("><");
if (ctx.route.endsWith(".html")) {
ctx.outputPath = path.join(__dirname, "dist", ctx.route);
}
return ctx;
},
minify: {
collapseBooleanAttributes: true,
collapseWhitespace: true,
decodeEntities: true,
keepClosingSlash: true,
sortAttributes: true
},
renderer: new PrerenderSpaPlugin.PuppeteerRenderer({
// 需要注入一个值,这样就可以检测页面当前是否是预渲染的
inject: {},
headless: false,
// 视图组件是在 API 请求获取所有必要数据后呈现的,因此我们在 dom 中存在“data view”属性后创建页面快照
renderAfterDocumentEvent: "render-event"
})
})
);
}
config.plugins = [...config.plugins, ...plugins];
}
};
mounted() 中添加 document.dispatchEvent(new Event('render-event'))
new Vue({
router,
store,
render: h => h(App),
mounted() {
document.dispatchEvent(new Event("render-event"));
}
}).$mount("#app");
为自定义预渲染页面添加自定义 title、description、content
- 删除 public/index.html 中关于 description、content 的 meta 标签。保留 title 标签
- 配置 router-config.js
module.exports = { "/": { title: "首页", keywords: "首页关键词", description: "这是首页描述" }, "/about.html": { title: "关于我们", keywords: "关于我们页面关键词", description: "关于我们页面关键词描述" } };
// vue.config.js const path = require("path"); const PrerenderSpaPlugin = require("prerender-spa-plugin"); const routesConfig = require("./router-config"); const resolve = dir => path.join(__dirname, dir); const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV); module.exports = { configureWebpack: config => { const plugins = []; if (IS_PROD) { // 预加载 plugins.push( new PrerenderSpaPlugin({ staticDir: resolve("dist"), routes: Object.keys(routesConfig), postProcess(ctx) { ctx.route = ctx.originalRoute; ctx.html = ctx.html.split(/>[\s]+</gim).join("><"); ctx.html = ctx.html.replace( /<title>(.*?)<\/title>/gi, `<title>${ routesConfig[ctx.route].title }</title><meta name="keywords" content="${ routesConfig[ctx.route].keywords }" /><meta name="description" content="${ routesConfig[ctx.route].description }" />` ); if (ctx.route.endsWith(".html")) { ctx.outputPath = path.join(__dirname, "dist", ctx.route); } return ctx; }, minify: { collapseBooleanAttributes: true, collapseWhitespace: true, decodeEntities: true, keepClosingSlash: true, sortAttributes: true }, renderer: new PrerenderSpaPlugin.PuppeteerRenderer({ // 需要注入一个值,这样就可以检测页面当前是否是预渲染的 inject: {}, headless: false, // 视图组件是在 API 请求获取所有必要数据后呈现的,因此我们在 dom 中存在“data view”属性后创建页面快照 renderAfterDocumentEvent: "render-event" }) }) ); } config.plugins = [...config.plugins, ...plugins]; } };
添加 IE 兼容
在 main.js 中添加 :
import 'core-js/stable';
import 'regenerator-runtime/runtime';
配置 babel.config.js :
const plugins = [];
module.exports = {
presets: [["@vue/app",{"useBuiltIns": "entry"}]],
plugins: plugins
};
静态资源自动打包上传阿里 oss、华为 obs
开启文件上传 ali oss,需要将 publicPath 改成 ali oss 资源 url 前缀,也就是修改 VUE_APP_PUBLIC_PATH。具体配置参见 阿里 oss 插件 webpack-oss 、 华为 obs 插件 huawei-obs-plugin
npm i -D webpack-oss
const AliOssPlugin = require("webpack-oss");
const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV);
const format = AliOssPlugin.getFormat();
module.exports = {
publicPath: IS_PROD ? `${process.env.VUE_APP_PUBLIC_PATH}/${format}` : "./", // 默认'/',部署应用包时的基本 URL
configureWebpack: config => {
const plugins = [];
if (IS_PROD) {
plugins.push(
new AliOssPlugin({
accessKeyId: process.env.ACCESS_KEY_ID,
accessKeySecret: process.env.ACCESS_KEY_SECRET,
region: process.env.REGION,
bucket: process.env.BUCKET,
prefix: process.env.PREFIX,
exclude: /.*\.html$/,
format
})
);
}
config.plugins = [...config.plugins, ...plugins];
}
};
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论