组件库实现按需加载全攻略

lxf2023-03-13 07:51:02

陪伴前端开发日常的一定是组件了吧,能提高开发效率,降低代码重复度等,在我司后端,ui,产品都经常说这不就是一个组件么拿来用就好了,但是堆砌的组件成了我们中后台打开性能的最大阻碍,gzip前体积高达4mb的js + 700kb的css,究其根本就是我们内部的组件库不支持按需导入,在入口就加载了这个巨大无比的组件库,但是在使用一些社区的组件库时并没有这些阻碍,我们先来看看他们是如何实现的

我司内部基于vue@2.7+antd@1.7.8开发,主要参考antd element的实现方案

element

在package.json可以看到,element提供了dist指令完成发布前的一些操作,我们拆解看一下

npm run clean // 清空发布目录
npm run build:file(
	node build/bin/iconInit.js & // 大概是处理图标
	node build/bin/build-entry.js & // 在根据根目录的components.json文件生成umd的入口文件, 包括umd入口注册组件的一些模版代码
	node build/bin/i18n.js & // 多语言相关的逻辑
	node build/bin/version.js)
npm run lint 
webpack --config build/webpack.conf.js // 重点关注 !!!
webpack --config build/webpack.common.js // 生成commonjs版本
webpack --config build/webpack.component.js // 重点关注 !!!
npm run build:utils // 使用babel降级src/目录中的公共文件
npm run build:umd // 使用babel降级多语言的js源文件
npm run build:theme // 根据默认的主题处理sass文件

看到这里element内部组件的打包使用webpack完成,接下来我们详细看一下如何生成es,umd的组件库代码

上面多个脚本都依赖到了根目录的components.json, 我们看一下这个文件的缩略版

{
  "pagination": "./packages/pagination/index.js",
  "dialog": "./packages/dialog/index.js",
}

看到这里我们也能大概猜到是如何生成es版本的组件库了吧,在webpack中的entry不就是提供一个入口文件么,哪对于umd格式,刚才有个脚本生成了所有compents的模版文件(node build/bin/build-entry.js),我们继续看一下element的实现方式

webpack --config build/webpack.conf.js

指定了entry(上文中提到了根据components.json渲染的模版文件,包括导入组件,注册组件) eg

// 精简版
import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
const components = [
  Pagination,
  Dialog,
]

const install = function(Vue, opts = {}) {
  components.forEach(component => {
    Vue.component(component.name, component);
  });
};

export default {
  version: '2.15.12',
  install,
  Pagination,
  Dialog,
}

所以总结一下umd的生成

组件库实现按需加载全攻略

最后附上完整的webpack配置

const path = require('path');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const TerserPlugin = require('terser-webpack-plugin');

const config = require('./config');

module.exports = {
  mode: 'production',
  entry: {
    app: ['./src/index.js']
  },
  output: {
    path: path.resolve(process.cwd(), './lib'),
    publicPath: '/dist/',
    filename: 'index.js',
    chunkFilename: '[id].js',
    libraryTarget: 'umd',
    libraryExport: 'default',
    library: 'ELEMENT',
    umdNamedDefine: true,
    globalObject: 'typeof self !== 'undefined' ? self : this'
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: config.alias
  },
  externals: {
    vue: config.vue
  },
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          output: {
            comments: false
          }
        }
      })
    ]
  },
  performance: {
    hints: false
  },
  stats: {
    children: false
  },
  module: {
    rules: [
      {
        test: /.(jsx?|babel|es6)$/,
        include: process.cwd(),
        exclude: config.jsexclude,
        loader: 'babel-loader'
      },
      {
        test: /.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      }
    ]
  },
  plugins: [
    new ProgressBarPlugin(),
    new VueLoaderPlugin()
  ]
};

我们继续看一下es版本的生成过程

有了每个组件的入口,我们是不是就能一一对应的生成出构建结果,但是事情还没那么简单,例如el-pagination导入了el-button,我们在项目中使用到了el-pagination,el-button那么构建的结果是不是就包含了两份el-button的代码,可能有同学说有treeshark,但是这个场景下很明显不可以,大家可以想一下为什么

组件库实现按需加载全攻略

如果这样做,哪我们的按需导入不就没有意义了么,继续看看element是如何解决的,答案就是webpack 的externals。只要将内部的组件,代码引用全部externals掉不就解决重复导入了么

我们看一下具体的webpack配置

const path = require('path');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

const Components = require('../components.json');
const config = require('./config');

const webpackConfig = {
  mode: 'production',
  entry: Components,
  output: {
    path: path.resolve(process.cwd(), './lib'),
    publicPath: '/dist/',
    filename: '[name].js',
    chunkFilename: '[id].js',
    libraryTarget: 'commonjs2'
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: config.alias,
    modules: ['node_modules']
  },
  externals: config.externals,
  performance: {
    hints: false
  },
  stats: 'none',
  optimization: {
    minimize: false
  },
  module: {
    rules: [
      {
        test: /.(jsx?|babel|es6)$/,
        include: process.cwd(),
        exclude: config.jsexclude,
        loader: 'babel-loader'
      },
      {
        test: /.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      },
      {
        test: /.css$/,
        loaders: ['style-loader', 'css-loader']
      },
      {
        test: /.(svg|otf|ttf|woff2?|eot|gif|png|jpe?g)(?\S*)?$/,
        loader: 'url-loader',
        query: {
          limit: 10000,
          name: path.posix.join('static', '[name].[hash:7].[ext]')
        }
      }
    ]
  },
  plugins: [
    new ProgressBarPlugin(),
    new VueLoaderPlugin()
  ]
};

module.exports = webpackConfig;

除了基础的配置外,我们看一下externals是如何做的

组件库实现按需加载全攻略

如我们想的一样,确实是枚举了element内部的组件+公共文件。

最后看一下es版本的整体流程

组件库实现按需加载全攻略

这个时候js的构建逻辑基本就完成了,css的构建在element中使用了gulp, 换个目录直出即可,就不展开说了

ant-design

在来看一下大名鼎鼎的ant-design,老规矩还是先看package.json, antd的发布脚本使用antd-tools compile,这里antd-tools应该是一个内部的库,我们转移视线来到antd-tools的仓库,看看他是如何提供构建的。

antd-tools run dist
antd-tools run compile // 主要编译的逻辑
antd-tools run clean
antd-tools run pub
antd-tools run guard
antd-tools run deps-lint
antd-tools run sort-api-table
antd-tools run api-collection

有了上面element的经验, 我们大概知道要实现按需,必须拆分打包结果,做到一个结果对应一个组件,在element中他手写json配置的方式给出了每个组件的路径,在antd中并没有找到类似的配置,哪他是咋样实现的呢,答案是目录结构,约定一种文件结构使用glob来读取所有关联的文件。了解了基本的思路我们在看看antd是如何做es版本的输出的。

看一下核心compile task,antd内部用了gulp来承载这些操作, 有了目录结构,我们是不是就可以只做transform不做bundle了, 看一下antd es的处理逻辑,他主要做了文件的transform,包括js降级,类型文件生成

compile逻辑

  1. 拿到目标文件夹下所有的文件
  2. 不同类型匹配对应的翻译器eg js使用babel ts使用tsc
  3. 处理完毕按照原文件路径输出到指定目录

这不就有了es的版本么,还顺便知道了为什么使用了gulp来做

我们思考一下,我们一个组件库发布到npm, 要做到开箱即用,必须要做哪些事情,拍脑袋一想可能有

  1. 提供不同格式 比如 es umd

  2. 尽可能降低对编译速度的影响,比如将一些原本不支持的文件做编译 .vue ==> js ts ==> js

  3. type

对比上面的两种方式,在antd 肯定更加适合在2022年使用, 毕竟element的构建脚本已经停留在了5年前,哈哈