最近抽时间阅读了下 create-react-app 源码,里面所使用到的有用的插件,会不断分析扩展到项目中。当然源码内容过多,可能会摘选出基于自身考虑需要优化的内容加以介绍。
准备
我们可以通过yarn create react-app helloreact
创建基于 create-react-app 的脚手架工程,然后执行yarn eject
命令将原有的已经封装好的配置暴露出来,之后我们就可以便利的阅读及扩展了,不过需要注意的是这个命令是不可逆的,一旦执行后就不能恢复原状态了。大致的项目结构如下所示:
paths.js分析
而对于所有项目中的关键路径,create-react-app 都全部抽象到 paths.js 中统一定义,例如包括src
,package.json
等文件的路径等。并且,通过以下代码实现了正确解析所有的相对路径:
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebook/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
通过fs.realpathSync(process.cwd())
获取到当前nodejs执行的工作目录后,就能够在任意层级的配置中解析项目对应的相对路径了。如果我们需要实现类似 create-react-app 这种动态配置,可以采用这种方式来实现。
env.js分析
我们在实际开发中,会使用到类似远程数据库访问用户名,密码或者部署容器的用户名,密码等敏感信息,这些信息暴露出去以后是相当危险的,并且对于这些类似的信息我们可能不同环境需要指定不同的值,例如开发环境和产品环境的数据库配置肯定是不同的。如此我们需要能够根据环境不同,能够自定义需要的变量,该文件即实现了此功能。
以下列表展示了实现加载环境变量的主要插件:
- dotenv 加载指定的 env*类的环境定义到 nodejs 的 process.env 环境变量中。
-
dotenv-expand 使
dotenv
可以定义变量。使用方法如下所示,最终获取到的BASE_URL
变量值为BASE_URL: 'http://127.0.0.1:8080/'
。
PORT=8080
IP=127.0.0.1
BASE_URL = http://${IP}:${PORT}/
如下代码指定了对应环境变量所加载的定义文件及顺序。NODE_ENV 变量需要我们根据不同环境指定,例如开发development,产品production,测试test。[].filter(Boolean)
是移除所有的 false 类型元素 (false, null, undefined, 0, NaN or an empty string) 的一个简写方式。
var dotenvFiles = [
`${paths.dotenv}.${NODE_ENV}.local`,
`${paths.dotenv}.${NODE_ENV}`,
// Don't include `.env.local` for `test` environment
// since normally you expect tests to produce the same
// results for everyone
NODE_ENV !== 'test' && `${paths.dotenv}.local`,
paths.dotenv,
].filter(Boolean);
根据不同环境,循环按序加载环境变量定义文件。
dotenvFiles.forEach(dotenvFile => {
if (fs.existsSync(dotenvFile)) {
require('dotenv-expand')(
require('dotenv').config({
path: dotenvFile,
})
);
}
});
支持基于 NODE_PATH 来解析程序模块,将 NODE_PATH 里定义的相对路径,转换为基于应用程序的绝对路径。如何项目想使用非标准布局,可以考虑使用 NODE_PATH 来解析。
const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || '')
.split(path.delimiter)
.filter(folder => folder && !path.isAbsolute(folder))
.map(folder => path.resolve(appDirectory, folder))
.join(path.delimiter);
过滤出 REACT_APP_ 开头的环境变量后,与 NODE_ENV 和 PUBLIC_URL 一起提供给 Webpack 的 DefinePlugin。应用程序中可以随意通过process.env.REACT_APP_*
的方式使用定义的变量。
const REACT_APP = /^REACT_APP_/i;
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
// Useful for determining whether we’re running in production mode.
// Most importantly, it switches React into the correct mode.
NODE_ENV: process.env.NODE_ENV || 'development',
// Useful for resolving the correct path to static assets in `public`.
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// This should only be used as an escape hatch. Normally you would put
// images into the `src` and `import` them in code to get their paths.
PUBLIC_URL: publicUrl,
}
);
// Stringify all values so we can feed into Webpack DefinePlugin
const stringified = {
'process.env': Object.keys(raw).reduce((env, key) => {
env[key] = JSON.stringify(raw[key]);
return env;
}, {}),
};
return { raw, stringified };
}
webpack.config.js分析
看文件名就可以知道,肯定和 Webpack 打包有关,create-react-app中是通过定义一个 Webpack 工厂函数来实现开发和产品环境区分的,通过之间传入对应的环境参数不同,生成不同环境的打包配置。
module.exports = function(webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
...
}
getStyleLoaders
函数定义了处理 Css 所需要的 loaders。
-
style-loader 通过注入<style>标签将CSS添加到DOM,建议将
style-loader
与css-loader
结合使用。 -
css-loader 解释
@import
和url()
,会import/require()
后再解析它们,主要用于将 CSS 转换为JS模块。 -
postcss-loader 启用
postcss
来处理 Css,需要另外配置。通过配置不同插件,可以完成非常强大的功能。postcss -
sass-loader 加载
SASS / SCSS
文件并将其编译为 CSS。
产品环境中,使用的 MiniCssExtractPlugin 插件将每个JS中包含的CSS提取为独立文件。
以下我们重点关注一些新增的配置或插件。
bail
编译遇到错误立即终止打包过程
output.pathinfo
告诉 webpack 在 bundle 中引入「所包含模块信息」的相关注释
optimization.minimize
Webpack4 启动的优化配置,一般只在产品环境设置。
optimization.splitChunks
根据注释理解嗯,如下配置会自动开启 vendor 和 commons 的分割。
splitChunks: {
chunks: 'all',
name: false,
},
但实际执行效果和手动配置有差异。
// 代码块分割配置
splitChunks: {
cacheGroups: {
vendor: {
// 抽取出来文件的名字,默认为 true,表示自动生成文件名
name: "vendor",
// 表示从所有chunks里面抽取代码, 可选值为initial、async、all,也可以自定义函数
chunks: "all",
// 表示要过滤 modules, 这里限制为 node_modules
test: /node_modules/,
// 表示抽取权重,数字越大表示优先级越高。
priority: 20,
// 表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的
reuseExistingChunk: true
},
commons: {
// 抽取出来文件的名字,默认为 true,表示自动生成文件名
name: "commons",
// 从初始chunks里面抽取代码
chunks: "initial",
// 表示被引用次数,默认为1
minChunks: 2,
// 表示抽取出来的文件在压缩前的最小大小
minSize: 0,
// 表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的
reuseExistingChunk: true
}
}
},
terser-webpack-plugin插件(仅限产品环境)
在之前的配置中使用的 uglifyjs-webpack-plugin 插件不支持 es6 语法的解析,需要配合 Babel 一起使用,现在通过 terser-webpack-plugin 插件可以直接完成。
optimize-css-assets-webpack-plugin插件(仅限产品环境)
优化及压缩CSS的插件。并且配置使用 postcss-safe-parser 这个能修复语法错误的 PostCSS 的容错CSS解析器。
pnp-webpack-plugin插件
添加支持由Yarn 团队开发的 PnP 特性。解决现有的依赖管理方式效率太低,引用依赖时慢,安装依赖时也慢的痛点。该特性还比较新,实际尝试了下,安装体验确实很大改善,不过相对来讲如果想要查看对应的源码就相当麻烦了,同时想添加区别于全局的特定版本也需要额外操作。详情可以参考此博文 Yarn 的 Plug'n'Play 特性。
react-dev-utils/ModuleScopePlugin插件
react-dev-utils 工具集提供的插件,禁止导入 src 及 node_modules 文件夹以外的模块。
module.strictExportPresence
使缺少的导出出现错误而不是警告
module.rules里的oneOf
后接 loader 数组,会遍历所有 loader 直到有一个符合要求,最终缺少 loader 的情况下,会由最后的 file-loader 完成解析。
node
nodejs 标准模块的mock。使 nodejs 编写的程序能够在类似浏览器等环境运行。
performance
关闭 bundle 文件大小提示,create react app 使用了自带的 react-dev-utils/FileSizeReporter 插件。
html-webpack-plugin插件
相对之前的配置,针对产品环境,增加了压缩处理。
react-dev-utils/InterpolateHtmlPlugin
react-dev-utils 工具集提供的插件,与 HtmlWebpackPlugin 一起使用,以在index.html中嵌入值。
react-dev-utils/ModuleNotFoundPlugin
react-dev-utils 工具集提供的插件,创建模块未找到而错误的上下文环境。
case-sensitive-paths-webpack-plugin插件
强制所有需要的模块的整个路径匹配磁盘上实际路径的具体情况。适用于 window 环境和 osx 环境共同开发的情况。
react-dev-utils/WatchMissingNodeModulesPlugin
react-dev-utils 工具集提供的插件,Webpack 在缺少相关包时会抛出错误。
如果执行 yarn install 后,除非重启 devServer,否则通常无法识别该包。该插件会在安装新包时,自动识别它而无需重新启动 devServer。
webpack-manifest-plugin插件
生成项目的清单文件,包含所有资产的引用。要开启 PWA 功能时,会使用到该清单文件。
fork-ts-checker-webpack-plugin插件
使用专门线程来进行 ts 类型检查,目的就是运用多核资源来提升编译的速度。
IgnorePlugin
指定不加载某些第三方包的资源。例如忽略moment 2.18的本地化内容。
优化配置
基于以上的分析我们可以对原有程序做做优化。
新增 config/webpack/getModuleRules.js
// 将每个JS中包含的CSS提取为独立文件
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
// 允许通过读取browserslist配置来部分加载 normalize.css或sanitize.css
const postcssNormalize = require("postcss-normalize");
const path = require("path");
const fs = require("fs");
// 获取nodejs执行的工作目录
const appDirectory = fs.realpathSync(process.cwd());
// 获取相对于工作目录的相对路径的真实路径
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
// 定义正则匹配
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
const getCssRules = webpackEnv => {
// 是否为开发环境
const isEnvDevelopment = webpackEnv === "development";
// 是否为产品环境
const isEnvProduction = webpackEnv === "production";
// 启用/禁用 Sourcemap 开发环境启用/产品环境禁用
const shouldUseSourceMap = isEnvDevelopment ? true : false;
// 根据环境,获取style相关loader数组
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
// 开发环境使用style-loader
isEnvDevelopment && require.resolve("style-loader"),
// 生产环境使用MiniCssExtractPlugin.loader
isEnvProduction && {
loader: MiniCssExtractPlugin.loader
},
// 解释 @import 和 url() ,会 import/require() 后再解析它们,主要用于将 CSS 转换为JS模块
{
loader: require.resolve("css-loader"),
options: cssOptions
},
{
// 启用postcss
loader: require.resolve("postcss-loader"),
options: {
// 解决引用外部css出现的异常
// https://github.com/facebook/create-react-app/issues/2677
ident: "postcss",
plugins: () => [
require("postcss-flexbugs-fixes"),
// 允许你使用未来的 CSS 特性
require("postcss-preset-env")({
// 自动添加前缀
autoprefixer: {
flexbox: "no-2009"
},
// 填充语法允许使用标准stage3阶段
stage: 3
}),
postcssNormalize()
],
sourceMap: shouldUseSourceMap
}
}
].filter(Boolean);
// 添加其他loader sass或less等
if (preProcessor) {
loaders.push({
loader: require.resolve(preProcessor),
options: {
sourceMap: shouldUseSourceMap
}
});
}
return loaders;
};
return [
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
// 用于配置css-loader作用于 @import的资源之前有多少个loader
importLoaders: 1,
// 是否开启sourceMap
sourceMap: shouldUseSourceMap
}),
sideEffects: true
},
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: shouldUseSourceMap,
modules: true
})
},
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: shouldUseSourceMap
},
require.resolve("sass-loader")
),
sideEffects: true
},
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: shouldUseSourceMap,
modules: true
},
require.resolve("sass-loader")
)
}
];
};
// 获取完整的模块处理规则
const getModuleRules = webpackEnv => {
return [
// 解析图片资源
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "static/media/[name].[hash:8].[ext]"
}
},
// babel-loader解析typescript
{
test: /\.(ts|tsx|js|jsx)$/,
exclude: /node_modules/,
include: resolveApp("src"),
use: {
loader: require.resolve("babel-loader")
}
},
// css解析相关loaders
...getCssRules(webpackEnv),
// 其他文件解析
{
loader: require.resolve("file-loader"),
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
name: "static/media/[name].[hash:8].[ext]"
}
}
];
};
module.exports = getModuleRules;
通过传递不同环境参数,组合 Webpack 中 oneOf 所需要使用的模块解析规则。
新增 config/webpack/getEnvVariables.js
const fs = require("fs");
const getEnvVariables = webpackEnv => {
// 默认仅加载 .env.production | .env.development | .env.test格式的变量定义文件
const dotenvFiles = [`.env.${webpackEnv}`].filter(Boolean);
// 将计算机自身和自定义变量加载到nodejs环境中
dotenvFiles.forEach(dotenvFile => {
if (fs.existsSync(dotenvFile)) {
require("dotenv-expand")(
require("dotenv").config({
path: dotenvFile
})
);
}
});
// 假定应用程序中所使用的环境变量都是以 APP_ 开头
const VAR_PREFIX = /^APP_/i;
// 生成需要注入的变量
const raw = Object.keys(process.env)
.filter(key => VAR_PREFIX.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
// 增加环境变量
NODE_ENV: webpackEnv
}
);
// 需要注入的变量字符串化
const stringified = {
"process.env": Object.keys(raw).reduce((env, key) => {
env[key] = JSON.stringify(raw[key]);
return env;
}, {})
};
return { raw, stringified };
};
module.exports = getEnvVariables;
项目可以在根目录添加类似 .env.development,.env.production,.env.test的区分不同环境的变量,推荐将 .env.development 加入 git 管理,以便协作的同学清楚工程中环境变量的定义。
比如我们新增 .env.development
APP_DB_URL=127.0.0.1
APP_DB_USERNAME=admin
APP_DB_PASSWORD=12345
具体的 webpack 配置文件调整分别如下所示:
webpack.common.js
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const path = require("path");
const webpack = require("webpack");
module.exports = {
// 入口文件
entry: "./src/index.tsx",
// 需要解析的文件后缀名
resolve: {
extensions: [".tsx", ".ts", ".js"],
modules: ["node_modules", path.resolve(__dirname, "src")],
},
// 管理插件,通过插件实现增强功能
plugins: [
// 自动清理dist
new CleanWebpackPlugin(),
// 生成清单目录
new ManifestPlugin({
fileName: "asset-manifest.json",
generate: (seed, files) => {
const manifestFiles = files.reduce(function(manifest, file) {
manifest[file.name] = file.path;
return manifest;
}, seed);
return {
files: manifestFiles
};
}
}),
// 忽略moment 2.18的本地化内容
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
],
// 配置项目处理的不同文件及模块
module: {
// 使缺少的导出出现错误而不是警告
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
{
enforce: "pre",
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
include: path.resolve(__dirname, "src"),
loader: "eslint-loader"
}
]
},
// 管理输出
output: {
// 定义输出文件名路径
path: path.resolve(__dirname, "dist"),
publicPath: "/"
},
optimization: {
// 代码块分割配置
splitChunks: {
cacheGroups: {
vendor: {
// 抽取出来文件的名字,默认为 true,表示自动生成文件名
name: "vendor",
// 表示从所有chunks里面抽取代码, 可选值为initial、async、all,也可以自定义函数
chunks: "all",
// 表示要过滤 modules, 这里限制为 node_modules
test: /node_modules/,
// 表示抽取权重,数字越大表示优先级越高。
priority: 20,
// 表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的
reuseExistingChunk: true
},
commons: {
// 抽取出来文件的名字,默认为 true,表示自动生成文件名
name: "commons",
// 从初始chunks里面抽取代码
chunks: "initial",
// 表示被引用次数,默认为1
minChunks: 2,
// 表示抽取出来的文件在压缩前的最小大小
minSize: 0,
// 表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的
reuseExistingChunk: true
}
}
},
// manifest分割配置
runtimeChunk: true
},
};
webpack.dev.js
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const getModuleRules = require("./config/webpack/getModuleRules");
const getEnvVariables = require("./config/webpack/getEnvVariables");
// 开发环境
const webpackDev = "development";
// 定义模块解析规则
const rules = getModuleRules(webpackDev);
// 获取环境变量定义
const env = getEnvVariables(webpackDev);
// 将环境定义注入到Nodejs中,后续Babel等配置会使用该变量
process.env.NODE_ENV = webpackDev;
module.exports = merge(common, {
// 标识配置为开发用
mode: webpackDev,
// 控制是否生成,以及如何生成 source map
devtool: "cheap-module-source-map",
// 管理开发服务器
devServer: {
// 开启服务器路由支持,默认定位根目录index.html
historyApiFallback: true,
// 查找文件路径
contentBase: "dist",
// 启用 HMR
hot: true
},
plugins: [
// 当开启 HMR 的时候使用该插件会显示模块的相对路径,建议用于开发环境
new webpack.NamedModulesPlugin(),
// 启用 HMR 热更新,建议用于开发环境
new webpack.HotModuleReplacementPlugin(),
// 预设程序执行环境
new webpack.DefinePlugin(env.stringified),
// 根据模板生成html
new HtmlWebpackPlugin({
title: "My App",
template: "./src/index.html"
})
],
// 管理输出
output: {
// 定义输出文件名规则
filename: "static/js/bundle.js",
// 定义非入口(non-entry) chunk 文件的名称
chunkFilename: "static/js/[name].chunk.js",
// 告诉 webpack 在 bundle 中引入「所包含模块信息」的相关注释
pathinfo: true
},
// 配置项目处理的不同文件及模块
module: {
// 配置项目处理模块规则
rules: [
{
oneOf: rules
}
]
}
});
webpack.prod.js
,生成环境去掉了之前采用的 UglifyJSPlugin JS压缩插件。
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
// const UglifyJSPlugin = require("uglifyjs-webpack-plugin");
const webpack = require("webpack");
const TerserPlugin = require("terser-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const safePostCssParser = require("postcss-safe-parser");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const getModuleRules = require("./config/webpack/getModuleRules");
const getEnvVariables = require("./config/webpack/getEnvVariables");
// 生产环境
const webpackDev = "production";
// 定义模块解析规则
const rules = getModuleRules(webpackDev);
// 获取环境变量定义
const env = getEnvVariables(webpackDev);
// 将环境定义注入到Nodejs中,后续Babel等配置会使用该变量
process.env.NODE_ENV = webpackDev;
module.exports = merge(common, {
// 标识配置为生产用
mode: webpackDev,
// 编译遇到错误立即终止打包过程
bail: true,
// 控制是否生成,以及如何生成 source map
devtool: false,
plugins: [
// 预设程序执行环境
new webpack.DefinePlugin(env.stringified),
new MiniCssExtractPlugin({
filename: "static/css/[name].[contenthash:8].css",
chunkFilename: "static/css/[name].[contenthash:8].chunk.css"
}),
// 根据模板生成html
new HtmlWebpackPlugin({
title: "My App",
template: "./src/index.html",
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true
}
})
],
// 管理输出
output: {
// 定义输出文件名规则
filename: "static/js/[name].[contenthash:8].js",
// 定义非入口(non-entry) chunk 文件的名称
chunkFilename: "static/js/[name].[contenthash:8].chunk.js"
},
// 代码分割配置
optimization: {
// 启用js代码压缩,生产环境默认为true
minimize: true,
// 指定自定义压缩插件
minimizer: [
// Terser配置
new TerserPlugin({
terserOptions: {
parse: {
ecma: 8
},
compress: {
ecma: 5,
warnings: false,
comparisons: false,
inline: 2
},
mangle: {
safari10: true
},
output: {
ecma: 5,
comments: false,
ascii_only: true
}
},
// 开启多线程,加快编译速度
parallel: true,
// 开启文件缓存
cache: true,
// 关闭sourceMap
sourceMap: false
}),
// 优化及压缩CSS
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
parser: safePostCssParser,
map: false
}
})
]
},
// 配置项目处理的不同文件及模块
module: {
// 配置项目处理模块规则
rules: [
{
oneOf: rules
}
]
}
});