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的支持度也會愈來愈好,這也算是一個使人欣慰的好消息了。

相關文章
相關標籤/搜索