阅读 1986

VUE UI组件库按需引入的探索

这一整个月我几乎投入所有工作之外的时间在组件库的框架搭建上,特别是组件按需引入的探索让我本来发量就不多的头顶更加荒芜。关于组件按需引入的探索已经告一段落,虽然结果很不如意,但是过程中积累了很多东西,值得分享。

VUE UI组件库按需引入的探索
VUE UI组件库按需引入的探索(2)

组件库按需引入方案的选择

一个组件库会提供很多的组件,有时候用户只想使用其中的部分组件,那么在打包时,未使用的组件就应该被过滤,减小打包之后的体积。实现按需引入组件的思路有两种:

  1. 第一种是每个组件单独打包,以组件为单位生成多个模块,也就是多个js文件。使用时引入哪个组件就加载对应的文件。
  2. 第二种是用es6模块化标准编写组件,所有的组件打包成一个es模块,利用export导出多个接口。使用时import部分组件,然后打包时利用tree shaking特性将没有import的组件消除。

babel-plugin-component

现在流行的几款vue ui组件库(element-ui、ant-design-vue、iview)都是使用的方案一。以element-ui为例,虽然使用时我们的写法是import { Button } from 'element-ui',但是这种写法的前提是安装 babel-plugin-component插件。这种看似es模块的引入方式,实际上是在编译阶段,针对引用路径做替换。

import { Button } from 'components' 
复制代码

被替换成

var button = require('components/lib/button')
require('components/lib/button/style.css')
复制代码

第一种方案比较成熟,实现起来也不复杂(多入口打包,生成多个模块),具体的代码我会再写一篇文章介绍。我们先来看一下第二种方案的实现原理。

tree shaking

在谈tree shaking之前我们先来看另一个概念DCE(dead code elimination 意为消除无用代码)。无用代码指那些不会被执行或者执行结果不会被使用以及只读不写的代码。先来看一个例子。

DCE

新建一个文件夹,初始化项目

npm init -y
复制代码

安装打包工具webpack4

npm i webpack webpack-cli -D
复制代码

创建入口文件

写一下最基本的webpack配置

打包之后结果如下

webpack
复制代码

可以看到无用代码依然存在。那是因为我们的mode选择的none,webpack本身是不会帮我们消除无用代码的,js消除无用代码借助的是uglify这个代码压缩混淆工具。我们把webpack的mode设置为production,默认开启uglify及其他生产环境工具,再打包一次。格式化后代码如下

可以看到无用代码都被干掉了。

再来尝试一下加上模块之后的情况。

module.js中有两个函数,m1和m2,用CommonJs规范导出。index.js中引入时只使用m1。production模式下打包,结果如下

很明显,整个module.js的代码都被打包了。这也很好理解,require引入的是module.exports整个对象,使用了对象中的其中一个属性,这个对象就不再是无用代码了。

tree shaking

tree shaking也可以消除无用代码,它和传统DCE的不同之处在于它的消除原理是依赖于ES6的模块特性。也就是说,要想使用tree shaking,必须使用import/export语法导入导出模块。

下面我们尝试一下,添加两个esModule

打包结果如下

无用代码、导入未使用的模块以及未导入的代码都被干掉了。tree shaking大法好啊!

通过上面的例子我们可以发现tree shaking的强大之处还有esModule的好处。

esModule的模块依赖关系是确定的,和运行时的状态无关。基于此特性可以进行静态分析,在运行之前就知道哪些模块被引入了。 这就是tree-shaking优化的基础。

小结

通过上面的例子我们可以暂时得出以下结论:

  1. uglify可以去除js中的无用代码
  2. esModule的模块依赖关系是确定的,和运行时的状态无关。基于此特性可以进行静态分析。
  3. tree shaking只适用于esModule

既然tree shaking这么好用,那如果我基于esModule标准编写vue组件,export每个组件,然后使用时只import需要的组件,这样打包时可以利用tree shaking自动帮我消除没用到的组件,这样不就是按需引入了吗?想到这里心里真的美滋滋。

犹豫再三,我选择了tree shaking方案来实现按需引入,因为我写组件库的出发点是学习沉淀,如果用了别人用过很多次的成熟方案,那还怎么折腾。选好方向之后,那就开始coding吧。

组件按需引入实践

既然要使用tree shaking来做按需引入,那么组件库必须使用esModule规范,也就是说打包工具只能选择rollup。

webpack的output.libraryTarget只有 var、this、window、global、commonjs、commonjs2、amd、amd-require、umd、jsonp

关于rollup和webpack的区别大家可以查看rollup的官方文档,后面我也会考虑再写一篇文章比较rollup和webpack,也建议大家尝试一下rollup。它们的众多差异性中最重要的就是rollup支持打包生成esModule,而webpack不支持。下面我们就来实践一下,用rollup打包我们的组件库。

初始化项目&安装依赖

新建一文件夹,就叫VUI吧

npm init -y
复制代码
npm i rollup @babel/core @babel/plugin-transform-runtime 
@babel/preset-env rollup-plugin-babel rollup-plugin-terser node-sass
rollup-plugin-postcss rollup-plugin-vue2 -D
复制代码
npm i @babel/runtime-corejs2 -S
复制代码

rollup中的扩展主要通过plugin实现,下面分别解释一下上面部分依赖的作用,部分常见的依赖就不解释了,不了解的同学可以查一下它们的文档

  • rollup-plugin-babel ---- rollup中的babel插件,用于转换es6代码,babel怎么配置,它就怎么配置
  • rollup-plugin-terser ---- 代码压缩混淆,和uglify的区别是uglify无法压缩es6代码,terser可以。当然这里我们使用了babel,所以用rollup-plugin-ugligy来压缩代码也是可以的。这里主要是为了尝鲜,所以选择了rollup-plugin-terser
  • rollup-plugin-postcss ---- 用于编译css。postcss功能强大,可通过插件扩展
  • rollup-plugin-vue2 --- 用于编译.vue文件

写组件代码

下面是compA/CompA.vue

<template>
  <div class="comp-a">
    {{msg}}
    <span class="text">{{text}}</span>
  </div>
</template>

<script>
export default {
  name: 'CompA',

  props: {
    text: {
      type: String,
      default: ''
    }
  },

  data() {
    return {
      msg: 'hello compA'
    }
  }
};
</script>

<style lang="scss" scoped>
.comp-a {
  color: red;
  &:hover {
    font-size: 20px;
  }
  .text {
    color: blue;
  }
}
</style>
复制代码

下面是compA/index.js

import CompA from './CompA.vue'

CompA.install = (Vue) => {
  Vue.component(CompA.name, CompA)
}

export default CompA
复制代码

下面是compB/CompB.vue

<template>
  <div class="comp-b">
    {{msg}}
    <span class="text">this is compB</span>
  </div>
</template>

<script>
export default {
  name: 'CompB',

  data() {
    return {
      msg: 'hello compB'
    }
  }
};
</script>

<style lang="scss" scoped>
.comp-b {
  color: yellow;
  &:hover {
    font-size: 20px;
  }
  .text {
    color: green;
  }
}
</style>
复制代码

下面是compB/index.js

import CompB from './CompB.vue'

CompB.install = (Vue) => {
  Vue.component(CompB.name, CompB)
}

export default CompB
复制代码

本文主要的讨论点在于组件库的按需引入,所以组件的写法等细节点就不细说了。大家有疑问可以留言讨论。

babel配置

先贴出.babelrc文件

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": 2,
        "useESModules": true 
      }
    ]
  ]
}
复制代码

下面对部分配置进行说明

  • modules: false
    modules的可选项有 "amd" | "umd" | "systemjs" | "commonjs" | "cjs" | "auto" | false。设置为false表示babel不会对esModule的模块语法进行转换,保留原始的import/export语法。如果设置为其他选项,那么esModule语法就会被转换成其他模块化语法,我们就没法使用tree shaking了。
  • useESModules: true
    useESModules表示是否对文件使用ES模块的语法,使用ES的模块语法可以减少文件的大小。默认值是false,这里设置为true同样是为了防止babel将esModule转换为其他模块化标准的语法。

编写rollup配置文件

贴出rollup.config.js

import { terser } from "rollup-plugin-terser";
import babel from 'rollup-plugin-babel';
import vue from 'rollup-plugin-vue2';

import postcss from 'rollup-plugin-postcss';

export default {
  input: 'src/index.js',

  output: [
    {
      file: 'lib/v-ui.esm.js',
      format: 'esm'
    },
    {
      file: 'lib/v-ui.umd.js',
      name: 'v-ui',
      format: 'umd',
      exports: 'named'
    }
  ],

  plugins: [
    vue(),
    postcss(),
    terser(),
    babel({
      exclude: 'node_modules/**',
      runtimeHelpers: true
    })    
  ]
};
复制代码

同样,本文讨论的是组件库的按需引入,所以对于rollup的用法细节及插件机制不会详述。没接触过rollup的同学建议看看官方文档。下面对配置文件做简要说明

  • output
    rollup的output支持多种格式。format: 'esm'表示输出esModule规范的模块,使用tree shaking的前提。 format: 'umd'表示输出通用模块定义,以amd,cjs,iife为一体。这样做的目的是支持组件库的其他引入方式,比如require、cdn等。

修改package.json

{
  "name": "VUI",
  "version": "1.0.0",
  "description": "",
  "main": "lib/v-ui.umd.js",
  "sideEffects": false,
  "module": "lib/v-ui.esm.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@babel/runtime-corejs2": "^7.7.2"
  },
  "devDependencies": {
    "@babel/core": "^7.7.2",
    "@babel/plugin-transform-runtime": "^7.6.2",
    "@babel/preset-env": "^7.7.1",
    "node-sass": "^4.13.0",
    "rollup": "^1.27.3",
    "rollup-plugin-babel": "^4.3.3",
    "rollup-plugin-postcss": "^2.0.3",
    "rollup-plugin-terser": "^5.1.2",
    "rollup-plugin-vue2": "^0.8.1"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "rules": {},
    "parserOptions": {
      "parser": "babel-eslint"
    }
  }
}
复制代码

下面对部分关键配置做出说明

  • "main": "lib/v-ui.umd.js"
    main表示程序的入口,也就是用户引入这个组件库时默认加载的文件。这里我们设置成lib/v-ui.umd.js是为了支持用户按require和cdn的方式引入组件库
  • "module": "lib/v-ui.esm.js"
    module是rollup最先提出的一个概念,在webpack2中开始支持。在es6之前,模块化规范CommonJs比较通用,大家构建库时也大都采用的此标准,组件通过module.exports导出,使用时通过require导入,组件库的入口文件通过main设置。伴随着es6的诞生,esModule模块化规范开始展现它的优势。项目打包出esModule模块后,如果入口文件还是使用main就会对使用者造成困扰,因为用户的项目可能采用的是其他模块化规范,直接引入esModule模块可能造成问题。所以rollup提出使用module字段表示esModule模块的入口。设置module后,会根据项目的引入方式自动识别模块化规范,以import的方式引入项目会寻找module字段指定的入口文件,以其他方式引入项目会寻找main字段指定的入口文件。如果module字段指定的入口文件无法被找到,会转而寻找main字段指定的入口文件。
  • "sideEffects": false
    sideEffects是webpack4新增的一个特性,设置为false表示这个包在设计的时候就是期望没有副作用的,即使他打完包后是有副作用的。使用者项目的打包工具可以放心的tree shaking。

打包组件库

rollup -c
复制代码

成功的打包出两个文件。下面是v-ui.esm.js格式化之后的代码

function t(t, e) {
  void 0 === e && (e = {});
  var n = e.insertAt;
  if (t && "undefined" != typeof document) {
    var s = document.head || document.getElementsByTagName("head")[0],
      o = document.createElement("style");
    o.type = "text/css", "top" === n && s.firstChild ? s.insertBefore(o, s.firstChild) : s.appendChild(o), o.styleSheet ? o.styleSheet.cssText = t : o.appendChild(document.createTextNode(t))
  }
}
t(".comp-a {\n  color: red; }\n  .comp-a:hover {\n    font-size: 20px; }\n  .comp-a .text {\n    color: blue; }\n");
var e = {
  render: function () {
    var t = this.$createElement,
      e = this._self._c || t;
    return e("div", {
      staticClass: "comp-a"
    }, [this._v("\n    " + this._s(this.msg) + "\n    "), e("span", {
      staticClass: "text"
    }, [this._v(this._s(this.text))])])
  },
  staticRenderFns: [],
  name: "CompA",
  props: {
    text: {
      type: String,
      default: ""
    }
  },
  data: () => ({
    msg: "hello compA"
  }),
  install: function (t) {
    t.component(e.name, e)
  }
};
t(".comp-b {\n  color: yellow; }\n  .comp-b:hover {\n    font-size: 20px; }\n  .comp-b .text {\n    color: green; }\n");
var n = {
    render: function () {
      var t = this.$createElement,
        e = this._self._c || t;
      return e("div", {
        staticClass: "comp-b"
      }, [this._v("\n    " + this._s(this.msg) + "\n    "), e("span", {
        staticClass: "text"
      }, [this._v("this is compB")])])
    },
    staticRenderFns: [],
    name: "CompB",
    data: () => ({
      msg: "hello compB"
    }),
    install: function (t) {
      t.component(n.name, n)
    }
  },
  s = [e, n],
  o = {
    install: function (t) {
      s.map((function (e) {
        return t.component(e.name, e)
      }))
    }
  };
export default o;
export {
  e as CompA, n as CompB
};
复制代码

代码打包好了,暂时不做分析,先来试用一下吧。

试用组件库

本地开发,我们直接把组件库连接到全局

npm link
复制代码

用@vue/cli新建一个vue项目,就叫vuitest吧

vue create vuitest
复制代码

项目创建好之后引入VUI

npm link VUI
复制代码

main.js中使用VUI,先试一试整体引入

整体引入

改一下项目中的HelloWorld.vue,使用VUI

跑起来看看

npm run serve
复制代码

效果很不错,至少组件库能用了,先给自己点个赞。下面我们打包vuitest

npm run build
复制代码

打包之后的文件太大了,我就不截图了。这里我们需要验证的是两个组件和样式是不是都被打包了。

很明显,两个组件和样式都被打包了。

按需引入

下面尝试一下按需引入

跑起来

npm run serve
复制代码

打包看看

npm run build
复制代码

同样,我们来验证一下两个组件和样式的打包情况

惊喜的是未被引入的CompB打包的时候被干掉了,惊吓的是CompB的样式没有被干掉。

辛苦了半天,我们总算实现了一个阉割版的按需引入。剩下的问题就是样式怎么办。

样式怎么办

我们先来分析一下VUI打包之后的v-ui.esm.js

上面的代码表明:打包之后样式代码被单独拿了出来,通过创建style标签的方式插入了head中。样式和组件代码没有产生关联,被一股脑的单独引入了。

那现在我们需要做的事情就是让样式和组件关联起来,加载某一个组件的同时加载对应的样式。

css in js ?

解决组件和样式关联问题,我想到的第一个解决方案是css in js。现在最流行的css in js库是style-components,可是这个库是为react量身打造的,引入之后甚至还得安装react依赖。这里我忍不住想吐槽,我找了几个流行的css in js库,发现它们基本都是绑定react的,有几个声称是框架无关,但是官方文档里写的推荐框架依然是react...... 兜兜转转好几圈,终于找到一个vue-styled-components,先试一试

在打包一次

rollup -c
复制代码

贴出打包后格式化的代码

import t from "@babel/runtime-corejs2/helpers/esm/taggedTemplateLiteral";
import n from "vue-styled-components";

function e() {
  var n = t(["\n  .comp-a {\n    color: red;\n    &:hover {\n      font-size: 20px;\n    }\n    .text {\n      color: blue;\n    }\n  }\n"]);
  return e = function () {
    return n
  }, n
}
var s = {
  render: function () {
    var t = this.$createElement,
      n = this._self._c || t;
    return n("compa-style", [n("div", {
      staticClass: "comp-a"
    }, [this._v("\n    " + this._s(this.msg) + "\n    "), n("span", {
      staticClass: "text"
    }, [this._v(this._s(this.text))])])])
  },
  staticRenderFns: [],
  name: "CompA",
  components: {
    "compa-style": n.span(e())
  },
  props: {
    text: {
      type: String,
      default: ""
    }
  },
  data: () => ({
    msg: "hello compA"
  })
};

function o() {
  var n = t(["\n  .comp-b {\n    color: yellow;\n    &:hover {\n      font-size: 20px;\n    }\n    .text {\n      color: green;\n    }\n  }\n"]);
  return o = function () {
    return n
  }, n
}
s.install = function (t) {
  t.component(s.name, s)
};
var r = {
    render: function () {
      var t = this.$createElement,
        n = this._self._c || t;
      return n("compb-style", [n("div", {
        staticClass: "comp-b"
      }, [this._v("\n    " + this._s(this.msg) + "\n    "), n("span", {
        staticClass: "text"
      }, [this._v("this is compB")])])])
    },
    staticRenderFns: [],
    name: "CompB",
    components: {
      "compb-style": n.span(o())
    },
    data: () => ({
      msg: "hello compB"
    }),
    install: function (t) {
      t.component(r.name, r)
    }
  },
  a = [s, r],
  i = {
    install: function (t) {
      a.map((function (n) {
        return t.component(n.name, n)
      }))
    }
  };
export default i;
export {
  s as CompA, r as CompB
};
复制代码

可以看到样式和组件确实关联起来了。 在vuitest中也打包一次

npm run build
复制代码

看看两个组件和样式的打包情况

嗯... 情况更糟了,所有的组件和样式都被打包了。简单分析一下v-ui.esm.js,应该是打包之后的代码有副作用,无法被tree shaking,就连加了sideEffects: false都不行。

style-components方案宣布失败,不甘心的我又尝试了jssvue-emotion等其他支持vue的css in js方案,无一例外全部以失败告终。看来这些库在编写时并未考虑tree shaking的情况,或者说现在esModule仍为广泛使用。

放弃

花了一周多的时间解决样式问题,尝试N多方案之后都无果,我最终能想到的解决方案只剩下样式单独打包,以组件为单位进行拆分,然后使用时借鉴babel-plugin-component的思路,单独引入样式。可是这样又违背了我使用tree shaking的初心。

很遗憾花费了近一个月的时间探索无果,最终我决定放弃探索样式问题,转而借鉴babel-plugin-component的思路。如果哪位大佬有合适的解决方案,跪求指点。

留下了没技术的泪水...

番外

在尝试tree shaking的过程中遇到了一个很奇怪的问题,至今还未不理解,贴出来大家看看。

写vue组件的过程中,props的值会有Boolen类型,可是当出现type: Boolean这样的代码时,tree shaking直接失效了。具体代码如下

先看正常的情况

compA中props text的type为String,VUI打包。vuitest中只引入compB

vuitest打包后,组件和样式打包情况如下

和我们之前的结果一样,组件内容被tree shaking优化了,样式保留。现在我们把props的内容换一下

VUI打包,vuitest打包。组件和样式的打包情况如下

可以看到未引入的compA的组件和样式都被打包了,tree shaking失效了。

刚开始遇到这个问题的时候为了定位问题,我尝试修改了文章开头的那个例子

似乎uglify对于属性值为Boolean的情况有什么特殊处理,暂时我也没找到思路,只能深入源码找找原因了。哪位大佬知道原因欢迎留言点拨。

总结

尽管webpack4加入了sideEffects字段,改善了对于tree shaking的支持情况。但是tree shaking的发展情况依然不容乐观啊。现阶段在没有其他方案的帮助下单纯利用tree shaking特性来实现组件库的按需引入看来还有难度。今天看到Node新版本13.2.0正式支持ES Modules特性,可能在不久的未来tree shaking的支持度也会越来越好,这也算是一个令人欣慰的好消息了。