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

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

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

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

  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模块的引入方式,其实是在编译阶段,针对引用路径作替换。node

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

被替换成react

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

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

tree shaking

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

DCE

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

npm init -y
复制代码

安装打包工具webpack4github

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

建立入口文件web

写一下最基本的webpack配置npm

打包以后结果以下

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、commonjs二、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的支持度也会愈来愈好,这也算是一个使人欣慰的好消息了。

相关文章
相关标签/搜索