2

背景

公司前端技术栈还处于React+MobxSpring MVC(freemarker+jQuery)共存的阶段,两种技术栈页面难免会存在一些相同的业务功能,如果分别开发和维护,需要投入较大人力成本,因此,我们尝试将React业务组件应用于Spring MVC项目,一处开发多处使用,降低不必要的成本投入。

应用

一、简单封装组件挂载与卸载方法

Spring MVC是面向DOM api的编程,需要给组件封装挂载和卸载的方法。React业务组件可以利用react-dom中的render方法挂载到对应的容器元素上,利用unmountComponentAtNode方法卸载掉容器元素下的元素。

// 引入polyfill,后面会将为什么不用@babel/polyfill
import 'react-app-polyfill/ie9';
import 'react-app-polyfill/stable';
import React from 'react';
import ReactDOM from 'react-dom';
import { MediaPreview } from './src/MediaPreview';

// 引入组件库全部样式,后面会做css tree shaking处理
import '@casstime/bricks/dist/bricks.development.css';
import './styles/index.scss';

;(function () {
  window.MediaPreview = (props, container) => {
    return {
      // 卸载
      close: function () {
        ReactDOM.unmountComponentAtNode(container);
      },
      // 挂载
      open: function (activeIndex) {
        ReactDOM.render(React.createElement(MediaPreview, { ...props, visible: true, activeIndex: activeIndex || 0 }), container);
        // 或者
        // ReactDOM.render(<MediaPreview {...{ ...props, visible: true, activeIndex: activeIndex || 0 }} />, container);
      },
    };
  };
})();

二、babel转译成ES5语法规范,polyfill处理兼容性api

babel在转译的时候,会将源代码分成syntaxapi两部分来处理

  • syntax:类似于展开对象、optional chainletconst等语法;
  • api:类似于[1,2,3].includesnew URL()new URLSearchParams()new Map()等函数、方法;

babel很轻松就转译好syntax,但对于api并不会做任何处理,如果在不支持这些api的浏览器中运行,就会报错,因此需要使用polyfill来处理api,处理兼容性api有以下方案:

@babel/preset-env中有一个配置选项useBuiltIns,用来告诉babel如何处理api。由于这个选项默认值为false,即不处理api
  1. 设置useBuiltIns为“entry”,在入口文件最上方引入@babel/polyfill;或者不设置useBuiltIns和设置useBuiltInsfalse,在webpack entry添加@babel/polyfill。这种配置下,babel会将所有的polyfill全部引入,构建产物体积会很大,需要启用tree shaking清除没有使用的代码;
  2. 启用按需加载,将useBuiltIns改成“usage”,babel就可以按需加载polyfill,并且不需要手动引入@babel/polyfill但依然需要安装它
  3. 上述两种方法存在两个问题,① polyfill注入的方法会改变全局变量的原型(篡改原型链),可能带来意料之外的问题。② 转译syntax时,会注入一些辅助函数来帮忙转译,这些helper函数会在每个需要转译的文件中定义一份,导致最终的产物含有大量重复的helper。因此,引入@babel/plugin-transform-runtimehelperapi都改为从一个统一的地方引入,并且引入的对象和全局变量是完全隔离的,既不会篡改原型链,亦不会出现重复的helper
  4. 在入口文件最上方或者webpack entry引入react-app-polyfill,启用tree shaking

方案一:全量引入@babel/polyfill,启用tree shaking

入口文件添加@babel/polyfill

// index.tsx
import '@babel/polyfill';
// coding...

根目录配置babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "9"
        }
      },
      "useBuiltIns": "entry",
      "corejs": "3" // 指定core-js版本,core-js提供各种垫片
    ],
    "@babel/preset-react",
    "@babel/preset-typescript"
  ],
  "plugins": []
}

如果在执行构建时报如下警告,表示在使用useBuiltIns选项时没有指定core-js版本

image-20220801160226642.png

webpack.config.js配置

/* eslint-disable @typescript-eslint/no-var-requires */
const package = require('./package.json');
const path = require('path');

module.exports = {
  mode: 'production',
  entry: [
    './index.tsx',
  ],
  output: {
    path: __dirname + '/dist',
    filename: `media-preview.v${package.version}.min.js`,
    library: {
      type: 'umd',
    },
  },
  module: {
    rules: [
      {
        test: /\.(m?js|ts|js|tsx|jsx)$/,
        exclude: /(node_modules|lib|dist)/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              cacheDirectory: true,
            },
          },
        ],
      },
      {
        test: /\.(scss|css|less)/,
        use: [
          'style-loader',
          'css-loader',
          'sass-loader',
        ],
      },
      {
        test: /\.(png|jpg|jepg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8 * 1024 * 1024, // 大小超过8M就不使用base64编码了
              name: 'static/media/[name].[hash:8].[ext]',
              fallback: require.resolve('file-loader'),
            },
          },
        ],
      },
      {
        test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8 * 1024 * 1024,
              name: 'static/fonts/[name].[hash:8].[ext]',
              fallback: require.resolve('file-loader'),
            },
          },
        ],
      },
    ],
  },
  plugins: [],
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json'],
  },
};

构建生成的产物含有一堆图片和字体文件,并且都重复了双份,其实期望的结果是这些资源都被base64编码在代码中,但没有生效。

image-20220801164038928.png

原因是当在 webpack 5 中使用旧的 assets loader(如 file-loader/url-loader/raw-loader 等)和 asset 模块时,你可能想停止当前 asset 模块的处理,并再次启动处理,这可能会导致 asset 重复,你可以通过将 asset 模块的类型设置为 'javascript/auto' 来解决。

module.exports = {
  module: {
   rules: [
      {
        test: /\.(png|jpg|jepg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8 * 1024 * 1024, // 大小超过8M就不使用base64编码了
              name: 'static/media/[name].[hash:8].[ext]',
              fallback: require.resolve('file-loader'),
            },
          },
        ],
        type: 'javascript/auto',
      },
      {
        test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8 * 1024 * 1024,
              name: 'static/fonts/[name].[hash:8].[ext]',
              fallback: require.resolve('file-loader'),
            },
          },
        ],
        type: 'javascript/auto',
      },
   ]
  },
}

传送门:资源模块(asset module)

再次构建,生成的产物在IE浏览器中应用会报语法错误,代码中有使用箭头函数语法。不是说babel会将高级语法转译成ES5语法吗?为什么还会出现语法错误呢?

image-20220801160930532.png

这是因为webpack注入的运行时代码默认是按web平台构建编译的,但是编译的语法版本不是ES5,因此需要告知 webpack 为目标(target)指定一个环境

module.exports = {
  // ...
  target: ['web', 'es5'], // Webpack 将生成 web 平台的运行时代码,并且只使用 ES5 相关的特性
};

传送门:构建目标(Targets)

再次构建,IE浏览器运行,出现另外问题,IE浏览器不支持new URL构造函数,为什么呢?@babel/polyfill不是会处理具有兼容性问题的api吗?

image-20220801170525791.png

image-20220801162352598.png

原因在于@babel/polyfillcore-js部分并没有提供URL构造函数的垫片,自行安装URL垫片库url-polyfill,在入口文件或者webpack entry引入它,再次构建

module.exports = {
  // ...
  entry: ['url-polyfill', './index.tsx'],
};

IE10IE11运行正常,但是在IE9会报错,原因是url-polyfill使用了IE9不支持的“checkValidity”属性或方法

image-20220801172926750.png

image-20220801174022881.png

image-20220801172420047.png

element-internals-polyfill实现了ElementInternals,为 Web 开发人员提供了一种允许自定义元素完全参与 HTML表单的方法。

image-20220801180628306.png

但是,该垫片中另外使用new WeakMapWeakMapIE中也存在兼容性问题,一个个去补充缺失的垫片方法简直跟套娃似的,还不如换其他方案

image-20220801180751116.png

image-20220801181203756.png

方案二:按需引入@babel/polyfill

不用在入口文件最上方或者webpack entry引入@babel/polyfill,只需要设置"useBuiltIns": "usage",并安装@babel/polyfill即可

babel.config.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "9"
        }
      },
      "useBuiltIns": "usage"
    ],
    "@babel/preset-react",
    "@babel/preset-typescript"
  ],
  "plugins": []
}

方案二和方案一都是使用@babel/polyfill,构建产物在IE执行依旧会报一样的错误,URL构造函数不支持

方案三:@babel/plugin-transform-runtime

安装yarn add @babel/plugin-transform-runtime @babel/runtime-corejs3 -D,由 @babel/runtime-corejs3 提供垫片弥补兼容性问题

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "chrome": "58",
          "ie": "9"
        },
      }
    ],
    "@babel/preset-react",
    "@babel/preset-typescript"
  ],
  "plugins": [
    [
    "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": true,
        "corejs": 3, // 指定corejs版本,安装@babel/runtime-corejs3就指定3版本
        "helpers": true,
        "regenerator": true,
        "version": "7.0.0-beta.0"
      }
    ]
  ]
}

构建产物在IE运行同样会报上述方案的错误,原因是安装的@babel/runtime-corejs3没有提供URL构造函数的垫片

image-20220802005547838.png

方案四:入口文件引入react-app-polyfill,启用tree shaking

安装

yarn add react-app-polyfill

在入口文件最上方或者webpack entry引入

// 入口文件引入
import 'react-app-polyfill/ie9';
import 'react-app-polyfill/stable';

// webpack entry
entry: [‘react-app-polyfill/ie9’, 'react-app-polyfill/stable', './index.tsx'],

设置mode: 'production'就会默认启用tree shaking

执行构建,产物在IE9+都可以运行成功,说明react-app-polyfill很好的提供了new URLcheckValidity等垫片,查阅源代码也可验证

image-20220802011148142.png

image-20220802011028485.png

三、css tree shaking

业务组件中使用了基础组件库,比如import { Modal, Carousel, Icon } from '@casstime/bricks';,虽然这些基础组件都有对应的样式文件(比如Modal组件有自己的对应的_modal.scss),但这些样式文件可能依赖样式变量_variables.scss,混合_mixins.scss等,需要捋清样式模块依赖关系,一个个导入,非常不方便。于是在入口文件全局引入整个组件库样式import '@casstime/bricks/dist/bricks.development.css';,但会引入很多未使用的样式,被打包到最终产物中,致使产物体积增大,需要对样式做清洁处理css tree shaking

接下来就该 PurgeCSS 上场了。PurgeCSS 是一个用来删除未使用的 CSS 代码的工具。当你构建一个网站时,你可能会决定使用一个 CSS 框架,例如 TailwindCSS、Bootstrap、MaterializeCSS、Foundation 等,但是,你所用到的也只是框架的一小部分而已,大量 CSS 样式并未被使用。PurgeCSS 通过分析你的内容和 CSS 文件,首先它将 CSS 文件中使用的选择器与内容文件中的选择器进行匹配,然后它会从 CSS 中删除未使用的选择器,从而生成更小的 CSS 文件。

对应webpack插件purgecss-webpack-plugin,该插件的使用依赖样式抽离插件mini-css-extract-plugin,只有先将样式抽离成独立文件后才能将 CSS 文件中使用的样式选择器与内容文件中的样式选择器进行匹配,删除 CSS 中未使用的选择器,从而生成更小的 CSS 文件。

purgecss-webpack-plugin的使用需要指定paths属性,告诉purgecss需要分析的文件列表,这些文件中使用的选择器与抽离的样式文件中的选择器进行匹配,从而剔除未使用的选择器。

安装:

yarn add purgecss-webpack-plugin mini-css-extract-plugin glob-all -D

webpack.config.js

/* eslint-disable @typescript-eslint/no-var-requires */
const package = require('./package.json');
const path = require('path');
const PurgeCSSPlugin = require('purgecss-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const glob = require('glob-all');

const PATHS = {
  src: path.join(__dirname, 'src'),
};

function collectSafelist() {
  return {
    standard: ['icon', /^icon-/],
    deep: [/^icon-/],
    greedy: [/^icon-/],
  };
}

module.exports = {
  target: ['web', 'es5'],
  mode: 'production',
  // 'element-internals-polyfill', 'url-polyfill',
  entry: ['./index.tsx'],
  output: {
    path: __dirname + '/dist',
    filename: `media-preview.v${package.version}.min.js`,
    library: {
      type: 'umd',
    },
  },
  module: {
    rules: [
      {
        test: /\.(m?js|ts|js|tsx|jsx)$/,
        exclude: /(node_modules|lib|dist)/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              cacheDirectory: true,
            },
          },
        ],
      },
      {
        test: /\.(scss|css|less)/,
        use: [
          'style-loader',
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              // url: false
              // modules: {
              //   localIdentName: '[name]_[local]_[hash:base64:5]'
              // },
              // 1、【name】:指代的是模块名
              // 2、【local】:指代的是原本的选择器标识符
              // 3、【hash:base64:5】:指代的是一个5位的hash值,这个hash值是根据模块名和标识符计算的,因此不同模块中相同的标识符也不会造成样式冲突。
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                // parser: 'postcss-js',
                // execute: true,
                plugins: [['postcss-preset-env']], // 跟Autoprefixer类型,为样式添加前缀
              },
            },
          },
          'sass-loader',
        ],
      },
      {
        test: /\.(png|jpg|jepg|gif)$/i,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8 * 1024 * 1024, // 大小超过8M就不使用base64编码了
              name: 'static/media/[name].[hash:8].[ext]',
              fallback: require.resolve('file-loader'),
            },
          },
        ],
        type: 'javascript/auto',
      },
      {
        test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8 * 1024 * 1024, // 为了不将font抽离,目标产物只有js和css
              name: 'static/fonts/[name].[hash:8].[ext]',
              fallback: require.resolve('file-loader'),
            },
          },
        ],
        type: 'javascript/auto',
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: `media-preview.v${package.version}.min.css`,
    }),
    /**
     * PurgeCSSPlugin用于清除⽆⽤ css,必须和MiniCssExtractPlugin搭配使用,不然不会生效。
     * paths属性用于指定哪些文件中使用样式应该保留,没有在这些文件中使用的样式会被剔除
     */
    new PurgeCSSPlugin({
      paths: glob.sync(
        [
          `${PATHS.src}/**/*`,
          path.resolve(__dirname, '../../node_modules/@casstime/bricks/lib/components/carousel/*.js'),
          path.resolve(__dirname, '../../node_modules/@casstime/bricks/lib/components/modal/*.js'),
          path.resolve(__dirname, '../../node_modules/@casstime/bricks/lib/components/icon/*.js'),
        ],
        { nodir: true },
      ),
      safelist: collectSafelist, // 安全列表,指定不剔除的样式
    }),
  ],
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json'],
  },
};

由于Icon组件使用的图标是通过type属性指定的,比如<icon type="close"/>,表示应用icon-close的样式,虽然PurgeCSSPlugin配置指定icon.js文件中使用样式应该保留,但因为icon-${type}是动态的,PurgeCSSPlugin并不知道icon-close被使用了,会被剔除掉,因此需要配置safelist,手动指定不被剔除的样式,防止无意被删除。

image-20220802014556715.png

最终产物由1.29M降低到752KB,其实构建后产物中还有比较多冗余重复的代码,如果使用公共模块抽取还会进一步减小产物体积大小,但是会拆分成好多个文件,不方便在Spring MVC项目的引入使用,期望最终构建产物由一个js或者一个js和一个css组成最佳

image-20220802014028065.png

四、处理样式兼容性

1、scss中使用具有兼容性样式

在书写scss样式文件时,常常会用到一些具有兼容性问题的样式属性,比如transform、transform-origin,在IE内核浏览器中需要添加ms-前缀,谷歌内核浏览器需要添加webkit-前缀,因此构建时需要相应的loader或者plugin处理,这里我们采用postcss来处理

安装

yarn add postcss postcss-preset-env -D

loader配置

module.exports = {
    module: [
        // ...
        {
        test: /\.(scss|css|less)/,
        use: [
          'style-loader',
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              // url: false
              // modules: {
              //   localIdentName: '[name]_[local]_[hash:base64:5]'
              // },
              // 1、【name】:指代的是模块名
              // 2、【local】:指代的是原本的选择器标识符
              // 3、【hash:base64:5】:指代的是一个5位的hash值,这个hash值是根据模块名和标识符计算的,因此不同模块中相同的标识符也不会造成样式冲突。
            },
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                // parser: 'postcss-js',
                // execute: true,
                plugins: [['postcss-preset-env']], // 跟Autoprefixer类型,为样式添加前缀
              },
            },
          },
          'sass-loader',
        ],
      },
    ]
}

2、处理tsx脚本中动态注入兼容性问题的样式

在某些场景下,可能会用脚本来控制UI交互,比如控制拖拽平移element.style.transform = 'matrix(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)';,对于这类具有兼容性问题的动态样式也是需要处理的。可以考虑以下几种方案:

  • 自行实现loader或者plugin转化脚本的样式,或者寻找对应的第三方库;
  • 平时编写的动态样式就处理好其兼容性;

由于我们的业务组件相对简单,直接在编写时做好了兼容性处理

element.style.transform = 'matrix(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)';
element.style.msTransform = 'matrix(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)';
element.style.oTransform = 'matrix(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)';
element.style.webkitTransform = 'matrix(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)';

五、附录

常见polyfill清单

No.NamePackageSource MapNetwork
1ECMAScript6es6-shim🇺🇳 🇨🇳
2Proxyes6-proxy-polyfill 🇺🇳 🇨🇳
3ECMAScript7es7-shim🇺🇳 🇨🇳
4ECMAScriptcore-js-bundle🇺🇳 🇨🇳
5Regeneratorregenerator-runtime🇺🇳 🇨🇳
6GetCanonicalLocales@formatjs/intl-getcanonicallocales 🇺🇳 🇨🇳
7Locale@formatjs/intl-locale 🇺🇳 🇨🇳
8PluralRules@formatjs/intl-pluralrules 🇺🇳 🇨🇳
9DisplayNames@formatjs/intl-displaynames 🇺🇳 🇨🇳
10ListFormat@formatjs/intl-listformat 🇺🇳 🇨🇳
11NumberFormat@formatjs/intl-numberformat 🇺🇳 🇨🇳
12DateTimeFormat@formatjs/intl-datetimeformat 🇺🇳 🇨🇳
13RelativeTimeFormat@formatjs/intl-relativetimeformat 🇺🇳 🇨🇳
14ResizeObserverresize-observer-polyfill🇺🇳 🇨🇳
15IntersectionObserverintersection-observer 🇺🇳 🇨🇳
16ScrollBehaviorscroll-behavior-polyfill🇺🇳 🇨🇳
17WebAnimationweb-animations-js🇺🇳 🇨🇳
18EventSubmitterevent-submitter-polyfill 🇺🇳 🇨🇳
19Dialogdialog-polyfill 🇺🇳 🇨🇳
20WebComponents@webcomponents/webcomponentsjs🇺🇳 🇨🇳
21ElementInternalselement-internals-polyfill 🇺🇳 🇨🇳
22AdoptedStyleSheetsconstruct-style-sheets-polyfill🇺🇳 🇨🇳
23PointerEvents@wessberg/pointer-events🇺🇳 🇨🇳
24TextEncoderfastestsmallesttextencoderdecoder-encodeinto🇺🇳 🇨🇳
25URLurl-polyfill 🇺🇳 🇨🇳
26URLPatternurlpattern-polyfill 🇺🇳 🇨🇳
27Fetchwhatwg-fetch🇺🇳 🇨🇳
28EventTargetevent-target-polyfill🇺🇳 🇨🇳
29AbortControlleryet-another-abortcontroller-polyfill🇺🇳 🇨🇳
30Clipboardclipboard-polyfill🇺🇳 🇨🇳
31PWAManifestpwacompat 🇺🇳 🇨🇳
32Shareshare-api-polyfill 🇺🇳 🇨🇳

记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。