React元件應用於Spring MVC工程

記得要微笑發表於2022-11-23

背景

公司前端技術棧還處於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 ?? ??

相關文章