背景
公司前端技术栈还处于React+Mobx
与Spring 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
在转译的时候,会将源代码分成syntax
和api
两部分来处理
syntax
:类似于展开对象、optional chain
、let
、const
等语法;api
:类似于[1,2,3].includes
、new URL()
,new URLSearchParams()
、new Map()
等函数、方法;
babel
很轻松就转译好syntax
,但对于api
并不会做任何处理,如果在不支持这些api
的浏览器中运行,就会报错,因此需要使用polyfill
来处理api
,处理兼容性api
有以下方案:
@babel/preset-env
中有一个配置选项useBuiltIns
,用来告诉babel
如何处理api
。由于这个选项默认值为false
,即不处理api
- 设置
useBuiltIns
为“entry
”,在入口文件最上方引入@babel/polyfill
;或者不设置useBuiltIns
和设置useBuiltIns
为false
,在webpack entry
添加@babel/polyfill
。这种配置下,babel
会将所有的polyfill
全部引入,构建产物体积会很大,需要启用tree shaking
清除没有使用的代码; - 启用按需加载,将
useBuiltIns
改成“usage
”,babel
就可以按需加载polyfill
,并且不需要手动引入@babel/polyfill
,但依然需要安装它; - 上述两种方法存在两个问题,①
polyfill
注入的方法会改变全局变量的原型(篡改原型链),可能带来意料之外的问题。② 转译syntax
时,会注入一些辅助函数来帮忙转译,这些helper
函数会在每个需要转译的文件中定义一份,导致最终的产物含有大量重复的helper
。因此,引入@babel/plugin-transform-runtime
将helper
和api
都改为从一个统一的地方引入,并且引入的对象和全局变量是完全隔离的,既不会篡改原型链,亦不会出现重复的helper
; - 在入口文件最上方或者
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
版本
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
编码在代码中,但没有生效。
原因是当在 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',
},
]
},
}
再次构建,生成的产物在IE
浏览器中应用会报语法错误,代码中有使用箭头函数语法。不是说babel
会将高级语法转译成ES5
语法吗?为什么还会出现语法错误呢?
这是因为webpack
注入的运行时代码默认是按web
平台构建编译的,但是编译的语法版本不是ES5
,因此需要告知 webpack
为目标(target
)指定一个环境
module.exports = {
// ...
target: ['web', 'es5'], // Webpack 将生成 web 平台的运行时代码,并且只使用 ES5 相关的特性
};
传送门:构建目标(Targets)
再次构建,IE
浏览器运行,出现另外问题,IE
浏览器不支持new URL
构造函数,为什么呢?@babel/polyfill
不是会处理具有兼容性问题的api
吗?
原因在于@babel/polyfill
中core-js
部分并没有提供URL
构造函数的垫片,自行安装URL
垫片库url-polyfill
,在入口文件或者webpack entry
引入它,再次构建
module.exports = {
// ...
entry: ['url-polyfill', './index.tsx'],
};
在IE10
和IE11
运行正常,但是在IE9
会报错,原因是url-polyfill
使用了IE9
不支持的“checkValidity
”属性或方法
element-internals-polyfill
实现了ElementInternals
,为 Web
开发人员提供了一种允许自定义元素完全参与 HTML
表单的方法。
但是,该垫片中另外使用new WeakMap
,WeakMap
在IE
中也存在兼容性问题,一个个去补充缺失的垫片方法简直跟套娃似的,还不如换其他方案
方案二:按需引入@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
构造函数的垫片
方案四:入口文件引入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 URL
、checkValidity
等垫片,查阅源代码也可验证
三、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
,手动指定不被剔除的样式,防止无意被删除。
最终产物由1.29M
降低到752KB
,其实构建后产物中还有比较多冗余重复的代码,如果使用公共模块抽取还会进一步减小产物体积大小,但是会拆分成好多个文件,不方便在Spring MVC
项目的引入使用,期望最终构建产物由一个js
或者一个js
和一个css
组成最佳
四、处理样式兼容性
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
清单
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。