Skip to content

一文讲明白组件的来加载

如今是单页面SPA的天下,因此面试时经常会被问到,如何提升页面性能,其中有一个点就是对组件或资源进行来加载。

  • css/js/media/package 等资源 使用 对应 loader来加载
  • vue 使用 import(/xxx/, () => import('@/components/xxx.vue')) 来加载组件
  • react 使用 const Xx = lazy(() => import("./xx/xx")); 来加载组件

因此本文就如何进行懒加载进行讲解。

JS/CSS 懒加载实现

typescript
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 赖加载实现

javascript
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(包含多个导出函数)
javascript
// 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 函函数被打包进来了

alt text


math-cjs.js(CommonJS 风格)

javascript
// 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 打包后,产物如下,把所有东西都打包进来了:

alt text

组件库怎样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
javascript
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
javascript
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
javascript
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 中引入组件库:

javascript
import Vue from "vue";
import { B } from "lazy-ui";

打包之后,会发现,不仅组件B被打包进来了,组件A也被打包进来了 alt text

但是当我们开启 esmodule 模式,就能很好的避免这个问题。(当 package.json 文件中同时有 module 和 main 字段时,打包工具会优先使用 module 字段指定的文件)

  • 在lazy-ui项目中 package.json文件添加
json
"module": "index.js",
"sideEffects": ["**/*.css"], //
// - "sideEffects": false :告诉打包工具,我这个包里的所有文件都没有副作用,如果没被用到就可以放心删除。(最激进的优化)
// - "sideEffects": ["**/*.css"] :告诉打包工具, 大部分文件都没有副作用,但是所有的 CSS 文件除外 。请不要把 CSS 文件摇树摇掉,即使看起来没人引用它的导出值,也要保留它。
  • 再次打包,发现只有组件B被打包进来了

alt text

使用 bebel 实现

babel 是目前大多数组件库实现的方式,比如 vant 以及 element-ui

alt text

可以看到能直接使用import { B } from 'lazy-ui'方式来引入组件B,也不需要手动引入样式,那么这是怎么实现的呢,接下来我们来撸一个极简版的。

最终我们想要的是这样方式

javascript
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文件:

js
module.exports = {
  // ...
  plugins: ["./babel-plugin-component.js"],
};
  • STEP 1 分析 import { B } from 'lazy-ui'对应AST树

alt text

整体是一个ImportDeclaration,通过souce.value可以判断导入的来源,specifiers数组里可以找到导入的变量,每个变量是一个ImportSpecifier,可以看到里面有两个对象:ImportSpecifier.importedImportSpecifier.local,这两个有什么区别呢,在于是否使用了别名导入,比如:

js
import { B } from "lazy-ui";

这种情况imported和local是一样的,但是如果使用了别名:

javascript
import { B as LocalB } from "lazy-ui";

就会变成这样 alt text

我们这里简单起见就不考虑别名情况,只使用imported。

接下来的任务就是进行转换,看一下import B from 'lazy-ui/packages/b'的AST结构:

alt text

目标AST结构也清楚了接下来的事情就简单了,遍历specifiers数组创建新的importDeclaration节点,然后替换掉原来的节点即可:

javascript
// 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 被打包进来了 alt text

可以看到Tag组件的内容已经没有了。

当然,以上实现只是一个最简单的demo,实际上还需要考虑样式的引入、别名、去重、在组件中引入、引入了某个组件但是实际并没有使用等各种问题,有兴趣的可以直接阅读 babel-plugin-component 源码。

Vant和antd也都是采用这种方式,只是使用的插件不一样,这两个使用的都是babel-plugin-importbabel-plugin-component其实也是fork自babel-plugin-import

使用unplugin-vue-components插件实现

varlet组件库官方文档上按需引入一节里提到使用的是unplugin-vue-components插件: alt text

这种方式的优点是完全不需要自己来引入组件,直接在模板里使用,由插件来扫描引入并注册,这个插件内置支持了很多市面上流行的组件库,对于已经内置支持的组件库,直接参考上图引入对应的解析函数配置一下即可,但是我们的小破组件库它并不支持,所以需要自己来写这个解析器。

首先这个插件做的事情只是帮我们引入组件并注册,实际上按需加载的功能还是得靠前面两种方式。

  • Tree Shaking 我们先在上一节的基础上进行修改,保留package.jsonmodule和sideEffects的配置,然后从main.js里删除组件引入和注册的代码,然后修改vue.config.js文件。因为这个插件的官方文档比较简洁,看不出个所以然,所以笔者是参考内置的vant解析器来修改的: alt text

回的三个字段含义应该是比较清晰的,name表示引入的组件名,比如B,from表示从哪里引入,对于我们的组件库就是lazy-uisideEffects就是存在副作用的文件,基本就是配置对应的样式文件路径,所以我们修改如下:

javascript
// 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文件,让其支持如下方式引入
js
import B from "lazy-ui/packages/b";

B组件的内容如下:

js
import B from "./index.vue";

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

export { B };

export default B;

接下来再修改我们的解析器:

js
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文件,运行测试和打包测试后结果也是符合要求的。

参考文章

对应源码

上次更新于: