最近抽時間閱讀了下 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
}
]
}
});