您好,登錄后才能下訂單哦!
今天就跟大家聊聊有關如何自定義配置Angular CLI下的Webpack和loader處理,可能很多人都不太了解,為了讓大家更加了解,小編給大家總結了以下內容,希望大家根據這篇文章可以有所收獲。
使用Angular CLI新建工程后,一鍵式的配置已經能滿足大部分需求,但針對個體述求,可能會希望給webpack配置一些額外的loader或者plugins。【相關教程推薦:《angular教程》】
angular.json 暴露了多種Builder可以替換的接口,如果需要使用自定義webpack配置可以替換一下builder。 @angular-builders/custom-webpack
和 ngx-build-plus
都提供了對應的builder,查看npm的趨勢custom-webpack用戶比較多,這里以custom-webpack為例,介紹如何修改angular.json以用上自定義的webpack配置。
由于@angular-builders/custom-webpack
并不是ng官方的包,所以使用前都需要先安裝一下:
npm install @angular-builders/custom-webpack
不同的ng版本需要安裝對應不同的版本的包, ng的大部分庫目前有一個約定俗成的好習慣,就是主版本號和ng的主版本號是能夠對上的。比如使用的是ng12,那就用custom-webpack@12的版本。那么為什么需要這么多版本,原因是ng在自己的不同版本下的默認使用的@angular-devkit/build-angular
包的內容和結構甚至schema結構和位置可能會發生變化。對于custom-webpack來說更多是是繼承build-angular的schema和代碼,并暴露webpack的修改入口,讓用戶不需要了解整個webpack配置的情況下局部配置自己想要的功能。
在angular.json文件中,替換@angular-devkit/build-angular
為@angular-builders/custom-webpack
, 主要包括browser、dev-server、karma等幾個不同環節的builder,并增加配置參數
"build": { "builder": "@angular-builders/custom-webpack:browser", "options": { // 以下為新增的配置 customWebpackConfig "customWebpackConfig": { "path": "scripts/extra-webpack.config.js" }, .... }, "configurations": ... },
path可以按自己的工程來指定。 該文件可以導出一個函數(將會被調用)或者一段webpack配置(將會被Merge Options)。
從使用情況來說函數靈活性更好,可以直接操作整個webpack配置。示例文件內容
// extra-webpack.config.js module.exports = (config) => { // do something.. return config; };
至此,webpack的擴展配置所需要的基礎步驟就完成了。
組件庫主題化采用了css-var方案進行主題化定制,通過運行時替換樣式:root里的css自定義屬性的值來達到變更主題色的功能。對于IE來說它不認識也無法解析帶var的值,那么它會表現為無顏色。為了盡量滿足漸進增強和優雅退化。我們需要做一些兼容,以便IE無法使用主題化的情況下也能正常顯示顏色。
目標:
color: var(--devui-brand, #5e7ce0); -> color: #5e7ce0; color: var(--devui-brand, #5e7ce0);
上下文:
為了規范顏色的使用,庫里使用的是scss變量來約束。如$devui-brand: var(--devui-brand, #5e7ce0)
, 本身這種寫法是能滿足現代瀏覽器的降級的,當找不到--devui-brand的css自定義屬性,會回落到后面的色值,但是IE不認識var所以無法讀出色值。組件的樣式文件引用是定義文件然后直接使用$devui-brand
作為值,如下
@import '~ng-devui/styles-var/devui-var.scss'; .custom-class { color: $devui-brand; }
默認編譯完為:
.custom-class { color: var(--devui-brand, #5e7ce0); }
既然已經知道目標了,那么這件事情就變得簡單多了,通過插樁(console.log)查看默認NG工程啟動的webpack配置,可以看到module里有兩個rule是負責處理SCSS和SASS文件的,
它們都擁有test: /\.scss$|\.sass$/
字段,一個負責全局的scss的編譯(通過include字段指定了配置在angular.json的style的路徑集合),一個負責全局以外的組件內引用的scss的處理(通過exclude字段排除了前面全局已經處理過的scss)。
通常第一個想法可能是處理sass,遇到$devui-brand
的地方前面插入一句它的原始值。但是由于sass變量本身可能被二次賦值,如$my-brand: $devui-brand; color: $my-brand;
,這時候遇到$devui-brand
的就插值的顯然不合適,重復的定義$my-brand只是會最后一個值生效。
換個思路,當scss展開為css之后,每個取值的位置就是確定的了,哪怕二次賦值的地方也是同一個終值了。這時候就可以采用腳本來寫IE的降級,也就是目標所寫的內容。
那么,我們可以再sass-loader處理完之后增加一個loader來處理這段css。對css的處理使用PostCSS能對語法結構進行走查更嚴謹。
最后修改代碼如下:
// webpack-config-add-theme.js function webpackConfigAddThemeSupportForIE(config) { [{ ruleTest: /\.scss$|\.sass$/, loaderName: 'sass-loader' }, { ruleTest: /\.less$/, loaderName: 'less-loader' }].forEach(({ruleTest, loaderName}) => { config.module.rules.filter(rule => rule.test + '' === ruleTest + '').forEach((styleRule) => { if (styleRule) { var insertPosition = styleRule.use.findIndex(loaderUse => loaderUse.loader === loaderName || loaderUse.loader === require.resolve(loaderName)); if (insertPosition > -1) { styleRule.use.splice(insertPosition, 0, { loader: 'postcss-loader', options: { sourceMap: styleRule.use[insertPosition].options.sourceMap, plugins: () => { return [ require('./add-origin-varvalue'), ]; } } }); } } }); }); return config; }; module.exports = webpackConfigAddThemeSupportForIE;
代碼大致邏輯為尋找test為less/sass正則的rule,在對應的use里的loader里找到less-loader/sass-loader的位置,然后在其數組位置前面增加一個postcss-loader,loader里使用了自定義的add-origin-varvalue的PostCSS插件。(備注:這里有一塊邏輯是找到sass-loader的位置, 這里有兩個等式是因為ng7,8和ng9用戶的loader寫法不一樣了,之前ng7用字符串,后面ng9用的是文件路徑)
PostCSS插件如下:
var postcss = require('postcss'); var varStringJoinSeparator = 'devui-(?:.*?)'; var cssVarReg = new RegExp('var\\(\\-\\-(?:' + varStringJoinSeparator + '),(.*?)\\)', 'g'); module.exports = postcss.plugin('postcss-plugin-add-origin-varvalue', () => { return (root) => { root.walkDecls(decl => { if (decl.type !== 'comment' && decl.value && decl.value.match(cssVarReg)) { decl.cloneBefore({value: decl.value.replace(cssVarReg, (match, item) => item) }); } }); } });
代碼的大致邏輯如下,通過postcss.plugin定義了一個插件,該插件遍歷css每一條declarion(聲明),如果不是注釋,且它的值(對于每一條css聲明來說冒號左邊稱為property,postcss里為decl.prop;右邊稱為value,postcss里為decl.value)剛好匹配了正則規則(這里的正則規則為--devui-開頭),則在這條規則的前面插入該規則且把值替換為原規則逗號后面的值。
最后掛載到extra-webpack-config里
// extra-webpack.config.js const webpackConfigAddTheme = require('./webpack-config-add-theme'); module.exports = (config) => { return webpackConfigAddTheme(config); };
至此我們達成了我們的目標,而且對插值的范圍做了限定,限定為--devui
開頭的才需要插值,避免其他不想被處理的var被處理了。
要點:
找準CSS處理的位置, sass存在變量依賴問題,更適合在編譯后的css文件里處理
掌握PostCss插件的簡單寫法, sourceMap選項維持不變
注意loader的處理順序,是從use里的最后一個loader接收原始數據不斷往前面的loader傳遞,最前面的loader負責了最后內容的呈現。
組件庫的demo對組件的引用,我們通過tsconfig里的alias實現了ts的別名引用,并在網站生產構建階段采用了分開構建,先構建庫,然后配置另外的tsconfig指向了構建完的庫(不再直接指向源碼)。
一方面使得demo看起來用法和業務一致,另一方面分開構建實現生產端組件庫的demo的使用方法和業務使用方法完全一致,減少因為webpack構建和ng-packagr構建出來后一些細微差別導致問題沒有提前暴露出來。
這些通過tsconfig和配置build的不同的configuration已經可以實現了,但是僅僅只適用于ts文件,導出的scss文件/less文件就不生效了(由于支持外部主題化變量使用,scss文件和less文件會導出)。
目標: sass、less文件實現ts別名一樣的引用路徑。
上下文:
現有angular.json里配置了兩個configuration,一個是使用默認的tsconfig.app.json,一個是分開構建的tsconfig.app.separate.json。
angular.json如下:
tsconfig.app.json 繼承了tsconfig.json有如下別名配置
{ .... "compilerOptions":{ "paths": { "ng-devui": ["devui/index.ts"], "ng-devui/*": ["devui/*"] } } ... }
tsconfig.app.separate.json又繼承了tsconfig.app.json并且覆寫了path字段,
{ .... "compilerOptions":{ "paths": { "ng-devui": ["./publish"], "ng-devui/*": ["./publish/*"] } } ... }
所以當npm run start
(ng serve
)的時候,會直接從ts目錄讀取文件,直接走webpack構建,編譯速度快;
當npm run build:prod
(ng build --prod --configuration separate
)的時候,會從組件構建的目錄./publish/
下找尋npm包同目錄結構的組件。
以上就是整個不同環境采用不同ts配置達到不同的構建,可以看出來ts別名在這里起到非常大的作用。
然而我們的npm二方庫的包里面還導出了.scss 和 .less 文件。在demo里我們可以非常簡單的用ts別名‘ng-devui’引用 ./devui
目錄的文件,在生產打包又會自動引用 ./publish
目錄下的組件非常方便。
和業務側在代碼里引用node_modules目錄下的文件是一樣的寫法,最后構建也是一樣的編譯路徑,屏蔽了這一層差異。但是sass和less文件卻不支持再引用包里的變量,原因是,當ts文件請求了sass文件,這一層的路徑處理是webpack處理的,但是sass-loader接手之后(less-loader也是同理,這里僅直接說sass-loader),sass內部對sass文件引用的處理是sass-loader去啟動一個sass編譯器實例編譯拿到的sass文件內容,該sass編譯器實例也直接處理了sass文件之間的引用。
好在sass-loader其實提供了一個importer的配置option,這里可以弄點文章。
importer提供了同步和異步的api,考慮不阻塞我們采用異步的api function(url, prev, done)
,它可以直接返回內容{content: string}
或者返回文件的實際路徑{file: string}
,而且它規定了如果返回null則代表這個importer里找不到,它會繼續鏈式調用查找其他importer。
這個是一個很關鍵的點。
通過走讀sass-loader本身的代碼,我們可以看到傳給sass-loader的importer會和它內置的波浪線(~
)的importer合并,見代碼1,代碼2。
也就是說,我們可以實現自己的importer的同時,仍然保留sass-loader內置的波浪線解析到node_module的語法糖。
和案例1的思路一樣,我們可以通過webpack配置的module的rules里找到sass-loader,并給它的options的sassOptions傳入一個importer的數組,這樣就可以完成importer的插入。
那么下一個問題就是,我們怎么從運行時拿出對應的別名路徑映射過去?
Angular在編譯的時候,有一個AngularCompilerPlugin
(require('@ngtools/webpack').AngularCompilerPlugin
)會用于處理angular.json的build不同configuration下對應的tsconfig文件路徑,Angular Compiler CLI內又導出了一個readConfiguration
函數(require('@angular/compiler-cli').readConfiguration
)用于解析路徑下的tsconfig下的最后真實的配置。
tsconfig的描述文件是可以具有擴展功能的,可以拓展另一個tsconfig文件,readConfiguration幫我們解決了擴展過的tsconfig的合并問題。這樣就能拿到當前運行環境對應的tsconfig里面的path別名配置了。
下一步就是簡單的取出path數據進行一個簡單的映射,保留波浪線的規則,我們把~ng-devui
在本地開發時候映射到./devui
的,在生成打包時映射到./publish
目錄,在用戶側時候的時候會引用來自node_modules的。
最后代碼如下:
// tsconfig-alias-importer.js const path = require('path'); const readConfiguration = require('@angular/compiler-cli').readConfiguration; function pathAlias(tsconfigPath) { const {baseUrl, paths} = readConfiguration(path.resolve(tsconfigPath)).options; if (!paths) { return []; } return Object.keys(paths) .filter(alias => alias.endsWith('/*')) .map(alias => ( {alias: alias, paths:paths[alias]} )) .map(rule => ({ aliasReg: new RegExp('^~' + rule.alias.replace(/\/\*$/,'/(.*?)')), pathPrefixes: rule.paths.map(pathname => path.resolve(baseUrl || '' , pathname.replace(/\*$/,''))) })); } module.exports = function getTsconfigPathAlias(tsconfigPath = 'tsconfig.json') { try { const rules = pathAlias(tsconfigPath); // 匹配的情況下給出文件 return function importer(url, prev, done) { if (!rules || rules.length === 0) { return null; } for (let rule of rules) { if (rule.aliasReg.test(url)) { // 暫時只支持第一個alias地址,其他的忽略 const prefix = rule.pathPrefixes[0]; const filename = path.resolve(prefix, url.replace(rule.aliasReg, (item, match) => match)); return { file: filename}; } } return null; // 沒有匹配的返回null,以繼續使用下一個importer }; } catch (error) { console.warn('Sass alias importer might not effected', error); return function importer(url, prev, done) { return null; } } }
代碼的大體邏輯是pathAlias函數通過讀tsconfig里的baseUrl和path,過濾出/*
結尾的(因為非/*
結尾的主要是指向index.ts的,不會代理到樣式),然后通過整合組裝成一條條正則和正則要替換的內容,比如這條規則
"ng-devui/*": ["./devui/*"]
通過map轉換為
{ alias:"ng-devui/*", paths: ["./devui/"] }
進一步轉換為
{ aliasReg: /^~ng-devui\/(.*?)/, pathPrefixes: "D:\\code\\ng-devui\devui" // 真實路徑,筆者此處用的是windows系統 }
這里baseUrl最外層tsconfig指向了./
, 也就是工程的根目錄,最后pathResolve會解析為真實的路徑。
導出的getTsconfigPathAlias
這個sass的importer,假定我們有一個文件在./devui/styles-var/devui-var.scss
這個路徑,那么demo引用的時候可以使用~ng-devui/styles-var/devui-var.scss
, 函數將多個別名進行挨個檢測匹配到了, 如果有url匹配到正則,比如目前demo這個引用地址匹配到了 /^~ng-devui\/(.*?)/
, 那么importer會返回{filename: "D:\\code\\ng-devui\\devui\\styles-var\\devui-var.scss"}
。這樣就能找到tsconfig別名里面配置的路徑別名,實現了sass文件引用的別名。
Tsconfig的path別名本身是可以回落到多個地址的,這里簡化成只回落到第一個地址, 如果需要實現多個地址, 可能需要塞進去多個importer或者在一個importer里面檢測文件是否存在,回落到第二個地址,第三個地址。 這時候再把這個importer塞到webpack配置的每個sass-loader里。
// webpack-config-sass-loader-importer.js const AngularCompilerPlugin = require('@ngtools/webpack').AngularCompilerPlugin; const getTsConfigAlias = require('./get-tsconfig-alias'); function getAngularCompilerTsConfigPath(config) { const angularCompilerPlugin = config.plugins.filter(plugin => plugin instanceof AngularCompilerPlugin).pop(); if (angularCompilerPlugin) { return angularCompilerPlugin.options.tsConfigPath; } return undefined; } function webpackConfigSassImporterAlias(config) { const tsconfigPath = getAngularCompilerTsConfigPath(config) || 'tsconfig.json'; [{ ruleTest: /\.scss$|\.sass$/, loaderName: 'sass-loader' }].forEach(({ruleTest, loaderName}) => { config.module.rules.filter(rule => rule.test + '' === ruleTest + '').forEach((styleRule) => { if (styleRule) { var insertPosition = styleRule.use.findIndex(loaderUse => loaderUse.loader === loaderName || loaderUse.loader === require.resolve(loaderName)); if (insertPosition > -1) { styleRule.use[insertPosition].options.sassOptions.importer = [ getTsConfigAlias(tsconfigPath) ]; } } }); }); return config; } module.exports = webpackConfigSassImporterAlias;
這段代碼先從webpack的配置里找到AngualrCompilerPlugin插件,然后讀取它此時的tsconfig路徑。
以上是sass路徑別名的解決,得益于sass本身有一個importer,但是less上這個問題就沒有那么好解決了,less只提供includePaths的選項, 它會挨個遍歷去回落,并且是一視同仁的,即所有文件都會按這個includePaths去挨個嘗試。實際情況less是不支持波浪線的,但是less-loader卻又是支持波浪線語法的,走讀一下less-loader的代碼看看有沒有線索。可以看到less-loader用的寫了一個WebpackFIleManagerment的plugin來做后綴名補充和利用webpack的resolve來做回落判斷。這個邏輯相對來說就比較復雜了。
我們只能換個思路來解決這個問題, less本身是有語法的,也就是我們能從語法中判定哪些是引用外部文件的,在引用之前我們可以處理一下路徑,比如把~ng-devui/styles-var/devui-var.less
處理成相對于less文件的../../styles-var/devui-var.less
那么less編譯器就能理解。
這是我們可以借用前面幾個案例的思路,在less-loader加載前增加一個loader,提前處理less語法里面的import引用語句,直接把波浪線地址替換成我們真實開發環境或者生產打包環境的地址。
const AngularCompilerPlugin = require('@ngtools/webpack').AngularCompilerPlugin; const pathAlias = require('./get-path-alias-from-tsconfig'); function getAngularCompilerTsConfigPath(config) { const angularCompilerPlugin = config.plugins.filter(plugin => plugin instanceof AngularCompilerPlugin).pop(); if (angularCompilerPlugin) { return angularCompilerPlugin.options.tsConfigPath; } return undefined; } function webpackConfigSassImporterAlias(config) { const tsconfigPath = getAngularCompilerTsConfigPath(config) || 'tsconfig.json'; [{ ruleTest: /\.less$/, loaderName: 'less-loader' }].forEach(({ruleTest, loaderName}) => { config.module.rules.filter(rule => rule.test + '' === ruleTest + '').forEach((styleRule) => { if (styleRule) { var insertPosition = styleRule.use.findIndex(loaderUse => loaderUse.loader === loaderName || loaderUse.loader === require.resolve(loaderName)); if (insertPosition > -1) { styleRule.use.splice(insertPosition + 1, 0, { loader: require.resolve('./less-alias-replacer-loader'), options: { aliasMap: pathAlias(tsconfigPath) } }); } } }); }); return config; } module.exports = webpackConfigSassImporterAlias;
這是webpack的修改,代碼大致意思是找到less-loader并在后面位置增加一個自定義的loader,并且把路徑別名從tsconfig的數據取出作為options傳給該loader。
pathAlias的寫法就和前面sass-loader的是一樣的
// get-path-alias-from-tsconfig.js const path = require('path'); const readConfiguration = require('@angular/compiler-cli').readConfiguration; module.exports = function pathAlias(tsconfigPath) { const { baseUrl, paths } = readConfiguration(path.resolve(tsconfigPath)).options; if (!paths) { return []; } return Object.keys(paths) .filter(alias => alias.endsWith('/*')) .map(alias => ( { alias: alias, paths: paths[alias] } )) .map(rule => ({ aliasReg: new RegExp('^~' + rule.alias.replace(/\/\*$/, '/(.*?)')), pathPrefixes: rule.paths.map(pathname => path.resolve(baseUrl || '', pathname.replace(/\*$/, ''))) })); }
const path = require('path'); const { getOptions } = require('loader-utils'); const validateOptions = require('schema-utils'); const postcss = require('postcss'); const postcssLessSyntax = require('postcss-less'); const loaderName = 'less-path-alias-replacer-loader'; const trailingSlashAndContent = /[/\\][^/\\]*?$/; const optionsSchema = { type: 'object', properties: { aliasMap: { anyOf: [{ instanceof: 'Array' }, { enum: [ null ] }] }, }, additionalProperties: false } const defaultOptions = { } function getOptionsFromConfig(config) { const rawOptions = getOptions(config) if (rawOptions) { validateOptions(optionsSchema, rawOptions, loaderName); } return Object.assign({}, defaultOptions, rawOptions); } /** * * @param {*} css less文本內容 * @param {*} aliasMap 別名規則集合 */ function lessReplacePathAlias(css, aliasMap, sourcePath) { const replacePathAlias = postcss.plugin('postcss-plugin-replace-path-alias', () => { return (root) => { root.walkAtRules(atRule => { if (atRule.import && atRule.filename) { const oFilename = atRule.filename.substring(1, atRule.filename.length - 1); // 去掉頭尾單引號雙引號 const rule = aliasMap.filter(rule => rule.aliasReg.test(oFilename)).pop(); if (rule) { const prefix = rule.pathPrefixes[0]; // 取第一個路徑忽略剩余的 const filename = path.resolve(prefix, oFilename.replace(rule.aliasReg, (item, match) => match)); const relativePath = path.relative(sourcePath.replace(trailingSlashAndContent, ""), filename).split(path.sep).join('/'); var realPathAtRule = atRule.clone({ params: (atRule.options || '' ) + " '" + relativePath + "'", filename: "'" + relativePath + "'"}); atRule.replaceWith(realPathAtRule); } } }); } }); return postcss([replacePathAlias]).process(css, { syntax: postcssLessSyntax }).css; } function process(source, map) { this.cacheable && this.cacheable(); // 獲取配置文件里的主題數據 const aliasMap = getOptionsFromConfig(this).aliasMap; let newSource = source; if (aliasMap.length > 0) { newSource = lessReplacePathAlias(source, aliasMap, this.resourcePath); } // 返回結果 this.callback(null, newSource, map); return newSource; } exports.default = process;
這里自定義一個wepack-loader的寫法,實際上也是可以用postcss-loader搭配自定義replacePathAlias 的plugin 和 postcss-less的syntax進行使用。這里演示了wepack-loader的寫法。
代碼大意是定義了一個loader的optionSchema用于校驗選項,process函數獲取option里的路徑別名數據之后,如果路徑別名有數據則用postcss對代碼進行處理。注意在process里this指向webpack的上下文,所以可以從resourcePath里獲取當前文件路徑。
代碼核心為中間的postcss插件, 通過遍歷@開頭的規則,如果是一個import聲明則讀取文件名去掉頭尾的單雙引號;測試是否文件名命中了規則中的任意一條,命中則取第一條,和sass-loader處理的一樣得到了文件的絕對路徑,然后通過path.relative重新計算出和當前文件的相對路徑。然后將這條@import規則替換成新的文件地址。
實際上這個思路同樣適用于sass規則的處理,只需要把語法syntax換成postcss-sass。
我們可以看到前兩個大案例都是在處理css類問題的,大部分時候處理都可以用上postcss利器。直接去操作css內容容易誤修改內容,而經過AST語法樹拆解后的遍歷會更穩當一些。
說一下為什么會同時使用sass和less。一般工程是不會同時使用兩種的。實際上我們的工程主要也是使用sass。但是對于一個打包后的組件庫來說,業務使用的時候是不會感知它是sass還是less的,甚至也不會提供sass或者less文件給業務引用。這里是為了主題化的能力能夠對外輻射,組件庫同時提供了sass和less的版本變量供使用。
這兩個樣式編譯器loader支持路徑別名的腳本最早寫于2020年8月。
Webpack官方在sass-loader/less-loader的使用文檔里面都說明了,~語法已經廢除,建議刪除。
sass-loader@11.0.0(2021-2-5),less-loader@8.0.0(2021-2-1)分別發布了對應版本聲明~已經標記為Deprecated。
作為歷史解決方案,這個案例依然會放在這里,提供一些解決思路。
筆者認為能~和現在的回落解決方案還是不一樣的,尤其當存在同名文件的時候(目前這種情況會比較少,少有人使用模塊同名路徑作為相對目錄路徑),波浪線方案仍然能明顯強調出文件的第一指向。
Webpack官方在2020年8月底開始給less-loader也加上了webpackImporter的選項,進一步屏蔽less-loader和sass-loader之間的差異。兼容歷史原因,這兩個loader目前還會保留波浪線語法。由于項目ng版本滯后于NG官方版本,NG官方版本使用的loader又滯后于webpack官方版本。目前NG9版本仍在用less-loader@5.0.0,sass-loader@8.0.2。
要點:
從運行時獲取tsconfig配置項
文件路徑處理(scss、less)
掌握loader的寫法,接收參數。
Webpack配置中 config.resolve.alias 也是一個配置別名的地方, 而且sass-loader/less-loader也支持了webpackImporter的配置,其實可以直接通過修改config中的alias就能達成目的。 比如:
config.resolve.alias = { ... config.resolve.alias, 'ng-devui': path.resolve('./devui/'), })
那么如果webpack的resolve alias已經支持了,是不是tsconfig就可以不用配置,或者怎么配置成一份?
不幸的是 Angular工程的 webpack 和 tsconfig不是同步的,兩邊需要同時配置,否則會出現找不到模塊的構建錯誤, 如果兩者配置不同,隱患會更大,因為tsconfig的配置是直接影響tsc編譯器的拿不到文件,webpack也會解讀出一份等。
配置成一份可以通過寫webpack-plugin在運行時拿到當前的tsconfig再處理數據塞到alias里。因為如果直接修改config,那么它是靜態的,實際情況還是需要獲取到ng工程當前的ts配置是什么文件會比較好。
Awesome-typescript-loader有個TsConfigPathsPlugin,使用也非常簡單。
const { TsConfigPathsPlugin } = require('awesome-typescript-loader'); module.exports = (config) => { config.resolve.plugins = [ ... (config.resolve.plugins || []), new TsConfigPathsPlugin() ] };
給resolve.plugins塞一個TsConfigPathsPlugin就可以把tsconfig里的,不過這個插件已經存檔了,最后一個版本是3年前的了。這里我們做了一個測試,仍然是不支持動態的tsconfig,它的代碼可以參考用來寫一個resolve的plugin, 動態的tsconfig的path獲取可以參考我們之前的操作。
Webpack config的resolve.alias和 tsconfig的alias不是同步的,需要配置兩份或者找到一個方法同步。
Sass-loader 和less-loader的內部引用都能走webpack的resolve.alias, 可以說resolve.alias的解法會比 在改sass-loader的importer或者在less-loader前先處理 通用性更好,基本上所有blob都可以嘗試去這樣解決路徑別名問題。更多需要注意的仍然是動態的tsconfig的配置獲取問題。
組件庫的業務使用方剛升級ng7的時候,打包經常出問題,經常出現文件搖樹之后,很多自執行命令被搖樹認為無副作用搖樹掉了。
目標:不關掉全局搖樹的情況下,針對個別目錄進行搖樹的問題排除。
上下文:
Angular.json里有個配置,默認為打開,打開之后可以對js類型的代碼進行搖樹優化,從而減小打包體積。
Terser是從Uglifyjs這個庫fork 出來的項目用來支持ES6語法,Angular 編譯階段用它來壓縮js代碼。
Angular使用TerserPlugin來進行搖樹,由于裝飾器等問題導致搖樹效果不理想的問題(相關討論內容見Angular搖樹如何工作),angular提供了一個專門用于標記純函數的注釋的優化器,所以對于默認的Angular項目來說,搖樹是一個兩階段模型。
第一個階段是Angular在生產構建階段(ng build --prod
)在解析資源的時候加入了一個@angular_devkit/build_optimizer/webpack-loader
,用于標記js文件里的無用代碼;angular_devkit/build_optimizer 介紹它的主要功能為標記/* PURE */功能。
第二個階段是Angular在生成打包的時候給Webpack配置了 optimization.minimizer數組塞入了TerserPlugin然后把無副作用的無引用代碼搖掉。(準確說是塞入了兩個Plugin,一個針對globalScript,一個排除globalScript。GlobalScript是指在angular.json的build的script字段配置的路徑。)
TerserPlugin本身是有一個include和exclude字段(見API)可以用正則和字符串來排除。
function terserOptionsWebpackConfig(config) { let excludeList = [ // 此處可以填自己要排除的目錄 ] let minimizerArr = config.optimization.minimizer; let terserPlugins = minimizerArr .filter(plugin => plugin.options && plugin.options.terserOptions) terserPlugins.forEach(terserPlugin => { if (terserPlugin.plugin.exclude) { const isArray = Array.isArray(terserPlugin.plugin.exclude) if (isArray) { terserPlugin.plugin.exclude = [ ...terserPlugin.plugin.exclude, ...excludeList ]; } else { terserPlugin.plugin.exclude = [ terserPlugin.plugin.exclude, ...excludeList ]; } } else { terserPlugin.plugin.exclude = excludeList; } }); return config; }; module.exports = terserOptionsWebpackConfig;
之前在ng7工程會遇到比較多的搖樹問題,有些升級到ng9之后默認配置有點變化之后就沒有搖樹問題了。包括IVY打包模式下編譯引擎實際上搖樹的方式不太一樣,有些文件不會被搖掉了,這個問題還是要遇到具體問題具體分析來解決。必要時可以重新new一個TerserPlugin但是要保持它的原來的options不變。
要點:
搖樹具體的階段,攔截問題
維持原有的Options,不對其進行破壞。
highlight.js升級到10.0.0之后,官方就開始不再默認支持ie11了,導出的包也只有es2015的包。之前的業務需要為了兼容IE11,我們需要對highlight.js進行一次babel。更新到ng9之后,其實ng默認會僅打包es2015然后進行差分打包到es5,所以實際情況在生產打包是沒有問題的,但是在本地開發的ie11調試環節會出現問題,比如class的語法ie不認識等等,導致整個js無法加載。
目標:讓不支持es5的包在開發態支持es5。
上下文:由于之前組件庫9的版在一段時間內仍然需要支持ie11,也就經常需要開發態下到ie11下debug,所以開發態下的highlight.js導致ie無法訪問問題需要解決。
首先es2015轉es5經典的做法就是讓babel幫忙處理。然后大部分的ES語法新增的api可以由core-js來解決,剩下的IE11還有一些瀏覽器端DOM的API的實現還需要添加一些polyfill才行。Polyfill可以用到了什么api就加什么api,具體可以從這里參考,本文不再累述。
// babel-loader-wepack-config.js const path = require('path'); const ts = require('typescript'); const AngularCompilerPlugin = require('@ngtools/webpack').AngularCompilerPlugin; const readConfiguration = require('@angular/compiler-cli').readConfiguration; const ES6_ONLY_THIRD_PARTY_LIST = require('./es6-only-third-party-list'); function getAngularCompilerTsConfigPath(config) { const angularCompilerPlugin = config.plugins.filter(plugin => plugin instanceof AngularCompilerPlugin).pop(); if (angularCompilerPlugin) { return angularCompilerPlugin.options.tsConfigPath; } return undefined; } function getTsconfigCompileTarget(tsconfigPath) { const {target} = readConfiguration(path.resolve(tsconfigPath)).options; return target; } function webpackConfigAddBabel2ES5(config, list = []) { const tsconfigPath = getAngularCompilerTsConfigPath(config) || 'tsconfig.json'; const target = getTsconfigCompileTarget(tsconfigPath); if (target === ts.ScriptTarget.ES5) { config.module.rules.push({ test: /\.js$/, use: [{ loader: 'babel-loader' }], include: [ ...ES6_ONLY_THIRD_PARTY_LIST, ...list ] }); } return config; }; module.exports = webpackConfigAddBabel2ES5; /** * 備注:如果三方庫只提供es6版本, 則添加到ES6_ONLY_THIRD_PARTY_LIST, 通過babel轉換語法到es5 * 僅對target為es5的時候啟用(比如npm start狀態) * 差分打包會自動解決,不需要解決 */
// es6-only-third-party-list.js /** * 如果三方庫只提供es6版本, 則添加到ES6_ONLY_THIRD_PARTY_LIST, 通過babel轉換語法到es5 */ const path = require('path'); const ES6_ONLY_THIRD_PARTY_LIST = [ path.resolve('./node_modules/highlight.js') // ^10.0.0 no longer support ie 11 ]; module.exports = ES6_ONLY_THIRD_PARTY_LIST;
兩段代碼大概思路就是從tsconfig里面讀,如果目標為es5,則塞進去babel-loader,然后把對應的三方庫的路徑放到include里邊。
ES6_ONLY_THIRD_PARTY_LIST 列表示意了highlight.js的路徑應該怎么寫。如果編譯目標為es2015,則這段處理就不需要了不會被插入,哪怕是差分打包也不會調用它,ng-cli會自行調用內部邏輯。
IE11已經慢慢退出了歷史舞臺,各大網站也開始聲明不再支持IE11,這些冗余的插件已經可以慢慢移除。包括ng12起也不再承諾支持ie,升級到ng12之后這些插件也沒有必要了。
要點:
分清語法API的降級和瀏覽器BOM/DOM墊片
提供一個可維護的列表
針對tsconfig的target上下文進行編譯。
在可視化拖拽生產力平臺項目,組件定義目錄會有一系列重復雷同的目錄結構,最后需要匯總到一個ts里作為全局信息入口。
目標:自動掃描組件定義目錄,匯總信息到ts里,避免手動增加信息維護。
上下文:
生成的信息匯總內容結構為:
src/app/connect/connet.ts
目錄的結構為:
src/component-lib
要求不要包含 _目錄的內容
const fs = require('fs').promises; const path = require('path'); async function listDir() { return fs.readdir(path.resolve('./src/component-lib')).then(dirs => dirs.filter(item => !item.startsWith('_'))); // 過濾_開頭的 } function genConnectInfo(dirArr) { return `export const ConnectInfo = { ${dirArr.map(item =>"'"+ item +"': import( /* webpackChunkName: \"component-lib-" + item + "-connect\" */ 'src/component-lib/" + item + "/connect')").join(`, `)} };`; } async function process() { var list = await listDir(); return genConnectInfo(list); } module.exports = function(content, map, meta) { var callback = this.async(); this.addContextDependency(path.resolve('./src/component-lib')); // 自動掃描目錄,但是刪除目錄可能會引起報錯 process().then((result)=> { callback(null, result, map, meta); }, err => { if (err) return callback(err); }); };
const path = require('path'); function webpackConfigAddScanAndGenerateConnectInfo(config) { config.module.rules.push({ test: /connect\.ts$/, use: [{ loader: require.resolve('./scan-n-gen-connect-webpack-loader') }], include: [ path.resolve('./src/app/connect') ], enforce: 'post', }); return config; }; module.exports = webpackConfigAddScanAndGenerateConnectInfo;
通過簡單的一個loader 將目錄掃描內容組裝返回給src/app/connect/connet.ts。
這里有幾個要點:
這里需要使用loader的enforce: 'post'
屬性,因為ts的編譯最后loader是直接去文件系統讀取內容的不是從上一個loader拿到結果繼續往下處理的(不符合loader的規范,但是性能會更好),所以這里需要把階段屬性配置為'post',確保最后編譯的內容是我們生成的內容。
對loader添加一些依賴可以在目錄結構變化的時候刷新內容,否則就只能等下一次啟動的時候獲取內容,即這一行 this.addContextDependency(path.resolve('./src/component-lib'));
由于webpack的tsc編譯是文件分析依賴型的,我們動態生成的文件內容,webpack就無法從中分析依賴了另外一些ts內容,導致其他ts內容不會走ts編譯。這時候可以參考下面,在tsconfing加一下include字段,解決編譯問題。
/* tsconfig.app.json*/ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", "types": [] }, "files": [ "src/main.ts", "src/polyfills.ts" ], "include": [ "src/**/*.d.ts", "src/component-lib/**/*.ts" ] }
要點:
自動掃描邏輯
添加依賴
添加非直接依賴的編譯入口
圖標庫導出了一系列可用字體圖標,圖標通過不同的css類名來引用,現在要做一個圖標選擇器,需要把所有圖標列出來。
目標:分析icon.css,提取所有有效的icon名字。
上下文:
需要把信息自動生成到一個文件叫 src/app/properties-panel/properties-control/icon-picker/icon-library.data.ts
格式為一個數組
圖標庫的文件為./node_modules/@devui-design/icons/icomoon/devui-icon.css
格式為:
圖片里紅色標記的就是要提取出來的圖標名。
loader的定義
// auto-gen-icon-data-webpack-loader.js const fs = require('fs').promises; const path = require('path'); function genIconData(fileContent) { const iconNames = [...fileContent.matchAll(/\.icon-(.*?):before/g)].map(item => item[1]); return `export const ICON_DATA = [ ${iconNames.map(item => "'" + item + "'").join(",")} ]; `; } async function process(file) { const content = await fs.readFile(`${path.resolve(file)}`, 'utf8'); return genIconData(content); } module.exports = function(content, map, meta) { const file = './node_modules/@devui-design/icons/icomoon/devui-icon.css'; var callback = this.async(); this.addDependency(path.resolve(file)); process(file).then((result)=> { callback(null, result, map, meta); }, err => { if (err) return callback(err); }); };
webpack config里塞入loader
const path = require('path'); function webpackConfigAddGenIconData(config) { config.module.rules.push({ test: /icon-library.data\.ts$/, use: [{ loader: require.resolve('./auto-gen-icon-data-webpack-loader') }], include: [ path.resolve('./src/app/properties-panel/properties-control/icon-picker') ], enforce: 'post', }); return config; }; module.exports = webpackConfigAddGenIconData;
代碼相對就比較簡單了。
要點:
1、分析圖標庫文件結構,排除一些對齊的干擾項
配置項調試:在方法中進行打印調試, 通常在執行前就已經可以打印出配置項相關的內容。
內容調試:使用簡單的loader打印前后經過loader的內容, 自定義一個打印內容的loader,在執行自定義loader前后都打印一下,可以獲得對比內容。
一些經驗: 修改loader的時候要注意本地開發階段和生產打包階段,不要顧此失彼。
文章介紹了如何在AngularCLI生成的工程里使用自定義的webpack設置,并且舉了幾個實際情況下為了解決問題修改的webpack配置,覆蓋css的修改、js的修改以及攔截內容自動掃描生成。
本文主要為解決問題或者功能特性去修改webpack配置,沒有涉及復雜的構建速度優化、性能優化等。
有些內容是舊版本處理IE11問題做兼容的,具體的實踐不再具備復制方案就能解決現實問題的意義,更多的是提供一些思路和參考。
Webpack目前幾個版本始終是從入口文件開始,通過規則配置對文件進行加載解析,通過插件攔截整個構建生命周期做一些抽取變換。
大部分時候Angular的默認配置已經能滿足需求,當和三方庫集成、自動化處理結合的時候,采用自定義的配置可以解決問題,節省工作甚至進一步優化性能等等。
看完上述內容,你們對如何自定義配置Angular CLI下的Webpack和loader處理有進一步的了解嗎?如果還想了解更多知識或者相關內容,請關注億速云行業資訊頻道,感謝大家的支持。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。