美文网首页
读Element-UI之组件库设计按需加载详细流程

读Element-UI之组件库设计按需加载详细流程

作者: 叶小七的真命天子 | 来源:发表于2020-06-28 12:14 被阅读0次

最近闲暇无事,研究了下element-ui按需加载的实现,特与此记录一下。

首先说下概念:

1、按需加载指的是只引入需要用到的组件和组件的css,以减小打包体检,优化项目加载速度。

2、按需加载的反面是全部引入,即将组件库中所有组件和css都引入,打包后的体积明显很大。

按需加载设计步骤

1、组件和组件样式文件单独导出
2、打包时除了打包full组件的的单文件,还需单独打包各个组件,以便按需加载引入
3、编写按需加载babel插件
4、引入按需加载插件

1、组件和组件样式文件单独导出

以Dialog组件为例:首先组件文件目录是如下结构:

packages/dialog
├── index.js //入口文件,对外暴露组件
└── src
    ├── component.vue //组件的源文件

组件入口文件index.js必须export default Component ,由于是Vue的组件库,故还必须提供一个install方法。

import ElDialog from './src/component';

ElDialog.install = function(Vue) {
  Vue.component(ElDialog.name, ElDialog);
};

export default ElDialog;

样式文件单独编码,并且文件名与组件文件名一致,以便后期按需加载使用。

packages/theme-chalk
└── src
    ├── dialog.scss //dialog组件的样式文件
    ├── base.scss //base类的样式文件
    ├── index.scss //样式主题入口文件,其导入了dialog.scss和其它的组件样式文件
2、打包策略

必须分2个打包逻辑,一个是生成含有所有组件的包的逻辑,另一个是配置多入口,对每个组件进行单独打包。element前者打包分了2个模块,一个是commonjs2,一个是umd模式。在此只讲下日常工程化项目中使用的commonjs2模式。

webpack.common.js 将所有组件打进一个bundle

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

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

module.exports = {
  mode: 'production',
  entry: {
    app: ['./src/index.js']
  },
  output: {
    path: path.resolve(process.cwd(), './lib'),
    publicPath: '/dist/',
    filename: 'element-ui.common.js',
    chunkFilename: '[id].js',
    libraryExport: 'default',
    library: 'ELEMENT',
    libraryTarget: 'commonjs2'
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: config.alias,
    modules: ['node_modules']
  },
  externals: config.externals,
 // 省略.....
  plugins: [
    new ProgressBarPlugin(),
    new VueLoaderPlugin()
  ]
};

1、入口文件为./src/index.js,以此webpack进行依赖收集获取所有组件包进行打包。

// src/index.js
/* Automatically generated by './build/bin/build-entry.js' */

import Pagination from '../packages/pagination/index.js';
import Dialog from '../packages/dialog/index.js';
import Autocomplete from '../packages/autocomplete/index.js';
import Dropdown from '../packages/dropdown/index.js';
import DropdownMenu from '../packages/dropdown-menu/index.js';
//....省略

const components = [
  Pagination,
  Dialog,
  Autocomplete,
  Dropdown,
  DropdownMenu,
  DropdownItem,
]
 //.........
};
export default {
  version: '2.13.1',
  locale: locale.use,
  i18n: locale.i18n,
  install,
  CollapseTransition,
  Loading,
  Pagination,
  Dialog,
  Autocomplete,
  Dropdown,
  DropdownMenu,
  DropdownItem,
  ...
};

入口文件包含所有对组件库的引用,以达到将所有组件打包的目的。最终在lib目录下生成
element-ui.common.js文件。由于package.json中定义的main字段值为element-ui.common.js
故在全局引入:import ElementUI from 'element-ui'时,实际引入的就是刚才打包的包含全部组件的bundle文件element-ui.common.js

2、其次说改配置下的external字段(具体使用方法查看webpack官网),此配置的key将组件库中的引用的库从bundle中剥离不进行打包,value值为不打包之后webpack引用此库暴露给在window上的库名,如element-ui的UMD包的库名为ELEMENT,vue的为Vue
其配的是有将引入的以/^element-ui\/开头的引入的文件,不进行打包,文件引用实际是引用的element-ui/lib下的生产版本的文件。
其中特别关注第一行代码,将组件的引用改为lib下独立的组件文件,此就是接下来要写的webpack配多入口文件将所有组件单独打包,生成的文件。

Object.keys(Components).forEach(function(key) {
  externals[`element-ui/packages/${key}`] = `element-ui/lib/${key}`;
});

externals['element-ui/src/locale'] = 'element-ui/lib/locale';
utilsList.forEach(function(file) {
  file = path.basename(file, '.js');
  externals[`element-ui/src/utils/${file}`] = `element-ui/lib/utils/${file}`;
});
mixinsList.forEach(function(file) {
  file = path.basename(file, '.js');
  externals[`element-ui/src/mixins/${file}`] = `element-ui/lib/mixins/${file}`;
});
transitionList.forEach(function(file) {
  file = path.basename(file, '.js');
  externals[`element-ui/src/transitions/${file}`] = `element-ui/lib/transitions/${file}`;
});

externals = [Object.assign({
  vue: 'vue'
}, externals), nodeExternals()];

exports.externals = externals;

因为element-ui设计时在组件中对其他组件的引入是element-ui/packages/xxx的故此才做external处理,防止二次打包和按需加载时的文件引用逻辑处理,例如:select.vue

<template>
  <div
    class="el-select"
    :class="[selectSize ? 'el-select--' + selectSize : '']"
      ....
  </div>
</template>

<script type="text/babel">
  import Emitter from 'element-ui/src/mixins/emitter';
  import Focus from 'element-ui/src/mixins/focus';
  import Locale from 'element-ui/src/mixins/locale';
  import ElInput from 'element-ui/packages/input';
  import ElSelectMenu from './select-dropdown.vue';
  import ElOption from './option.vue';
  import ElTag from 'element-ui/packages/tag';
  import ElScrollbar from 'element-ui/packages/scrollbar';
  import debounce from 'throttle-debounce/debounce';
  import Clickoutside from 'element-ui/src/utils/clickoutside';
  import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event';
  import { t } from 'element-ui/src/locale';
  import scrollIntoView from 'element-ui/src/utils/scroll-into-view';
  import { getValueByPath, valueEquals, isIE, isEdge } from 'element-ui/src/utils/util';
  import NavigationMixin from './navigation-mixin';
  import { isKorean } from 'element-ui/src/utils/shared';

  export default {
    .......
  }
</script>

webpack.component.js 多入口、所有组件单独打包

const webpackConfig = {
  mode: 'production',
  entry: Components,
  output: {
    path: path.resolve(process.cwd(), './lib'),
    publicPath: '/dist/',
    filename: '[name].js',
    chunkFilename: '[id].js',
    libraryTarget: 'commonjs2'
  },
   externals: config.externals,  //此逻辑和之前一直
    省略.........
}

其中entry配置的为一个对象。key为组件名,value为文件路径,以此为webpack多入口文件打包机制,配置的filename: '[name].js',生成的文件名命名跟组件名一致,最终在lib下生成所有组件的打包文件。

{
  "pagination": "./packages/pagination/index.js",
  "dialog": "./packages/dialog/index.js",
  "autocomplete": "./packages/autocomplete/index.js",
  "dropdown": "./packages/dropdown/index.js",
  "dropdown-menu": "./packages/dropdown-menu/index.js",
  "dropdown-item": "./packages/dropdown-item/index.js",
  "menu": "./packages/menu/index.js",
  "submenu": "./packages/submenu/index.js",
   // ......省略
}

externals逻辑与上面的commonjs2一致。。
生成的文件如下:

lib
├── alert.js
└── aside.js
└── autocomplete.js   
  ....

故此按需加载打包逻辑处理完成,可以对各个组件的进行单文件引用,可以按需引入的bable逻辑处理

按需引入方式:

element-ui官网按需加载的使用方式是引入babel-plugin-componentbable插件,传入组件库名称和样式库名称:

{
  "presets": [["es2015", { "modules": false }]],
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

然后在注册使用的组件:

import Vue from 'vue';
import { Button, Select } from 'element-ui';

通俗易懂的方式解释下bable插件对import { Button, Select } from 'element-ui';的处理:即是将此代码转成

import Button from element-ui/lib/button.js
import  element-ui/lib/theme-chalk/button.css

以达到按需引入的效果(实际bable处理时直接删除当前代码,然后在对AST抽象语法树遍历时,将对引用的方法、组件进行引用处理)。

babel-plugin-component中使用的AST节点信息我已在上一篇文章中记录过。

按需加载bable插件编码: babel-plugin-component.js

const { addSideEffect, addDefault } = require('@babel/helper-module-imports')
const resolve = require('path').resolve
const isExist = require('fs').existsSync
const cache = {}
const cachePath = {}
const importAll = {}

function core (defaultLibraryName) {
  return ({ types }) => {
    let specified
    let libraryObjs
    let selectedMethods
    let moduleArr

    function parseName (_str, camel2Dash) {
      if (!camel2Dash) {
        return _str
      }
      const str = _str[0].toLowerCase() + _str.substr(1)
      return str.replace(/([A-Z])/g, ($1) => `-${$1.toLowerCase()}`)
    }

    function importMethod (methodName, file, opts) {
      console.log('methodName', methodName)

      if (!selectedMethods[methodName]) {
        let options
        let path

        if (Array.isArray(opts)) {
          options = opts.find(option =>
            moduleArr[methodName] === option.libraryName ||
            libraryObjs[methodName] === option.libraryName
          ); // eslint-disable-line
        }
        options = options || opts

        const {
          libDir = 'lib',
          libraryName = defaultLibraryName,
          style = true,
          styleLibrary,
          root = '',
          camel2Dash = true
        } = options
        let styleLibraryName = options.styleLibraryName
        let _root = root
        let isBaseStyle = true
        let modulePathTpl
        let styleRoot
        let mixin = false
        const ext = options.ext || '.css'

        if (root) {
          _root = `/${root}`
        }

        if (libraryObjs[methodName]) {
          path = `${libraryName}/${libDir}${_root}`
          if (!_root) {
            importAll[path] = true
          }
        } else {
          path = `${libraryName}/${libDir}/${parseName(methodName, camel2Dash)}`
        }
        const _path = path

        selectedMethods[methodName] = addDefault(file.path, path, { nameHint: methodName })
        if (styleLibrary && typeof styleLibrary === 'object') {
          styleLibraryName = styleLibrary.name
          isBaseStyle = styleLibrary.base
          modulePathTpl = styleLibrary.path
          mixin = styleLibrary.mixin
          styleRoot = styleLibrary.root
        }
        if (styleLibraryName) {
          if (!cachePath[libraryName]) {
            const themeName = styleLibraryName.replace(/^~/, '')
            cachePath[libraryName] = styleLibraryName.indexOf('~') === 0
              ? resolve(process.cwd(), themeName)
              : `${libraryName}/${libDir}/${themeName}`
          }

          if (libraryObjs[methodName]) {
            /* istanbul ingore next */
            if (cache[libraryName] === 2) {
              throw Error('[babel-plugin-component] If you are using both' +
                'on-demand and importing all, make sure to invoke the' +
                ' importing all first.')
            }
            if (styleRoot) {
              path = `${cachePath[libraryName]}${styleRoot}${ext}`
            } else {
              path = `${cachePath[libraryName]}${_root || '/index'}${ext}`
            }
            cache[libraryName] = 1
          } else {
            if (cache[libraryName] !== 1) {
              /* if set styleLibrary.path(format: [module]/module.css) */
              const parsedMethodName = parseName(methodName, camel2Dash)
              if (modulePathTpl) {
                const modulePath = modulePathTpl.replace(/\[module]/ig, parsedMethodName)
                path = `${cachePath[libraryName]}/${modulePath}`
              } else {
                path = `${cachePath[libraryName]}/${parsedMethodName}${ext}`
                console.log('=================style====================')
                console.log(path)
              }
              if (mixin && !isExist(path)) {
                path = style === true ? `${_path}/style${ext}` : `${_path}/${style}`
              }
              console.log('isBaseStyle', isBaseStyle)

              if (isBaseStyle) {
                addSideEffect(file.path, `${cachePath[libraryName]}/base${ext}`)
              }
              cache[libraryName] = 2
            }
          }

          addDefault(file.path, path, { nameHint: methodName })
        } else {
          if (style === true) {
            addSideEffect(file.path, `${path}/style${ext}`)
          } else if (style) {
            addSideEffect(file.path, `${path}/${style}`)
          }
        }
      }
      return selectedMethods[methodName]
    }

    function buildExpressionHandler (node, props, path, state) {
      const file = (path && path.hub && path.hub.file) || (state && state.file)
      props.forEach(prop => {
        if (!types.isIdentifier(node[prop])) return
        if (specified[node[prop].name]) {
          node[prop] = importMethod(node[prop].name, file, state.opts); // eslint-disable-line
        }
      })
    }

    function buildDeclaratorHandler (node, prop, path, state) {
      const file = (path && path.hub && path.hub.file) || (state && state.file)
      if (!types.isIdentifier(node[prop])) return
      if (specified[node[prop].name]) {
        node[prop] = importMethod(node[prop].name, file, state.opts); // eslint-disable-line
      }
    }

    return {
      visitor: {
        Program () {
          specified = Object.create(null)
          libraryObjs = Object.create(null)
          selectedMethods = Object.create(null)
          moduleArr = Object.create(null)
        },

        ImportDeclaration (path, { opts }) {
          const { node } = path
          const { value } = node.source
          let result = {}

          if (Array.isArray(opts)) {
            result = opts.find(option => option.libraryName === value) || {}
          }
          const libraryName = result.libraryName || opts.libraryName || defaultLibraryName

          if (value === libraryName) {
            console.log('ImportDeclaration', value)

            node.specifiers.forEach(spec => {
              if (types.isImportSpecifier(spec)) {
                specified[spec.local.name] = spec.imported.name
                moduleArr[spec.imported.name] = value
              } else {
                libraryObjs[spec.local.name] = value
              }
            })
            console.log('moduleArr', moduleArr)
            console.log('specified', specified)
            console.log('libraryObjs', libraryObjs)

            if (!importAll[value]) {
              console.log('remove', value)

              path.remove()
            }
          }
        },
        CallExpression (path, state) {
          const { node } = path
          const file = (path && path.hub && path.hub.file) || (state && state.file)
          const { name } = node.callee
          if (types.isIdentifier(node.callee)) {
            if (specified[name]) {
              node.callee = importMethod(specified[name], file, state.opts)
            }
          } else {
            node.arguments = node.arguments.map(arg => {
              const { name: argName } = arg
              if (specified[argName]) {
                return importMethod(specified[argName], file, state.opts)
              } else if (libraryObjs[argName]) {
                return importMethod(argName, file, state.opts)
              }
              return arg
            })
          }
        },

        MemberExpression (path, state) {
          const { node } = path
          const file = (path && path.hub && path.hub.file) || (state && state.file)

          if (libraryObjs[node.object.name] || specified[node.object.name]) {
            node.object = importMethod(node.object.name, file, state.opts)
          }
        },

        AssignmentExpression (path, { opts }) {
          if (!path.hub) {
            return
          }
          const { node } = path
          const { file } = path.hub

          if (node.operator !== '=') return
          if (libraryObjs[node.right.name] || specified[node.right.name]) {
            node.right = importMethod(node.right.name, file, opts)
            console.log(node.right)
          }
        },

        ArrayExpression (path, { opts }) {
          if (!path.hub) {
            return
          }
          const { elements } = path.node
          const { file } = path.hub

          elements.forEach((item, key) => {
            if (item && (libraryObjs[item.name] || specified[item.name])) {
              elements[key] = importMethod(item.name, file, opts)
            }
          })
        }

        Property (path, state) {
          const { node } = path
          buildDeclaratorHandler(node, 'value', path, state)
        },

        VariableDeclarator (path, state) {
          const { node } = path
          buildDeclaratorHandler(node, 'init', path, state)
        },

        LogicalExpression (path, state) {
          const { node } = path
          buildExpressionHandler(node, ['left', 'right'], path, state)
        },

        ConditionalExpression (path, state) {
          const { node } = path
          buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state)
        },

        IfStatement (path, state) {
          const { node } = path
          buildExpressionHandler(node, ['test'], path, state)
          buildExpressionHandler(node.test, ['left', 'right'], path, state)
        }
      }
    }
  }
}

代码很长,主要功能就是在bable遍历AST语法树时针对各个节点进行拦截处理。
其使用的AST节点也有很多,但是关键的节点其实是 ImportDeclarationCallExpressionMemberExpressionAssignmentExpression

ImportDeclarationimport声明表达式,对引入的包进行判断将element-ui引入的成员变量进行缓存处理,然后将此节点删除。例如:

import { Button } from 'element-ui';

CallExpression函数调用表达式,在函数或者方法调用时,对函数名或者参数名进行拦截处理,当函数名或者参数名是import时缓存的成员变量时,则调用importMethod方法替换函数的AST或者参数的AST。例如下面代码中的Button将被替换:

Vue.use(Button)

MemberExpression成员变量表达式,在对象成员变量表达式中拦截是否是缓存的element-ui的包名,如果是则调用importMethod替换AST,例如下面的service将被替换:

Vue.prototype.$loading = Loading.service

AssignmentExpression赋值表达式节点,如上面的Vue.prototype.$loading = Loading.service既有MemberExpression节点也有AssignmentExpression节点,会拦截赋值的字符,对其进行AST替换。

importMethod

生成element-ui/lib/xxx.js路径找到该模块的单独文件,然后使用addDefault方法替换AST,针对css文件则先生成element-ui/lib/theme-chalk/xxx.css,然后使用addSideEffect方法进行替换AST。这边替换的路径就是多入口文件打包生成的单个组件的路径。

最终完成对AST的完整修改。即按需加载指定的组件或者方法。

相关文章

  • 读Element-UI之组件库设计按需加载详细流程

    最近闲暇无事,研究了下element-ui按需加载的实现,特与此记录一下。 首先说下概念: 1、按需加载指的是只引...

  • VUE首屏加载优化

    (1)组件按需加载;对Element-UI, Mint-UI组件进行按需加载;(2)路由组建懒加载;异步加载con...

  • vue项目优化

    首屏加载速度变快1、 生成打包报告2、 第三方库启用CDN3、Element-UI组件按需加载4、路由懒加载5、首...

  • vue项目打包时的优化学习

    1.项目优化 1.1项目可优化的内容 生成打包报告 第三方库启用CDN Element-ui组件按需加载 路由懒加...

  • vue项目打包时的优化学习

    1.项目优化 1.1项目可优化的内容 生成打包报告 第三方库启用CDN Element-ui组件按需加载 路由懒加...

  • EasyUI之easyloader 加载

    学习使用EasyUI之easyloader 加载 easyloader 简单载入器这个组件主要作用是按需加载组件。...

  • EGG爬坑记录——element-ui样式文件加载

    在使用官方egg-vue-webpack时使用element-ui的组件库,发现虽然可以应用组件库,但是无法加载样...

  • 相关插件

    axios vue-router vuex vue-lazyload 图片懒加载 element-ui 按需加载 ...

  • VUE项目优化策略

    一. 生成打包报告 二. 第三方库启用CDN 三. Element-UI组件按需导入 四. 首页内容定制 五. 路...

  • 打包构建

    1、babel中:组件库的按需加载 2、路由的webpackChunkName,路由的懒加载和拆包 3、loada...

网友评论

      本文标题:读Element-UI之组件库设计按需加载详细流程

      本文链接:https://www.haomeiwen.com/subject/lgnrfktx.html