一文讲明白组件的来加载
如今是单页面SPA的天下,因此面试时经常会被问到,如何提升页面性能,其中有一个点就是对组件或资源进行来加载。
css/js/media/package等资源 使用 对应 loader来加载- vue 使用
import(/xxx/, () => import('@/components/xxx.vue'))来加载组件 - react 使用
const Xx = lazy(() => import("./xx/xx"));来加载组件
因此本文就如何进行懒加载进行讲解。
JS/CSS 懒加载实现
interface loadedMap {
[propName: string]: boolean;
}
export class Loader {
jsMap: loadedMap = {};
cssMap: loadedMap = {};
/**
* 加载js,主要用于加载cdn版本,模块请从全局变量获取
* @param src
*/
loadJs(src: string) {
return new Promise((resolve, reject) => {
if (this.jsMap[src]) {
resolve(true);
} else {
const script = document.createElement("script");
script.src = src;
script.async = false;
(document.head || document.body).appendChild(script);
script.onload = () => {
this.jsMap[src] = true;
typeof resolve === "function" && resolve(true);
};
script.onerror = (err) => {
typeof reject === "function" && reject(err);
};
}
});
}
/**
* 加载css
* @param link
*/
loadCss(href: string) {
return new Promise((resolve, reject) => {
if (this.cssMap[href]) {
resolve(true);
} else {
const link = document.createElement("link");
link.type = "text/css";
link.rel = "stylesheet";
link.href = href;
(document.head || document.body).appendChild(link);
link.onload = () => {
this.cssMap[href] = true;
typeof resolve === "function" && resolve(true);
};
link.onerror = (err) => {
typeof reject === "function" && reject(err);
};
}
});
}
/**
* 加载图片
* @param src
* @returns
*/
loadImage(src: string) {
return new Promise((resolve, reject) => {
const image = new Image();
image.src = src;
image.onload = () => {
typeof resolve === "function" && resolve(true);
};
image.onerror = (err) => {
typeof reject === "function" && reject(err);
};
});
}
/**
* 批量加载图片
* @param srcs
* @returns
*/
loadImages(srcs: string[]) {
const tasks = [];
for (const src of srcs) {
tasks.push(this.loadImage(src));
}
return Promise.all(tasks);
}
}
const loader = new Loader();
export default loader;package 赖加载实现
import(/* webpackChunkName: "html2canvas" */ "html2canvas").then((module) => {
const html2canvasModule = module.default || module;
const targetEle = document.querySelector("#_poster");
html2canvasModule(targetEle, { useCORS: true, scale: 2 }).then((canvas) => {
const shareUrlImage = canvas.toDataURL("image/jpeg");
this.shareUrlImage = shareUrlImage;
console.log(this.shareUrlImage);
this.isBorning = false;
resolve();
});
});组件懒加载
使用 tree shaking 实现
说起 tree shaking ,首先要讲一下 commonjs 和 esmodule 的区别。
- commonjs 是动态加载,即 require 时才会加载模块
- esmodule 是静态加载,即 import 时就会加载模块 因此在编译阶段,esmodule 会将所有的 import 语句收集起来,然后在运行时才会加载模块。而 commonjs是动态加载,他并不知道哪些模块会被使用,因此会把所有模块相关的东西都加在进来。 如: math.js(包含多个导出函数)
// math.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export function subtract(a, b) {
return a - b;
}
// 入口文件
// es-entry.js
import { add } from "./math.js";
console.log(add(2, 3));使用 rollup 打包后,产物如下,只有 add 函函数被打包进来了
math-cjs.js(CommonJS 风格)
// math-cjs.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, multiply, subtract };
// 入口文件
// cjs-entry.js
const { add } = require("./math-cjs.js");
console.log(add(2, 3));使用 rollup 打包后,产物如下,把所有东西都打包进来了:
组件库怎样tree shaking打包进来 首先我们正常写一个组件库,npm link 全局如: 目录结构如下:
lazy-ui/
├── package
├── src/
├── a/
├── index.js
├── index.vue
├── style.scss
├── b/
├── index.js
├── index.vue
├── style.scss
├── index.js
├── readme.md
├── package.json- lazy-ui/index.js
import A from "./packages/a/index.js";
import B from "./packages/b/index.js";
const components = [A, B];
const install = function (Vue) {
components.forEach((component) => {
Vue.component(component.name, component);
});
};
if (typeof window !== "undefined" && window.Vue) {
install(window.Vue);
}
export { A, B };
export default {
install,
};- lazy-ui/packages/a/index.js
import A from "./index.vue";
A.install = function (Vue) {
Vue.component(A.name, A);
};
export { A };
export default A;- lazy-ui/packages/b/index.js
import B from "./index.vue";
B.install = function (Vue) {
Vue.component(B.name, B);
};
export { B };
export default B;然后新建一个业务库,npm link lazy-ui 上面的组件库如:
lazy-loading/
├── package.json
├── src/
├── main.js
├── App.vue在 main.js 中引入组件库:
import Vue from "vue";
import { B } from "lazy-ui";打包之后,会发现,不仅组件B被打包进来了,组件A也被打包进来了
但是当我们开启 esmodule 模式,就能很好的避免这个问题。(当 package.json 文件中同时有 module 和 main 字段时,打包工具会优先使用 module 字段指定的文件)
- 在lazy-ui项目中 package.json文件添加
"module": "index.js",
"sideEffects": ["**/*.css"], //
// - "sideEffects": false :告诉打包工具,我这个包里的所有文件都没有副作用,如果没被用到就可以放心删除。(最激进的优化)
// - "sideEffects": ["**/*.css"] :告诉打包工具, 大部分文件都没有副作用,但是所有的 CSS 文件除外 。请不要把 CSS 文件摇树摇掉,即使看起来没人引用它的导出值,也要保留它。- 再次打包,发现只有组件B被打包进来了
使用 bebel 实现
babel 是目前大多数组件库实现的方式,比如 vant 以及 element-ui
可以看到能直接使用import { B } from 'lazy-ui'方式来引入组件B,也不需要手动引入样式,那么这是怎么实现的呢,接下来我们来撸一个极简版的。
最终我们想要的是这样方式
import { B } from 'lazy-ui'
<!-- 转换为 -->
import B from 'lazy-ui/packages/b'所以我们只要用 babel 插件来实现这个转换就可以了。 首先在lazy-loading/babel.config.js同级新增一个lazy-loading/babel-plugin-component.js文件,作为我们插件文件,然后修改一下lazy-loading/babel.config.js文件:
module.exports = {
// ...
plugins: ["./babel-plugin-component.js"],
};- STEP 1 分析
import { B } from 'lazy-ui'对应AST树
整体是一个ImportDeclaration,通过souce.value可以判断导入的来源,specifiers数组里可以找到导入的变量,每个变量是一个ImportSpecifier,可以看到里面有两个对象:ImportSpecifier.imported和ImportSpecifier.local,这两个有什么区别呢,在于是否使用了别名导入,比如:
import { B } from "lazy-ui";这种情况imported和local是一样的,但是如果使用了别名:
import { B as LocalB } from "lazy-ui";就会变成这样
我们这里简单起见就不考虑别名情况,只使用imported。
接下来的任务就是进行转换,看一下import B from 'lazy-ui/packages/b'的AST结构:
目标AST结构也清楚了接下来的事情就简单了,遍历specifiers数组创建新的importDeclaration节点,然后替换掉原来的节点即可:
// babel-plugin-component.js
module.exports = ({ types }) => {
return {
visitor: {
ImportDeclaration(path) {
const { node } = path;
const { value } = node.source;
if (value === "xui") {
// 找出引入的组件名称列表
let specifiersList = [];
node.specifiers.forEach((spec) => {
if (types.isImportSpecifier(spec)) {
specifiersList.push(spec.imported.name);
}
});
// 给每个组件创建一条导入语句
const importDeclarationList = specifiersList.map((name) => {
// 文件夹的名称首字母为小写
let lowerCaseName = name.toLowerCase();
// 构造importDeclaration节点
return types.importDeclaration(
[types.importDefaultSpecifier(types.identifier(name))],
types.stringLiteral("xui/packages/" + lowerCaseName),
);
});
// 用多节点替换单节点
path.replaceWithMultiple(importDeclarationList);
}
},
},
};
};接下来打包测试结果如下,只有组件 B 被打包进来了
可以看到Tag组件的内容已经没有了。
当然,以上实现只是一个最简单的demo,实际上还需要考虑样式的引入、别名、去重、在组件中引入、引入了某个组件但是实际并没有使用等各种问题,有兴趣的可以直接阅读 babel-plugin-component 源码。
Vant和antd也都是采用这种方式,只是使用的插件不一样,这两个使用的都是babel-plugin-import,babel-plugin-component其实也是fork自babel-plugin-import。
使用unplugin-vue-components插件实现
varlet组件库官方文档上按需引入一节里提到使用的是unplugin-vue-components插件:
这种方式的优点是完全不需要自己来引入组件,直接在模板里使用,由插件来扫描引入并注册,这个插件内置支持了很多市面上流行的组件库,对于已经内置支持的组件库,直接参考上图引入对应的解析函数配置一下即可,但是我们的小破组件库它并不支持,所以需要自己来写这个解析器。
首先这个插件做的事情只是帮我们引入组件并注册,实际上按需加载的功能还是得靠前面两种方式。
- Tree Shaking 我们先在上一节的基础上进行修改,保留
package.json的module和sideEffects的配置,然后从main.js里删除组件引入和注册的代码,然后修改vue.config.js文件。因为这个插件的官方文档比较简洁,看不出个所以然,所以笔者是参考内置的vant解析器来修改的:
回的三个字段含义应该是比较清晰的,name表示引入的组件名,比如B,from表示从哪里引入,对于我们的组件库就是lazy-ui,sideEffects就是存在副作用的文件,基本就是配置对应的样式文件路径,所以我们修改如下:
// vue.config.js
const Components = require("unplugin-vue-components/webpack");
module.exports = {
configureWebpack: {
plugins: [
Components({
resolvers: [
{
type: "component",
resolve: (name) => {
if (name.startsWith("lazy")) {
const partialName = name.slice(1);
return {
importName: partialName,
path: "lazy-ui",
sideEffects: `lazy/package/${partialName.toLowerCase()}/style.css`,
};
}
},
},
],
}),
],
},
};可以看到运行正常,打包后也成功去除了未使用的A组件的内容。
- 单独引入 最后让我们再看一下单独引入的方式,先把pkg.module和pkg.sideEffects字段都移除,然后修改每个组件的index.js文件,让其支持如下方式引入
import B from "lazy-ui/packages/b";B组件的内容如下:
import B from "./index.vue";
B.install = function (Vue) {
Vue.component(B.name, B);
};
export { B };
export default B;接下来再修改我们的解析器:
const Components = require("unplugin-vue-components/webpack");
module.exports = {
configureWebpack: {
mode: "production",
plugins: [
Components({
resolvers: [
{
type: "component",
resolve: (name) => {
if (name.startsWith("X")) {
const partialName = name.slice(1);
return {
importName: partialName,
// 修改path字段,指向每个组件的index.js
path: "lazy-ui/packages/" + partialName.toLowerCase(),
sideEffects:
"lazy-ui/packages/" +
partialName.toLowerCase() +
"/style.css",
};
}
},
},
],
}),
],
},
};其实就是修改了path字段,让其指向每个组件的index.js文件,运行测试和打包测试后结果也是符合要求的。