手把手带你自定义配置Angular CLI下的Webpack和loader处理

文章2022-04-18160 人已阅来源:网络

本篇文章通过案例介绍一下Angular CLI下的自定义Webpack配置方法和自定义loader处理的方法,希望对大家有所帮助!

手把手带你自定义配置Angular CLI下的Webpack和loader处理

1 Angular 使用自定义Webpack配置方法

1.1 背景

使用Angular CLI新建工程后,一键式的配置已经能满足大部分需求,但针对个体述求,可能会希望给webpack配置一些额外的loader或者plugins。【相关教程推荐:《angular教程》】

1.2 替换Builder实现外部配置webpack

angular.json 暴露了多种Builder可以替换的接口,如果需要使用自定义webpack配置可以替换一下builder。 @angular-builders/custom-webpackngx-build-plus都提供了对应的builder,查看npm的趋势custom-webpack用户比较多,这里以custom-webpack为例,介绍如何修改angular.json以用上自定义的webpack配置。

1.3 安装Builder的包

由于@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配置的情况下局部配置自己想要的功能。

1.4 配置方法

在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的扩展配置所需要的基础步骤就完成了。

2 使用自定义Webpack配置案例-loader篇

2.1 案例1:使用PostCSS插件来处理CSS降级插值(主题化降级)

背景

组件库主题化采用了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负责了最后内容的呈现。

2.2 案例2: 读取配置上下文处理:SCSS/LESS 处理TS别名路径同步处理 (别名模块联调)

背景

组件库的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如下:

手把手带你自定义配置Angular CLI下的Webpack和loader处理

tsconfig.app.json 继承了tsconfig.json有如下别名配置

{
   ....
"compilerOptions":{
    "paths": {
      "ng-devui": ["devui/index.ts"],
      "ng-devui/*": ["devui/*"]
    }
}
...
}

手把手带你自定义配置Angular CLI下的Webpack和loader处理

tsconfig.app.separate.json又继承了tsconfig.app.json并且覆写了path字段,

{
   ....
"compilerOptions":{
    "paths": {
      "ng-devui": ["./publish"],
      "ng-devui/*": ["./publish/*"]
    }
}
...
}

手把手带你自定义配置Angular CLI下的Webpack和loader处理

所以当npm run startng serve)的时候,会直接从ts目录读取文件,直接走webpack构建,编译速度快;

npm run build:prodng 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的写法,接收参数。

更多解法补充

resolve.alias

Webpack配置中 config.resolve.alias 也是一个配置别名的地方, 而且sass-loader/less-loader也支持了webpackImporter的配置,其实可以直接通过修改config中的alias就能达成目的。
比如:

config.resolve.alias = {
      ... config.resolve.alias,
      'ng-devui': path.resolve('./devui/'),
   })

resolve.plugins:TsConfigPathsPlugin

那么如果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的配置获取问题。

2.3 案例3:修改TerserPlugin排除项,摇树处理问题(摇树问题)

背景

组件库的业务使用方刚升级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,不对其进行破坏。

2.4 案例4: 三方ES2015库自动babel化处理(仅有ES2015的库处理)

背景

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上下文进行编译。

2.5 案例5: 拦截修改自动生成ts内容(内容自动扫描生成)

案例5-1: 自动扫描目录

背景

在可视化拖拽生产力平台项目,组件定义目录会有一系列重复雷同的目录结构,最后需要汇总到一个ts里作为全局信息入口。

目标:自动扫描组件定义目录,汇总信息到ts里,避免手动增加信息维护。

上下文:

生成的信息汇总内容结构为:

src/app/connect/connet.ts

手把手带你自定义配置Angular CLI下的Webpack和loader处理

目录的结构为:

src/component-lib

手把手带你自定义配置Angular CLI下的Webpack和loader处理

要求不要包含 _目录的内容

解决方案

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"
  ]
}

要点:

  • 自动扫描逻辑

  • 添加依赖

  • 添加非直接依赖的编译入口

案例5-2: 自动扫描css内容

背景

图标库导出了一系列可用字体图标,图标通过不同的css类名来引用,现在要做一个图标选择器,需要把所有图标列出来。

目标:分析icon.css,提取所有有效的icon名字。

上下文:

需要把信息自动生成到一个文件叫 src/app/properties-panel/properties-control/icon-picker/icon-library.data.ts

格式为一个数组

手把手带你自定义配置Angular CLI下的Webpack和loader处理

图标库的文件为./node_modules/@devui-design/icons/icomoon/devui-icon.css

格式为:

手把手带你自定义配置Angular CLI下的Webpack和loader处理

图片里红色标记的就是要提取出来的图标名。

解决方案

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、分析图标库文件结构,排除一些对齐的干扰项

2.6 补充:自定义Webpack配置loader的调试方法

  • 配置项调试:在方法中进行打印调试, 通常在执行前就已经可以打印出配置项相关的内容。

  • 内容调试:使用简单的loader打印前后经过loader的内容, 自定义一个打印内容的loader,在执行自定义loader前后都打印一下,可以获得对比内容。

  • 一些经验: 修改loader的时候要注意本地开发阶段和生产打包阶段,不要顾此失彼。

3 总结

本文介绍了如何在AngularCLI生成的工程里使用自定义的webpack设置,并且举了几个实际情况下为了解决问题修改的webpack配置,覆盖css的修改、js的修改以及拦截内容自动扫描生成。

本文主要为解决问题或者功能特性去修改webpack配置,没有涉及复杂的构建速度优化、性能优化等。

本文的有些内容是旧版本处理IE11问题做兼容的,具体的实践不再具备复制方案就能解决现实问题的意义!