组件库按需加载原理分析 您所在的位置:网站首页 webpack路由按需加载 组件库按需加载原理分析

组件库按需加载原理分析

2024-06-02 10:09| 来源: 网络整理| 查看: 265

前言

在使用 vant、element-ui、ant-design 等 UI 组件库时候会用到按需加载,通过 [babel-plugin-import](https://github.com/ant-design/babel-plugin-import) 插件可以快速配置好自动按需加载组件,还可以通过直接手动引入对应组件和样式文件的方式来实现。同时,在开发中使用 webpack 构建项目时也常使用懒加载技术,本文所述的组件库动态加载和 webpack 构建项目的懒加载是不同的。本文将以 babel-plugin-import 插件为主,讲解组件库按需加载方案的实现原理。

对比 webpack 懒加载

组件库按需加载: 组件库以组件为基本单位产出 js、css、less 文件,借助插件或者部分引入的写法,使得项目代码或 babel 编译后的代码中只包含使用到的组件的 js、css、less 等。 组件库动态加载1.png webpack 懒加载: webpack 将源码中的 import、require 引入的文件编译之后再根据动态加载语法配置(通常以页面路由为基本单位)将较大的代码拆分并构建出较小的 chunk 包,应用在运行时执行到相应业务逻辑时才去加载执行对应 chunk 代码。 webpack 懒加载主要发生在下图的 JS 拆分出不同的 Chunk 这一过程中。 组件库动态加载 2.png 可见,两者的差别主要在于:

两者执行时机不同,组件库按需加载是在源码编写阶段或者 babel 编译 js 阶段,而 webpack 懒加载则是在构建生成打包产物时,组件库按需加载在前,webpack 懒加载在后; 两者原理不同,组件库按需加载是在源码阶段就去掉了无关代码,而 webpack 懒加载则是将经过 tree-shaking 优化过后的大文件包进行拆分在适当的运行时进行按需加载。 为何需要组件库按需加载

组件库按需加载主要目的就是为了减少项目构建打包产物的大小,提高项目线上首屏渲染速度,减少白屏时间,减少流量消耗。

一般组件库会提供一种引入全部组件和 css 文件的写法,例如:

import Vue from 'vue'; import Vant from 'vant'; import 'vant/lib/index.css'; Vue.use(Vant);

这种写法经过 webpack 构建之后会将组件库产出的 vant.min.js、index.css 引入并打包至构建产物中,而引入的 vant.min.js 文件是包含组件库全部组件的 js 部分,index.css 包含全部组件的 css 部分。因此,这会导致构建打包产物增大。

组件库动态加载用法

Vant 官方文档中推荐使用如下两种方式让 Vant 组件库支持按需加载。

方式一:手动加载

手动引入需要使用到的组件以及其对应的样式文件即可,在 webpack 构件时组件库中其他未被引入的文件不会被打包。

import Button from 'vant/lib/button'; import 'vant/lib/button/style'; 方式二:自动加载

安装 babel-plugin-import 插件

npm i babel-plugin-import -D

修改 babel 插件配置

module.exports = { plugins: [ ['import', { libraryName: 'vant', libraryDirectory: 'es', style: true }, 'vant'] ] };

在项目代码中按需引入要用到的组件

import { Button } from 'vant'; Vue.use(Button); 组件库按需加载的本质

从上文中手动配置按需加载需要用到的组件中就可以看出,所谓的按需加载就如字面意思一样,指的就是按需引入需要的组件,用专业术语来讲就是:在代码中手动引入需要用到的组件。组件其实就是对一堆 js、css 以及 less 等文件的总称,所以上文中需要手动引入组件对应的样式文件 vant/lib/button/style 。

即,本质就是对源代码进行如下转换

import { Button } from 'vant';

转换为

import "vant/es/button/style"; import _Button from "vant/es/button";

可以想到,如果每次需要用到新的组件都像这样时都同时手动引入 js、css 或 less 文件岂不是很麻烦,所以为了免去引入写法的繁杂,产生了两种方案:引入全部组件和使用插件自动引入。使用插件自动引入就是插件帮我们把引入组件的写法进行了转换,最后转换成了上文中手动加载方式的写法。

babel-plugin-import 插件

上文已经介绍了使用插件自动按需加载的本质,下面开始进一步地分析该插件底层是如何实现的。

核心原理

该插件核心逻辑可以概括用图示如下 Xnip2021-05-23_10-23-47.jpg 关键过程包括:词法、语法分析,AST 转换,代码生成。

1. 词法、语法分析

@babel/core (v7 版本之后)读取项目的源码内容,进行词法、语法分析,然后得到抽象语法树 AST,词法、语法分析过程这里不赘述,抽象语法树 AST 可以简单的理解为是一种树状结构的数据用于描述源码的内容。下图为一段 import 导入语句对应的 AST 结构,一个 AST 由多个节点组成,基本的节点结构类似于如下:

{ "type": "ImportDeclaration", "start": 0, "end": 29, ... }

源代码和 AST 对应关系见下图: image.png

2. AST 转换

上文中提到组件按需加载的本质是对源码的 import 导入写法进行转换,babel-plugin-import 插件也是如此,而转换代码这一过程是在 AST 层面进行并非直接操作源代码。

以下面组件引入的代码为例:

import { Button } from 'vant'

对应的 AST 为(点击查看完整 AST) image.png 转换后代码为:

import "vant/es/button/style"; import _Button from "vant/es/button";

对应 AST 为(点击查看完整 AST) image.png 转换的过程中对 AST 进行了变更:新增加了对样式文件的导入声明——第一个 ImportDeclaration 节点,对第二个 ImportDeclaration 节点的变量描述符 Identifier 进行了更名等。

3. 代码生成

对 AST 树进行了转换之后需要根据转后的 AST 生成代码,这一过程由 @babel/generator 完成,过程就是深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。

根据这个 AST 构建出如下代码:

import "vant/es/button/style"; import _Button from "vant/es/button";

以上用例 demo 可以在这里 github.com/JohnieXu/de… 查看。

浅析插件原理 插件配置

babel.config.js、.babelrc 配置文件的 plugins 选项告诉 babel 在编译 js 文件时候需要使用 babel-plugin-import 插件进行处理,同时配置文件中还指定了插件的参数,参数主要包括:

{ "libraryName": "vant", // 组件库名称,对应 import 语法中的包名 "libraryDirectory": "lib", // 编译之后各个组件单元所在文件夹名称 "style": true, // 是否引入组件对应样式文件,也可以传入 less 来引入 less 文件 "styleLibraryDirectory": "", // 编译之后引入的组件样式文件所在文件夹名称 "camel2DashComponentName": false, // 是否将驼峰命名的导入变量转换为对应的横线连接命名的文件名 "customName": (name, file) => { return `vant/lib/${name}` }, // 自定义编译之后引入的组件名 "customStyleName": (name, file) => { return `vant/lib/css/${name}` }, // 自定义编译之后引入样式文件的名称 }

更多配置参数详见 github.com/ant-design/…

插件入口

一个 babel 插件其实就是一个返回了 Visitor 对象的函数,其大致结构如下

export default function({ types: t }) { return { visitor: { Identifier(path, state) {}, ASTNodeTypeHere(path, state) {} } }; };

之所以叫做 Visitor 是因为这里采用了一种访问者模式,Visitor 对象上配置的所有成员方法、成员对象都是用于 babel 处理每个 node 节点时的钩子函数。babel-plugin-import 插件中使用到的钩子函数如下所示,后面注释有各个钩子的执行时机。

const methods = [ 'ImportDeclaration', // import 导入声明 'CallExpression', // 函数调用 'MemberExpression', 'Property', 'VariableDeclarator', 'ArrayExpression', 'LogicalExpression', 'ConditionalExpression', 'IfStatement', 'ExpressionStatement', 'ReturnStatement', 'ExportDefaultDeclaration', 'BinaryExpression', 'NewExpression', 'ClassDeclaration', 'SwitchStatement', 'SwitchCase', ];

babel-plugin-import 插件的 Visitor 对象上还配置了 Program 钩子,其结构如下:

const Program = { enter(path, options) { // ... }, exit() { // ... } }

Program 钩子是在 babel 处理一个独立文件(或者叫做模块更合适,node 规范定义一个文件就是一个模块)时执行。这里 enter、exit 是钩子函数另一种写法,分别对应进入、退出钩子,每个钩子函数都可以分别指定进入和退出时指定的钩子函数,不按此方式具体指定则默认为 enter 钩子。

这里 Program 在 enter 时执行的逻辑具体为:

根据插件接受到的配置参数初始化插件 Plugin 数组 遍历插件 Plugin 数组,依次执行各个插件的初始化方法 ProgramEnter

这里提到的插件 Plugin 是指的 babel-plugin-import 独立封装的一个类,主要用来定义各个钩子函数的执行逻辑,与 babel 插件的 Visitor 对象对接,区别在于这里的 Plugin 里面封装了一些可复用的工具方法。其核心方法如下:

转换导入语法

转换导入语法需要识别 ES6 模块规范的默认导入、部分导入以及整体导入等语法,主要逻辑包括鉴别是否是部分导入,只有部分导入才表示导入具体组件,转换导入变量名等。

import { Button } from 'vant' console.log(Button) // 1. 部分导入 import Vant from 'vant' console.log(Vant.Dialog, Vant.Toast, Vant.Cell) // 2. 默认导入 import * as V from 'vant' console.log(V.Dialog, V.Toast) // 3. 全部导入

上面代码经过转后的代码如下,默认导入、整体导入等导入语句被去掉,转换成了对应组件的部分导入 ,例如: import Vant from 'vant'。

import "vant/es/log/style"; import _log from "vant/es/log"; import "vant/es/cell/style"; import _Cell from "vant/es/cell"; import "vant/es/toast/style"; import _Toast from "vant/es/toast"; import "vant/es/dialog/style"; import _Dialog from "vant/es/dialog"; import "vant/es/button/style"; import _Button from "vant/es/button"; console.log(_Button); // 1. 部分导入 console.log(_Dialog, _Toast, _Cell); // 2. 默认导入 console.log(_Dialog, _Toast); // 3. 全部导入

处理逻辑如下:

ImportDeclaration 钩子中将部分导入、默认导入和整体导入的语句记录到插件全局状态对象上,同时将节点的 path 对象记录至插件全局状态对象上; 插件全局状态对象上存储的 path 对象会在 Program 退出时遍历执行 remove 方法,从而移除了所有原始的导入语句; 在MemberExpression、CallExpression、buildExpressionHandler、buildDeclaratorHandler等钩子函数中执行 importMethod 函数; importMethod 函数会根据插件的配置参数计算出真实文件导入路径、是否导入样式文件、样式文件名、是否转换默认导入等配置,从而使用 addSideEffect 方法添加对应的部分导入语句。 增加样式导入

在上述第 4 步中 importMethod 方法会根据计算出的参数配置添加对样式文件的导入,其源码实现逻辑如下:

// Plugin.js importMethod方法部分逻辑 if (this.customStyleName) { const stylePath = winPath(this.customStyleName(transformedMethodName)); addSideEffect(file.path, `${stylePath}`); } else if (this.styleLibraryDirectory) { const stylePath = winPath( join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName), ); addSideEffect(file.path, `${stylePath}`); } else if (style === true) { addSideEffect(file.path, `${path}/style`); } else if (style === 'css') { addSideEffect(file.path, `${path}/style/css`); } else if (typeof style === 'function') { const stylePath = style(path, file); if (stylePath) { addSideEffect(file.path, stylePath); } }

核心还是使用 @babel/helper-module-imports 提供的 addSideEffect 方法添加对样式文件的导入。

转换变量引用

在上述转换导入语法步骤中,不管是否配置 transformToDefaultImport 来处理 export default ,都会重命名导入模块的变量描述符 Identifier,因此引用了的导入组件对应的变量都得进行转换。

涉及到 Identifier 的钩子主要有:

Property VariableDeclarator ArrayExpression LogicalExpression ConditionalExpression IfStatement ExpressionStatement ReturnStatement ExportDefaultDeclaration BinaryExpression NewExpression SwitchStatement SwitchCase ClassDeclaration

上述不同的钩子函数中 path.node 对应的属性稍有不同,但是最终都需要转换 Identifier 的 name 属性。

因此,转换变量引用主要逻辑为:

以钩子函数为入口,根据不同的节点类型取找到不同节点与变量相关的属性; 校验变量的 name 是否存在于插件全局状态的 specfied 中,即变量是否是导入组件指向的变量; 通过 path.scope.hasBinding、path.scope.getBinding 排除掉掉其他作用域的变量; 借助 importMethod 方法计算转换后模块对应的变量名然后修改节点对应的变量命。 总结

经过上文的讲解,我们清楚了在使用常见 UI 组件库时引入组件库的其中方式以及区别,也了解了如何配置组件库的按需加载,最后还分析了 babel-plugin-import 插件的实现原理,知道了所谓的组件库按需加载是在 babel 编译 js 阶段进行了代码转换。

上文的讲解对 babel 插件原理、AST 抽象语法树的介绍并不充分,主要考虑到这些并非本文的重点,要想更详细了解的话强烈建议前往阅读参考资料里给出的文章。

参考资料 在线查看 AST 工具:astexplorer.net/ 深入 babel,这一篇就够了 juejin.cn/post/684490… 一口(很长的)气了解 babel juejin.cn/post/684490… babel 插件手册 github.com/jamiebuilds… ECMAScript 6 入门-Module 的语法 es6.ruanyifeng.com/#docs/modul… 写在最后

既然看到这里了不妨点个赞鼓励下作者呗 :)

作者博客:blog.lessing.online/

公众号:【迪诺笔记】

语雀:www.yuque.com/johniexu

Github:github.com/johniexu



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有