如何從頭到尾做一個UI元件庫

Charon發表於2021-10-08

首先我們我們的這個ui元件庫是開發的vue版本,如果需要變通其他版本的話,就把vue相關的編譯器移除,其他相關的編譯器加上就可以了(例如react),打包構建方式是通用的。

元件庫的組織發版和專案是不太一樣的,這裡提下思路。
首先我們要確定我們做的庫要滿足的需求:

  1. 支援全部載入
  2. 支援按需載入
  3. ts的補充型別支援
  4. 同時支援cjs和esm版本

知道了我們要支援的需求之後,要確定一下我們最後包的目錄結構是什麼樣的,如下:
image.png
這簡單描述下為何是這樣的結構,首先index.esm是 我們整全量的包,裡面包含了所有的ui元件,還有一個index.cjs版本,在打包工具不支援esm時會使用cjs版本,兩個版本可以更好的支援不同的打包工具。

lib下放的是我們單個元件,用來結合 babel-plugin-import 來做按需載入。
這裡先簡單做一個概括,後續實現的時候會做詳細的解釋。

好了,瞭解了 我們最後的專案結構,就要開始ui庫的搭建了,後續所有的操作配置,都是為了在保證程式健壯性通用性的基礎上來打出來我們最後要釋出的這個包的結構。


設計及流程

程式碼的組織方式Monorepo

Monorepo 是管理專案程式碼的一個方式,指在一個專案倉庫(repo) 中管理多個模組/包(package),不同於常見的每個模組建一個repo。
例如:

├── packages
|   ├── pkg1
|   |   ├── package.json
|   ├── pkg2
|   |   ├── package.json
├── package.json

這樣的結構,可以看到這些專案的第一級目錄的內容以腳手架為主,主要內容都在 packages 目錄中、分多個package進行管理。

一些知名的庫例如vue3.0和react都是採用這種方式來管理專案的。
image.png
image.png
後續我們會根據這些packages裡的小包,來生成按需載入的檔案。

包的管理工具採用yarn,因為我們要用到它的workspaces依賴管理

如果不用workspaces時,因為各個package理論上都是獨立的,所以每個package都維護著自己的dependencies,而很大的可能性,package之間有不少相同的依賴,而這就可能使install時出現重複安裝,使本來就很大的 node_modules 繼續膨脹(這就是「依賴爆炸」...)。

為了解決這個問題在這裡我們要使用yarn的workspaces特性,這也就是依賴管理我們為什麼使用yarn的原因。
而使用yarn作為包管理器的同學,可以在 package.json 中以 workspaces 欄位宣告packages,yarn就會以monorepo的方式管理packages。
使用方式詳情可以檢視它的官方文件
文件

我們在package.json開啟了yarn 的workspaces工作區之後,當前這個目錄被稱為了工作區根目錄,工作區並不是要釋出的,然後這會我們在下載依賴的時候,不同元件包裡的相同版本的依賴會下載到工作區的node_modules裡,如果當前包依賴的版本和其他不一樣就會下載到當前包的node_modules裡。

yarn的話突出的是對依賴的管理,包括packages 的相互依賴、packages 對第三方的依賴,yarn 會以semver 約定來分析dependencies 的版本,安裝依賴時更快、佔用體積更小。

lerna

這裡簡單提一下lerna,因為目前主流的monorepo解決方案是Lerna 和 yarn 的 workspaces 特性,它主要用來管理工作流,但是它個人感覺如果你需要一次性發布packages裡的所有包時,用它會比較方便,我們這裡沒有過多的用到它。

Storybook開發階段的除錯

元件效果的除錯和使用介紹我們通過Storybook來進行管理,這是一個視覺化的元件展示平臺,它可以讓我們在隔離的開發環境 互動地開發和測試元件,最後也可以生成使用說明的靜態介面,它支援很多框架例如:vue.react,ng,React Native等。

jest單元測試

單元測試的話我們使用Facebook的jest

plop建立相同模版

我們包的結構是這樣的,例如avatar:

├── packages
|   ├── avatar
|   |   ├── __test__  //單元測試檔案
|   |   ├── src //元件檔案
|   |   ├── stories //storyBook 開發階段預覽的展示,掃描檔案
|   |   ├── index.ts //包入口
|   |   ├── LICENSE
|   |   ├── README.MD
|   |   ├── package.json
├── package.json

每個UI元件的結構基本都是一樣的,所以在這裡我們選用plop來統一生成模版,plop主要用於建立專案中特定檔案型別的小工具,類似於Yeoman中的sub generator,一般不會獨立使用。一般會把Plop整合到專案中,用來自動化的建立同型別的專案檔案。

Rollup進行打包

最後是構建操作,這裡我們打包不使用webpack,而是用Rollup,。
webpack的話更適合專案工程使用,因為專案裡很多靜態資源需要處理,再或者構建的專案需要引入很多CommonJS模組的依賴,這樣雖然它也有搖樹的功能tree-shaking(額外配置),但是因為要處理轉換其他檔案所以它打出來的包還是會有一些冗餘程式碼。
而rollup 也是支援tree-shaking的,而且它主要是針對js打包使用,它打包結果比webpack更小,開發類庫用它會更合適。

下面講下構建過程:

首先我貼一個我最後完整版本的依賴,如下:

{
  "name": "c-dhn-act",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "gitlabGroup": "component",
  "devDependencies": {
    "@babel/cli": "^7.13.16",
    "@babel/core": "^7.11.4",
    "@babel/plugin-transform-runtime": "^7.13.15",
    "@babel/preset-env": "^7.11.5",
    "@babel/preset-typescript": "^7.13.0",
    "@rollup/plugin-json": "^4.1.0",
    "@rollup/plugin-node-resolve": "^8.4.0",
    "@storybook/addon-actions": "6.2.9",
    "@storybook/addon-essentials": "6.2.9",
    "@storybook/addon-links": "6.2.9",
    "@storybook/vue3": "6.2.9",
    "@types/jest": "^26.0.22",
    "@types/lodash": "^4.14.168",
    "@vue/compiler-sfc": "^3.1.4",
    "@vue/component-compiler-utils": "^3.2.0",
    "@vue/shared": "^3.1.4",
    "@vue/test-utils": "^2.0.0-rc.6",
    "babel-core": "^7.0.0-bridge.0",
    "babel-jest": "^26.6.3",
    "babel-loader": "^8.2.2",
    "babel-plugin-lodash": "^3.3.4",
    "cp-cli": "^2.0.0",
    "cross-env": "^7.0.3",
    "css-loader": "^5.2.6",
    "http-server": "^0.12.3",
    "inquirer": "^8.0.0",
    "jest": "^26.6.3",
    "jest-css-modules": "^2.1.0",
    "json-format": "^1.0.1",
    "lerna": "^4.0.0",
    "plop": "^2.7.4",
    "rimraf": "^3.0.2",
    "rollup": "^2.45.2",
    "rollup-plugin-alias": "^2.2.0",
    "rollup-plugin-terser": "^7.0.2",
    "rollup-plugin-typescript2": "^0.30.0",
    "rollup-plugin-vue": "^6.0.0",
    "sass": "^1.35.1",
    "sass-loader": "10.1.1",
    "storybook-readme": "^5.0.9",
    "style-loader": "^2.0.0",
    "typescript": "^4.2.4",
    "vue": "3.1.4",
    "vue-jest": "5.0.0-alpha.5",
    "vue-loader": "^16.2.0"
  },
  "peerDependencies": {
    "vue": "^3.1.x"
  },
  "scripts": {
    "test": "jest --passWithNoTests",
    "storybookPre": "http-server build",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook --quiet --docs -o ui",
    "lerna": "lerna publish",
    "buildTiny:prod": "cross-env NODE_ENV=production rollup -c buildProject/rollup.tiny.js",
    "buildTiny:dev": "cross-env NODE_ENV=development rollup -c buildProject/rollup.tiny.js",
    "clean": "lerna clean",
    "plop": "plop",
    "clean:lib": "rimraf dist/lib",
    "build:theme": "rimraf packages/theme-chalk/lib && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib dist/lib/theme-chalk && rimraf packages/theme-chalk/lib",
    "build:utils": "cross-env BABEL_ENV=utils babel packages/utils --extensions .ts --out-dir dist/lib/utils",
    "buildAll:prod": "cross-env NODE_ENV=production rollup -c buildProject/rollup.all.js",
    "buildAll:dev": "cross-env NODE_ENV=development rollup -c buildProject/rollup.all.js",
    "build:type": "node buildProject/gen-type.js",
    "build:v": "node buildProject/gen-v.js",
    "build:dev": "yarn build:v && yarn clean:lib  && yarn buildTiny:dev && yarn buildAll:dev && yarn build:utils && yarn build:type && yarn build:theme",
    "build:prod": "yarn build:v && yarn clean:lib  && yarn buildTiny:prod && yarn buildAll:prod && yarn build:utils  && yarn build:type && yarn build:theme"
  },
  "dependencies": {
    "comutils": "1.1.9",
    "dhn-swiper": "^1.0.0",
    "lodash": "^4.17.21",
    "vue-luck-draw": "^3.4.7"
  },
  "private": true,
  "workspaces": [
    "./packages/*"
  ]
}

可以看到我這裡storyBook 用的是6.2.9版本的,這裡不用最新版是因為無法最後開啟文件模式,不知道現在問題解決了沒有。

專案的初始化可以採用storyBook的腳手架,後續我們再往裡面添東西。
初始化我們用的是vue3.0版本,這裡大家可以按手冊去初始化
storybook 官網vue初始化手冊

官網也提供了,其他框架專案的初始化。
初始化完成後,我們找到.storyBook資料夾,我們需要修改他下面的內容:
main.js改成這樣,如下:

const path = require('path');
module.exports = {
  "stories": [
    "../stories/**/*.stories.mdx",
    "../packages/**/*.stories.mdx",
    "../packages/**/*.stories.@(js|jsx|ts|tsx)",
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials"
  ],
  webpackFinal: async (config, { configType }) => {
    // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
    // You can change the configuration based on that.
    // 'PRODUCTION' is used when building the static version of storybook.

    // Make whatever fine-grained changes you need
    config.module.rules.push({
      test: /\.scss$/,
      use: ['style-loader', 'css-loader', {
        loader:'sass-loader',  //這種是指定dart-sass  替代node-sass  不然一些數學函式 用不了  math函式只有dart-sass可以用  
        options:{
          implementation:require("sass")
        }
      }],
      include: path.resolve(__dirname, '../'),
    });

    // Return the altered config
    return config;
  },
}

這裡stories 配置項,配置路徑裡放的是介面要呈現的說明和元件,匹配到的mdx裡放的是它的使用指引,mdx是markdowm和jsx的結合。

addons裡放的是它的一些外掛,addon-essentials是外掛集合(集合),包含了一系列的外掛 可以保證我們開箱即用,addon-links 用來設定連結的外掛。

webpackFinal是針對webpack 的一些擴充套件,我們這裡用dart-sass替代了node-sass,不然一些數學函式 用不了,例如 math函式只有dart-sass可以用。

那我們packages/avatar/stories/avatar.stories.mdx 下語法,你可以參考官網mdx語法

workspaces和private已經在packsge.json裡配置了,

  "private": true,
  "workspaces": [
    "./packages/*"
  ]

如果有不瞭解workspaces的作用的,可以百度下它的作用。

然後就是ts和jest的整合

首先我們先來整合ts,首先下載依賴:
主要的包有倆:

yarn add typescript rollup-plugin-typescript2 -D -W

然後修改tsconfig.json

{
    "compilerOptions": {
      "module": "ESNext",//指定使用的模組標準
      "declaration": true,// 生成宣告檔案,開啟後會自動生成宣告檔案
      "noImplicitAny": false,// 不允許隱式的any型別
      "strict":true,// 開啟所有嚴格的型別檢查
      "removeComments": true,// 刪除註釋 
      "moduleResolution": "node", //模組解析規則 classic和node的區別   https://segmentfault.com/a/1190000021421461
      //node模式下,非相對路徑模組 直接去node_modelus下查詢型別定義.ts 和補充宣告.d.ts
      //node模式下相對路徑查詢 逐級向上查詢 當在node_modules中沒有找到,就會去tsconfig.json同級目錄下的typings目錄下查詢.ts或 .d.ts補充型別宣告
      //例如我們這裡的.vue模組的  型別補充(.ts 檔案不認識.vue模組, 需要我們來定義.vue模組的型別)
      "esModuleInterop": true,//實現CommonJS和ES模組之間的互操作性。抹平兩種規範的差異
      "jsx": "preserve",//如果寫jsx了,保持jsx 的輸出,方便後續babel或者rollup做二次處理
      "noLib": false,
      "target": "es6", //編譯之後版本
      "sourceMap": true, //生成
      "lib": [ //包含在編譯中的庫
        "ESNext", "DOM"
      ],
      "allowSyntheticDefaultImports": true, //用來指定允許從沒有預設匯出的模組中預設匯入
    },
    "exclude": [ //排除
      "node_modules"
    ],

}
   

然後整合一下jest

yarn add @types/jest babel-jest jest jest-css-modules vue-jest @vue/test-utils -D -W

建議下載的依賴包版本,以我的專案的lock為準,因為這個是我校驗過得穩定版本,升級新版本可能會導致不相容。

這裡-D -W是安裝到工作區根目錄並且是開發依賴的意思,這裡jest是Facebook 給提供的單元測試庫官方推薦的,@vue/test-utils 它是Vue.js的官方測試實用程式庫,結合jest一起使用 配置最少,處理單檔案元件vue-jest,babel-jest 對測試程式碼做降級處理,jest-css-modules 用來忽略測試的css檔案。
然後我們在根目錄新建jest.config.js 單元測試的配置檔案:

module.exports = {
  "testMatch": ["**/__tests__/**/*.test.[jt]s?(x)"],  //從哪裡找測試檔案   tests下的
  "moduleFileExtensions": [ //測試模組倒入的字尾
    "js",
    "json",
    // 告訴 Jest 處理 `*.vue` 檔案
    "vue",
    "ts"
  ],
  "transform": {
    // 用 `vue-jest` 處理 `*.vue` 檔案
    ".*\\.(vue)$": "vue-jest",
    // 用 `babel-jest` 處理 js 降
    ".*\\.(js|ts)$": "babel-jest" 
  },
  "moduleNameMapper" : {
    "\\.(css|less|scss|sss|styl)$" : "<rootDir>/node_modules/jest-css-modules"
  }
}

然後再配置一下babel.config.js 我們測試用到了降級處理 ,後續打生產包時我們會通過babel環境變數utils,來使用對應配置轉換packages/utils裡一些工具函式。
babel.config.js:

module.exports = {
  // ATTENTION!!
  // Preset ordering is reversed, so `@babel/typescript` will called first
  // Do not put `@babel/typescript` before `@babel/env`, otherwise will cause a compile error
  // See https://github.com/babel/babel/issues/12066
  presets: [
    [
      '@babel/env', //babel轉換es6 語法外掛集合
    ],
    '@babel/typescript',  //ts
  ],
  plugins: [
    '@babel/transform-runtime', //墊片按需支援Promise,Set,Symbol等
    'lodash', 
    //一個簡單的轉換為精挑細選的Lodash模組,因此您不必這樣做。
  //與結合使用,可以生成更小的櫻桃精選版本! https://download.csdn.net/download/weixin_42129005/14985899
  //一般配合lodash-webpack-plugin做lodash按需載入
  ],
  env: {
    utils: { //這個babel環境變數是utils 覆蓋上述 的配置 這裡暫時不會用 先註釋掉
      presets: [
        [
          '@babel/env',
          {
            loose: true,//更快的速度轉換
            modules: false,//不轉換esm到cjs,支援搖樹  這個上面不配置 不然esm規範會導致jest 測試編譯不過
          },
        ],
      ],
      // plugins: [
      //   [
      //     'babel-plugin-module-resolver',
      //     {
      //       root: [''],
      //       alias: {
           
      //       },
      //     },
      //   ],
      // ],
    },
  },
}

然後我們在package.json中修改script命令 "test": "jest",
Jest 單元測試具體怎麼寫 可以根據自己的 需求去檢視官方文件。

plop生成元件模板

我們開頭的時候說過我們每個包的結構,長得都是一樣的,然後每生成一個元件包的話都要手動建立結構的話太麻煩了。

├── packages
|   ├── avatar
|   |   ├── _test_  //單元測試資料夾
|   |   ├─────  xxx.test.ts //測試檔案
|   |   ├── src //元件檔案資料夾
|   |   ├───── xxx.vue //元件檔案
|   |   ├── stories // 故事書除錯的js
|   |   ├───── xxx.stories.ts //元件檔案
|   |   ├── index.js //包入口
|   |   ├── LICENSE
|   |   ├── README.MD
|   |   ├── package.json
├── package.json

我們的基本結構是這樣,然後我們選擇plop生成模板,我們前面提到了plop主要用於建立專案中特定檔案型別的小工具。 我們把它安裝到專案中 yarn add plop -D -W

然後建立它的配置檔案plopfile.js

module.exports = plop => {
    plop.setGenerator('元件', {
      description: '自定義元件',
      prompts: [
        {
          type: 'input',
          name: 'name',
          message: '元件名稱',
          default: 'MyComponent'
        },
        {
          type: "confirm",
          message: "是否是組合元件",
          name: "combinationComponent",
          default:false
        }
      ],
      actions: [
        {
          type: 'add',
          path: 'packages/{{name}}/src/{{name}}.vue',
          templateFile: 'plop-template/component/src/component.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/__tests__/{{name}}.test.ts',
          templateFile: 'plop-template/component/__tests__/component.test.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/stories/{{name}}.stories.ts',
          templateFile: 'plop-template/component/stories/component.stories.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/index.ts',
          templateFile: 'plop-template/component/index.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/LICENSE',
          templateFile: 'plop-template/component/LICENSE'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/package.json',
          templateFile: 'plop-template/component/package.hbs'
        },
        {
          type: 'add',
          path: 'packages/{{name}}/README.md',
          templateFile: 'plop-template/component/README.hbs'
        },
        {
          type: 'add',
          path: 'packages/theme-chalk/src/{{name}}.scss',
          templateFile: 'plop-template/component/template.hbs'
        }
      ]
    })
  }

這裡通過命令列詢問互動 來生成 元件,然後我們來根據我們的配置檔案來新建 資料夾和模板。
image.png
模板的結構是這樣。
然後 我們來看下對應的模板 長什麼樣子,如下:
component.test.hbs

import { mount } from '@vue/test-utils'
import Element from '../src/{{name}}.vue'

describe('c-dhn-{{name}}', () => {
    test('{{name}}-text',() => {
        const wrapper = mount(Element)
        expect(wrapper.html()).toContain('div')
    })
})

component.hbs

<template>
  <div>
    <div @click="handleClick">tem</div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
interface I{{properCase name}}Props {

}
export default defineComponent({
  name: 'CDhn{{properCase name}}',
  setup(props: I{{properCase name}}Props, { emit }) {
    //methods
    const handleClick = evt => {
      alert('tem')
    }

    return {
      handleClick,
    }
  }
})
</script>

<style  lang="scss">
</style>

component.stories.hbs

import CDhn{{properCase name}} from '../'

export default {
  title: 'DHNUI/{{properCase name}}',
  component: CDhn{{properCase name}}
}


export const Index = () => ({
  setup() {
    return {  };
  },
  components: { CDhn{{properCase name}} },
  template: `
    <div>
       <c-dhn-{{name}} v-bind="args"></c-dhn-{{name}}>
    </div>
  `,
});

index.hbs

import CDhn{{properCase name}} from './src/{{name}}.vue'
import { App } from 'vue'
import type { SFCWithInstall } from '../utils/types'

CDhn{{properCase name}}.install = (app: App): void => {
  app.component(CDhn{{properCase name}}.name, CDhn{{properCase name}})
}

const _CDhn{{properCase name}}: SFCWithInstall<typeof CDhn{{properCase name}}> = CDhn{{properCase name}}

export default _CDhn{{properCase name}}

然後我們在package.json 中新增一個script命令 "plop": "plop"

執行之後就可以生產對應的檔案了,詳細的可以吧專案下載下載看一下。

到這裡我們測試,開發環境的storyBook和生產檔案的plop,已經完事了。
下面就該看如何打出生產環境的包了。

rollup構建打包

首先新建buildProject資料夾,我們的一些命令指令碼都會放在這裡。
這裡打包分為兩種,按需載入和全量包,這兩種方式有一些配置是一樣的,我們這裡寫一個公共的配置檔案rollup.comon.js


import json from '@rollup/plugin-json'
import vue from 'rollup-plugin-vue' //vue相關配置, css抽取到style標籤中  編譯模版
// import postcss from 'rollup-plugin-postcss'
import { terser } from 'rollup-plugin-terser'  //程式碼壓縮
import { nodeResolve } from '@rollup/plugin-node-resolve'
import alias from 'rollup-plugin-alias';
const { noElPrefixFile } = require('./common')
const pkg = require('../package.json')

const isDev = process.env.NODE_ENV !== 'production'
const deps = Object.keys(pkg.dependencies)
// 公共外掛配置
const plugins = [
    vue({
      // Dynamically inject css as a <style> tag 不插入
      css: false,
      // Explicitly convert template to render function
      compileTemplate: true,
      target: 'browser'
    }),
    json(), //json檔案轉換成es6模組
    nodeResolve(), //使用Node解析演算法定位模組,用於解析node_modules中的第三方模組
    //大多數包都是以CommonJS模組的形式出現的,如果有需要使用rollup-plugin-commonjs這個外掛將CommonJS模組轉換為 ES2015 供 Rollup 處理
    // postcss({//和css整合 支援  元件庫 不能使用  私有作用域css   不然提供給別人用時  覆蓋起來太費勁
    //   // 把 css 插入到 style 中
    //   // inject: true,
    //   // 把 css 放到和js同一目錄
    //   extract: true
    // }),
    alias({
      resolve: ['.ts', '.js','.vue','.tsx'],
      entries:{
        '@':'../packages'
      }
    })
  ]
  // 如果不是開發環境,開啟壓縮
isDev || plugins.push(terser())


function external(id) {
  return /^vue/.test(id)||
  noElPrefixFile.test(id)|| 
  deps.some(k => new RegExp('^' + k).test(id))
}
export {plugins,external};

這裡我們把utils下的工具函式(這個排除出去是因為 我們要用babel來做語法轉換)和vue庫,和我們所有使用的生產包 排除了出去,保證包的小體積.
common.js 目前只用到了utils

module.exports = {
    noElPrefixFile: /(utils|directives|hooks)/,
}
  
  1. 按需載入:工作區packages裡的每個元件都生成對應的js,方便後期配合babel外掛做按需引入
    rollup.tiny.js 這個檔案是針對工作區元件的配置
import {plugins,external} from './rollup.comon'
import path from 'path'
import typescript from 'rollup-plugin-typescript2'

const { getPackagesSync } =  require('@lerna/project')

module.exports = getPackagesSync().filter(pkg => pkg.name.includes('@c-dhn-act')).map(pkg => {
  const name =  pkg.name.split('@c-dhn-act/')[1] //包名稱
  return {
    input: path.resolve(__dirname, '../packages', name, 'index.ts'), //入口檔案,形成依賴圖的開始
    output: [ //出口  輸出
      {
        exports: 'auto',
        file: path.join(__dirname, '../dist/lib', name, 'index.js'), //esm版本
        format: 'es',
      },
    ],
    plugins: [

      ...plugins,
      typescript({
        tsconfigOverride: {
          compilerOptions: {
            declaration: false, //不生成型別宣告
          },
          'exclude': [
            'node_modules',
            '__tests__',
            'stories'
          ],
        },
        abortOnError: false,
      }),
    ],
    external
  }
})

這樣的話通過rollup 啟動打包時 會依次在dist下生成 我們對應的檔案,包名中包含@c-dhn-act 會認為是我們的小包 (注意點:這裡的name資料夾的名稱,是原始工作區package.json包的名字,例如avatar,在這我們生成.d.ts型別補充時也要用它,但是這裡和我們最後要產出的名字不符合, 後續打包完會進行一個改名操作)

注意:

這裡忽略了utils工具函式,這是因為 我們後續提供給其他人使用時,如果不忽略utils就會被打進來,然後如果其他人要是引用了不同的元件,但是不同的元件裡引用了相同的工具函式(函式被打包到了元件檔案中)。

拿webpack舉例這會就會有個問題,在webpack中使用的話,webpack針對模組 會有函式作用域的隔離,所以 即使是工具函式名稱相同也不會給覆蓋掉,這樣就會導致webpack打出來最終結果包變大。

而utils工具函式被忽略之後,不被打到 檔案中,而是通過import匯入的方式使用, 這樣在webpack使用的時候,就可以充分的利用它的模組快取機制,首先包的大小被減少了,其次因為用到了快取機制也會提升載入速度。

webpack虛擬碼
我這裡隨便手寫了一段webpack打包後的虛擬碼,方便理解,大家看下
utils是單獨模組時,大概是這樣的

(function(modules){
    var installedModules = {};
    function __webpack_require__(moduleId){
        //快取中有返回快取的模組
        //定義模組,寫入快取 module
        // {
        //     i: moduleId,
        //     l: false,
        //     exports: {}
        // };
        //載入執行modules中對應函式
        //修改模組狀態為已載入
        //返回函式的匯出  module.exports
    }
    /**一系列其他定義執行
     * xxx
     * xxx 
     * xxx
     */
    return __webpack_require__(__webpack_require__.s = "xxx/index.js");
})({
    "xxx/index.js":(function(module, __webpack_exports__, __webpack_require__){
        var button = __webpack_require__(/*! ./button.js */ "xxx/button.js");
        var input = __webpack_require__(/*! ./input.js */ "xxx/input.js");
    }),
    "xxx/button.js":(function(module, __webpack_exports__, __webpack_require__){
        var btn_is_array = __webpack_require__(/*! ./utils.js */ "xxx/utils.js");
        btn_is_array([])
        module.exports = 'button元件'
    }),
    "xxx/input.js":(function(module, __webpack_exports__, __webpack_require__){
        var ipt_is_array = __webpack_require__(/*! ./utils.js */ "xxx/utils.js");
        ipt_is_array([])
        module.exports = 'input元件'
    }),
    "xxx/utils.js":(function(module, __webpack_exports__, __webpack_require__){
        module.exports = function isArray(arr){
            //xxxxx函式處理,假設是特別長的函式處理
        } 
    })
})

第二種 utils裡的方法和元件打到了一起,webpack中使用時會是這樣的

(function(modules){
    var installedModules = {};
    function __webpack_require__(moduleId){
        //快取中有返回快取的模組
        //定義模組,寫入快取 module
        // {
        //     i: moduleId,
        //     l: false,
        //     exports: {}
        // };
        //載入執行modules中對應函式
        //修改模組狀態為已載入
        //返回函式的匯出  module.exports
    }
    /**一系列其他定義執行
     * xxx
     * xxx 
     * xxx
     */
    return __webpack_require__(__webpack_require__.s = "xxx/index.js");
})({
    "xxx/index.js":(function(module, __webpack_exports__, __webpack_require__){
        var button = __webpack_require__(/*! ./button.js */ "xxx/button.js");
        var input = __webpack_require__(/*! ./input.js */ "xxx/input.js");
    }),
    "xxx/button.js":(function(module, __webpack_exports__, __webpack_require__){
        function isArray(arr){
            //特別長的函式處理
        }
        isArray([])
        module.exports = 'button元件'
    }),
    "xxx/input.js":(function(module, __webpack_exports__, __webpack_require__){
        function isArray(arr){
            //特別長的函式處理
        }
        isArray([])
        module.exports = 'input元件'
    }),

})

我們可以對比一下,這樣可以驗證下,首先看看第二份webpack虛擬碼, 我們看傳入的引數,物件的key是檔案的路徑,值就是一個函式(未打包之前的模組,函式內容就是我們模組的程式碼),這裡有函式作用域做隔離,首先定義上就是重複定義,而且有隔離也不能複用,其次因為這樣的一堆重複的冗餘程式碼也會導致最後包變大(我們的元件庫導致的),最後就是每次載入模組都需要重新定義isArray函式,無法充分利用webpack的快取機制。

  1. 全部載入:生成一個包含所有元件的包,引入這個包相當於匯入了我們的所有元件
    首先在packages下新建c-dhn-act資料夾,這個資料夾裡放的是我們的所有元件的整合,裡面有兩個檔案。
    index.ts
import { App } from 'vue'
import CDhnDateCountdown from '../dateCountdown'
import CDhnAvatar from '../avatar'
import CDhnCol from '../col'
import CDhnContainer from '../container'
import CDhnRow from '../row'
import CDhnText from '../text'
import CDhnTabs from '../tabs'
import CDhnSwiper from '../swiper'
import CDhnTabPane from '../tabPane'
import CDhnInfiniteScroll from '../infiniteScroll'
import CDhnSeamlessScroll from '../seamlessScroll'
export {
  CDhnDateCountdown,
  CDhnAvatar,
  CDhnCol,
  CDhnContainer,
  CDhnRow,
  CDhnText,
  CDhnTabs,
  CDhnSwiper,
  CDhnTabPane,
  CDhnInfiniteScroll,
  CDhnSeamlessScroll
}
const components = [
  CDhnDateCountdown,
  CDhnAvatar,
  CDhnCol,
  CDhnContainer,
  CDhnRow,
  CDhnText,
  CDhnTabs,
  CDhnSwiper,
  CDhnTabPane,
  CDhnSeamlessScroll
]
const plugins = [CDhnInfiniteScroll]
const install = (app: App, opt: Object): void => {
  components.forEach(component => {
    app.component(component.name, component)
  })
  plugins.forEach((plugin) => {
    app.use(plugin)
  })
}
export default {
  version: 'independent',
  install
}

注意:

  1. 整包的ts檔案中export{ } 對元件的匯出不能省略,必須要匯出,不然最後dist/lib下生成的index.d.ts 的型別補充宣告中會缺少對 元件 的匯出,就會導致在ts專案中用的時候,推導不出你都匯出了哪些東西,我們在package.json的typings中指定了型別宣告檔案是lib/index.d.ts.
  2. babel-plugin-import 處理了模組js路徑的匯入,但是ts的型別推導 匯出檔案的推導 還是按原始寫的這個路徑來推導的,所以我們的index.d.ts中 必須還是要有對應的元件 型別匯出的,不然就會導致在ts專案中,ts找不到匯出的元件導致 編譯失敗。

還有package.json 這個檔案經過一些處理後會被copy到 我們的dist下

{
  "name": "c-dhn-act",
  "version": "1.0.16",
  "description": "c-dhn-act component",
  "author": "peng.luo@asiainnovations.com>",
  "main": "lib/index.cjs.js",
  "module": "lib/index.esm.js",
  "style": "lib/theme-chalk/index.css",
  "typings": "lib/index.d.ts",
  "keywords": [],
  "license": "MIT"
}

然後是我們的rollup全量包的配置

import {plugins,external} from './rollup.comon'
import path from 'path'
import typescript from 'rollup-plugin-typescript2'
const { noElPrefixFile } = require('./common')
const paths = function(id){
  if ((noElPrefixFile.test(id))) {
    let index = id.search(noElPrefixFile)
    return `./${id.slice(index)}`
  }
}
module.exports = [
    { 
        input: path.resolve(__dirname, '../packages/c-dhn-act/index.ts'),
        output: [
          {
            exports: 'auto', //預設匯出
            file: 'dist/lib/index.esm.js',
            format: 'esm',
            paths
          },
          {
            exports: 'named', //預設匯出
            file: 'dist/lib/index.cjs.js',
            format: 'cjs',
            paths
          }
        ],
        plugins: [
     
          ...plugins,
          typescript({
            tsconfigOverride: {
              'include': [
                'packages/**/*',
                'typings/vue-shim.d.ts',
              ],
              'exclude': [
                'node_modules',
                'packages/**/__tests__/*',
                'packages/**/stories/*'
              ],
            },
            abortOnError: false,
           
          }),
        ],
        external
    } 
]

然後這裡需要對utils的匯入做下處理,因為實質這個整合就是把對應元件的打包結果拿到了這個檔案中(不同的元件引入相同的包,rollup會給我們處理不會重複匯入),而我們又配置忽略 utils下工具函式,所以rollup只給處理了路徑,而不會把內容打進來, 但是 因為是直接拿的元件的打包結果,基於它的目錄處理的,路徑給處理的稍微有點問題,所以配置了path 我們轉換了一下 (這裡不使用路徑別名是因為要配置三份,ts的,rollup,storybook,我們這個庫路徑相對簡單,所以轉換的時候處理一下就可以了)。

這裡可以關注一下ts的型別宣告,是在all.js全量配置中生成的,不是單獨一個一個生成的。
在這裡設定下要編譯哪些檔案生成補充型別宣告include,我們把packages下的所有包都生成型別宣告,vue-shim.d.ts裡放的是.vue模組的 型別宣告,不然打包過程中 不認識vue檔案會報錯。
這裡輸出型別宣告時輸出的資料夾 會和 packages工作區裡的對應,所以我們上面在打但個包的時候rollup.tiny.js 裡資料夾的路徑和這個是對應的(因為都是通過plop建立的),這樣就會把型別的補充宣告和 我們前面輸出到dist中的單個包放在一塊。
但是這會輸出的全域性的.d.ts補充宣告在c-dhn-act裡,而且路徑也有問題 後續需要我們再處理下。

打包utils

前面的打包操作,沒有打包utils裡的工具函式。
我們的工具函式都在packages工具區的utils下,這裡面可能會用到一些es的新語法,所以它下面的方法最後生產時是需要編譯一下的,我們前面ts和jest部分已經把babel配置貼出來了。這裡就不重複貼配置了。
然後就是對應package.json裡的打包命令

"build:utils": "cross-env BABEL_ENV=utils babel packages/utils --extensions .ts --out-dir dist/lib/utils",

使用extensions 標識下副檔名ts 然後指定下輸出目錄。

名稱修改

buildProject下新建gen-type.js 檔案,
該檔案主要作用

  1. global.d.ts 全域性的ts型別定義 全域性的ts補充型別 挪動到dist下 這裡放的是.vue模組的補充型別宣告 移動過去是為了防止 其他人ts使用的時候 不識別.vue模組
  2. 處理單個包的資料夾的名字 以及c-dhn-act中的全部的型別宣告
const fs = require('fs')
const path = require('path')
const pkg = require('../dist/package.json')
const { noElPrefixFile } = require('./common')
const outsideImport = /import .* from '..\/(.*)/g
// global.d.ts  全域性的ts型別定義
fs.copyFileSync(
    path.resolve(__dirname, '../typings/vue-shim.d.ts'),
    path.resolve(__dirname, '../dist/lib/c-dhn-act.d.ts'),
)

//設定一下版本號,不通過c-dhn-act的index.ts裡匯入json寫入了  因為它是整體匯出匯入  所以會有一些其他冗餘資訊 不是js模組 無法搖樹搖掉所以在這裡寫入版本
const getIndexUrl = url =>  path.resolve(__dirname, '../dist/lib', url)
const updataIndexContent = (indexUrl,content) => fs.writeFileSync(getIndexUrl(indexUrl), content.replace('independent',pkg.version))

['index.esm.js','index.cjs.js'].map(fileName => ({
  fileName,
  content:fs.readFileSync(getIndexUrl(fileName)).toString()
})).reduce((callback,item)=>{
  callback(item.fileName,item.content)
  return callback;
},updataIndexContent)


// component 這個方法主要是 針對打包之後 包做重新命名處理 以及處理typings
const libDirPath = path.resolve(__dirname, '../dist/lib')
fs.readdirSync(libDirPath).forEach(comp => { //獲取所有檔案的名稱
  if (!noElPrefixFile.test(comp)) { //如果不是特殊的資料夾,正則比檔案資訊查詢快 在前面
    if (fs.lstatSync(path.resolve(libDirPath, comp)).isDirectory()) { //是資料夾
        if(comp === 'c-dhn-act'){ //如果是我們的整包  裡面放的是.d.ts  補充型別宣告
            fs.renameSync(
                // 把型別補充宣告檔案 剪下出來 和package.json 指定的 typings 對應
                path.resolve(__dirname, '../dist/lib', comp, 'index.d.ts'),
                path.resolve(__dirname, '../dist/lib/index.d.ts'),
            ) 
            fs.rmdirSync(path.resolve(__dirname, '../dist/lib/c-dhn-act'), { recursive: true })
            //移動完成 原來的檔案就沒用了刪除掉
              
            // re-import 移過去之後 檔案裡面引用路徑不對了 需要調整一下 原來引入的是button  而我們最後輸出包名是 c-dhn-button 所以要修正一下
            const imp = fs.readFileSync(path.resolve(__dirname, '../dist/lib', 'index.d.ts')).toString()
            if(outsideImport.test(imp)) {
                const newImp = imp.replace(outsideImport, (i, c) => {
                  //i匹配到的字串 import CDhnInput from '../input'
                  //c正則中子規則的匹配 inout
                  return i.replace(`../${c}`, `./c-dhn-${c.replace(/([A-Z])/g,"-$1").toLowerCase()}`) //修正引入包名
                })
               
                fs.writeFileSync(path.resolve(__dirname, '../dist/lib', 'index.d.ts'), newImp)
            }
            return;
        }
        //給我們的包改下名 方便後續的按需載入引入  
        const newCompName = `c-dhn-${comp.replace(/([A-Z])/g,"-$1").toLowerCase()}`
        fs.renameSync(
          path.resolve(libDirPath, comp),
          path.resolve(libDirPath, newCompName)
        ) 
    }
  }
})

修改合成最後dist中的package.json

dist資料夾裡放的就是我們最終釋出的包,它裡面的package.json需要我們修改一下。

新建gen-v.js

const inquirer = require('inquirer')
const cp = require('child_process')
const path = require('path')
const fs = require('fs')

const jsonFormat = require('json-format') //美化並轉換js
const promptList = [
  {
    type: 'list',
    message: '選擇升級版本:',
    name: 'version',
    default: 'patch', // 預設值
    choices: ['beta', 'patch', 'minor', 'major']
  }
]
const updataPkg = function () {
  const pkg = require('../packages/c-dhn-act/package.json')
  const { dependencies, peerDependencies } = require('../package.json')
  fs.writeFileSync(
    path.resolve(__dirname, '../dist', 'package.json'),
    jsonFormat({ ...pkg, dependencies, peerDependencies })
  )
}
inquirer.prompt(promptList).then(answers => {
  let pubVersion = answers.version
  if (pubVersion === 'beta') {
    const { version } = require('../packages/c-dhn-act/package.json')
    let index = version.indexOf('beta')
    if (index != -1) {
      const vArr = version.split('.')
      vArr[vArr.length - 1] = parseInt(vArr[vArr.length - 1]) + 1
      pubVersion = vArr.join('.')
    } else {
      pubVersion = `${version}-beta.0`
    }
  }
  cp.exec(
    `npm version ${pubVersion}`,
    { cwd: path.resolve(__dirname, '../packages/c-dhn-act') },
    function (error, stdout, stderr) {
      if (error) {
        console.log(error)
      }
      updataPkg()
    }
  )
})

這個檔案主要是用來更新版本號,並且以packages下的c-dhn-act資料夾下package.json檔案為主,然後ge把專案根目錄的dependencies 依賴拿過來合併,生成新的package.json放到dist下。
因為我們打包的時候把他們忽略了,但是最後提供給別人用的時候還是需要用的,所以最後在我們釋出npm包的json上還是要寫進去的,npm包的dependencies依賴當在專案中執行npm install 的時候會自動下載的,devDependencies的不會。

scss打包

scss的生產打包我們選擇用gulp,packages/theme-chalk/src放的是對應模組的scss檔案。
image.png
我們新增gulpfile.js,gulp的配置檔案。

'use strict'
const { series, src, dest } = require('gulp')
//暫時不用series  目前就一個任務
const sass = require('gulp-dart-sass')
const autoprefixer = require('gulp-autoprefixer')
const cssmin = require('gulp-cssmin')
const rename = require('gulp-rename')

const noElPrefixFile = /(index|base|display)/   //改名 如果不是這幾個加上c-dhn

function compile(){//編譯器
    return src('./src/*.scss') //讀取所有scss 建立可讀流
    .pipe(sass.sync()) //管道 插入處理函式 同步編譯 sass檔案 
    .pipe(autoprefixer({ cascade: false })) //不啟動美化 預設美化屬性
    .pipe(cssmin()) //壓縮程式碼
    .pipe(rename(function (path) {
        if(!noElPrefixFile.test(path.basename)) { //如果不是這些  給加字首
          path.basename = `c-dhn-${path.basename}`
        }
      }))
    .pipe(dest('./lib')) //建立寫入流 到管道  寫入到
}


exports.build = compile

到這裡可以看我最開始貼的package.json檔案。裡面的script命令就大都包含了。
scripts列表

  1. test 用來開啟jest單元測試
  2. storybookPre 用來檢視storyBook打包出來的靜態資源預覽
  3. storybook 用來開啟開發環境元件文件檢視
  4. build-storybook 用來生產對應的靜態資源文件,方便部署
  5. buildTiny:prod 打包按需載入包 壓縮程式碼版本
  6. buildTiny:dev 不壓縮的版本
  7. plop 生產plop模板
  8. clean:lib 清空dist/lib資料夾
  9. build:theme gulp構建scss樣式
  10. build:utils babel打包工具函式
  11. buildAll:prod 打包全量包 壓縮程式碼
  12. buildAll:dev 不壓縮程式碼
  13. build:type 修改打包出來的檔名和內部路徑,和ts補充型別宣告的位置
  14. build:v 修改要釋出的新的版本號和更新生產依賴。
  15. build:dev 完整的組合好的打包命令(常用的,不壓縮程式碼)
  16. build:prod 壓縮程式碼

這裡 yarn build:dev就是 我們的打包不壓縮的測試,方便我們檢視打包之後的內容結果是否和我們預期相符。
Yarn build:prod 就是正式釋出時 所執行的打包命令。

打包的主要思想順序是

  1. 修改版本,生成覆蓋package.json
  2. 清空資料夾
  3. packages工作區的元件逐個打包(按需載入)
  4. 打全量包(全部載入)
  5. 用babel 編譯 utils工具函式
  6. 最後修改dist/lib下的資料夾名稱,和.d.ts 的型別補充,和部分檔案內容修改。
  7. 最後構建一下scss樣式

最後是使用:

這樣我們配合 babel-plugin-import 這個外掛。

{
  plugins: [
    [
      'import',
      {
        libraryName: 'c-dhn-act',
        customStyleName: (name) => {
          return `c-dhn-act/lib/theme-chalk/${name}.css`
        }
      }
    ]
  ]
}

在碰到
import { CDhnAvatar } from "c-dhn-act"
這種情況時就會被解析成

import CDhnAvatar from "c-dhn-act/lib/c-dhn-avatar";

這種形式,這樣就直接從我們打的小的元件包裡去獲取,從而在載入層面就形成了按需載入。
不瞭解babel-plugin-import的可以,查下這個外掛的用法。

最後就是我們的程式碼地址了,有興趣的可以把程式碼下載下來跑一跑看看。

相關文章