最近闲暇无事,研究了下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-component
bable插件,传入组件库名称和样式库名称:
{
"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节点也有很多,但是关键的节点其实是 ImportDeclaration
、CallExpression
、 MemberExpression
、 AssignmentExpression
。
ImportDeclaration
import声明表达式,对引入的包进行判断将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的完整修改。即按需加载指定的组件或者方法。
网友评论