元件庫搭建總結

Shapeying發表於2021-04-14

開始搭建之前要明確需要支援什麼能力,再逐個考慮要如何實現。本專案搭建時計劃需要支援以下功能:

  • 支援元件測試/demo
  • 支援不同的引入方式 : 全部引入 / 按需載入
  • 支援主題定製
  • 支援文件展示

元件測試/demo

本專案是 vue 元件庫,元件開發過程中的測試可以直接使用 vue-cli 腳手架,在專案增加了/demos目錄,用來在開發過程中除錯元件和開發完成後存放各個元件的例子. 只需要修改在vue.config.js中入口路徑,即可執行 demos

  index: {
        entry: 'demos/main.ts',
  }
  "serve": "cross-env BABEL_ENV=dev vue-cli-service serve",

執行時傳入了一個 babel 變數 是用來區分 babel 配置的,後面會有詳細說明。

打包

js 打包暫時用的還是 webpack, 樣式處理使用的是 gulp, 考慮支援兩種引入方式,全部引入按需載入,兩種場景會有不同的打包需求。

全部引入

支援全部引入,需要有一個入口檔案,暴露並可以註冊所有的元件。 /src/index.ts 就是全部元件的入口,它匯出了所有元件,還有一個install函式可以遍歷註冊所有元件(為什麼是 install?詳見 vue 外掛 )。還需要加一些對script引入情況的處理 —— 直接註冊所有元件。

打包的時候需要以入口檔案為打包入口,全部元件一起打包

按需載入

顧名思義,使用者可以只載入使用到的元件的 js 及 css,且不論他通過何種方式來按需引入,就元件庫而言,我們需要在打包時將各個元件的程式碼分開打包,這樣是他能夠按需引入的前提。這樣的話,我們需要以每個元件作為入口來分別打包。

按需載入的實現可以簡單的使用require來實現,雖然有點粗暴,需要使用者require對應的元件 js 和 css。檢視了一些資料和開源庫的做法,發現了更人性化的做法,使用 babel 外掛輔助,可以幫我們把import語法轉換成require語法,這樣使用者在寫法上會更加簡單。

比如babel-plugin-component外掛,可以檢視文件,會幫我們進行語法轉換

import { SectionWrapper } from "xxx";

// 轉換成
require("xxx/lib/section-wrapper");
require("xxx/lib/css/section-wrapper.css");

那我們需要在按需載入打包時,按照一定的目錄結構來放置元件的 js 和 css 檔案,方便使用者用 babel 外掛來進行按需載入

樣式打包

同樣的,全部引入的樣式打包和按需載入的樣式打包也有所不同。

全部引入時,所有的樣式檔案(元件樣式,公共樣式)打包成一份檔案,使用時引入一次即可。

按需載入時,樣式檔案需要分元件來打包,每個元件需要生產一份樣式檔案,使用時才能分開載入,只引入需要的資源,因為要使用 babel 外掛,所以還要控制樣式檔案的位置。

所以樣式在編寫時,就需要公共/元件分開檔案,這樣方便後面打包處理,考慮目錄結構如下:

│  └─ themes                                                   
│     ├─ src               // 公共樣式                                    
│     │  ├─ base.less                                          
│     │  ├─ mixins.less                                        
│     │  └─ variable.less                                      
│     ├─ form-factory.less // 元件樣式                                    
│     ├─ index.less        // 所有樣式入口

themes/index.less會引入所有元件的樣式及公共樣式
themes/components-x.less 只包含元件的樣式

公共資源

元件之間公用的方法/指令/樣式,當然希望能在使用時只載入一份。

公共樣式

全部引入時沒有問題,所有的樣式檔案都會一起引入。

按需載入時,不能在元件樣式檔案中都打包進一份公共樣式,這樣引入多個元件時,重複的樣式太多。考慮把公共樣式單獨打包出來,按需引入的時候,單獨引入一次公共樣式檔案。這次引入也可以通過babel-plugin-component外掛幫我們實現,詳見文件中的相關配置。

公共 JS

有些js資源(方法/指令)是多個元件都會用到的,不能直接打包到元件中,否則按需載入多個元件時會出現多份重複的資源。所以考慮讓元件不打包這些資源,要用到 webpack.externals 配置,webpack.externals 可以從輸出的 bundle 中排除依賴,在執行時會從使用者環境中獲取,詳見文件

這裡需要考慮的時,如何辨別哪些是公共js,以及在使用者環境中要去哪裡獲取? , 這裡是參考element-ui的做法

公共JS通過目錄來約定,src/utils/directives下為公共指令,src/utils/tools下為公共方法,同樣的,引入公共資源的時候也約定好方式,按照配置的webpack.resolve.alias, 這樣在可以方便配置 webpack.externals

  // webpack.resolve.alias
  {
    alias: {
      'xxx': resolve('.')
    }
  }

  // 引入資源通過  xxx/src/...
  import ClickOutside from 'xxx/src/utils/directives/clickOutside'

  // 配置`webpack.externals`
  const directivesList = fs.readdirSync(resolve('/src/utils/directives'))
  directivesList.forEach(function(file) {
    const filename = path.basename(file, '.ts')
    externals[`xxx/src/utils/directives/${filename}`] = `yyy/lib/utils/directives/${filename}`
  })

至於要如何在使用者環境中獲取,在打包時會吧utils中資源也一起打包釋出,所以通過 釋出的包名(package.json 中的 name)來獲取,也就是上面示例程式碼中的yyy

下一步就是要考慮如何處理utils中的檔案?,utils中的資源也可能會相互應用,比如方法A中使用了方法B,也需要在處理的時候,要避免相互引入,也要每個單獨處理(babel)成單個檔案,因為使用者會在使用者環境中尋找單個的資源。

直接使用bable命令列來處理會更加方便

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

會對每個檔案進行babel相關的處理,生成的檔案會在 lib/utils中,和上面的webpack.externals配置時對應的

另外還要使用babel-plugin-module-resolver 外掛,檢視 文件,這裡的作用是讓打包之後到新的地方去找檔案。比如在 utils/tools/aimport B from 'xxx/src/utils/b',打包之後,會到 'xxx/lib/utils/' 下去找對應的資源

{
  plugins: [
    ['module-resolver', {
      root: ['xxx'],
      alias: {
        'xxx/src': 'xxx/lib'
      }
    }]
  ]
}

不需要被打包的依賴

本專案中會使用到ant-design-vuevue庫,但是都不需要被打包,這應該是由使用者自己引入的。

webpack.externals 在上面有用到過,在打包時可以排除依賴

peerDependencies 可以保證所需要的依賴被安裝,詳見文件

這兩個配合就可以實現不打包ant-design-vuevue不被打包,也不會影響元件庫的執行

實現

綜上,簡單總結下,我們在打包時需要做的事情

  • 全部引入和按需載入需要分開打包
  • 支援全部引入需要,以src/index.ts為入口進行打包,並且需要打包出一份包含所有樣式的 css 檔案
  • 支援按需載入需要,以每個元件為入口打包出獨立的檔案,並且需要單獨打包出每個元件的樣式檔案和一份公共樣式檔案。之後需要按照對應的目錄結構放好檔案,方便配合 babel 外掛實現按需載入
  • 排除不需要被打包的依賴

需要兩份不同的打包,分別對應全部引入和按需載入的打包

    "build:main": "cross-env BABEL_ENV=build webpack --config build/webpack.main.config.js",
    "build:components": "cross-env BABEL_ENV=build webpack --config build/webpack.components.config.js",

以下是兩種打包方式都需要做的事情

配置 webpack.externalsloaderplugins

  function getUtilsExternals() {
    const externals = {}

    const directivesList = fs.readdirSync(resolve('/src/utils/directives'))
    directivesList.forEach(function(file) {
      const filename = path.basename(file, '.ts')
      externals[`xxx/src/utils/directives/${filename}`] = `xxx/lib/utils/directives/${filename}`
    })
    const toolsList = fs.readdirSync(resolve('src/utils/tools'))
    toolsList.forEach(function(file) {
      const filename = path.basename(file, '.ts')
      externals[`xxx/src/utils/tools/${filename}`] = `xxx/lib/utils/tools/${filename}`
    })

    return externals
  }


  // webpack配置
  {
    mode: 'production',
    devtool: false,
    externals: {
      ...getUtilsExternals(),
      vue: {
        root: 'Vue',
        commonjs: 'vue',
        commonjs2: 'vue',
        amd: 'vue'
      },
      'ant-design-vue': 'ant-design-vue'
    },
    module:{
      // 相關loader
      rules: [
        {
          test: /\.vue$/,
          loader: 'vue-loader',
          options: {
            loaders: {
              ts: 'ts-loader',
              tsx: 'babel-loader!ts-loader'
            }
          }
        },
        {
          test: /\.tsx?$/,
          exclude: /node_modules/,
          use: [
            'babel-loader',
            {
              loader: 'ts-loader',
              options: { appendTsxSuffixTo: [/\.vue$/] }
            }
          ]
        }
      ]
    },
    plugins: [
      new ProgressBarPlugin(),
      new VueLoaderPlugin() // vue loader的相關外掛
    ]
  }

全部引入

以下是全部引入的入口和輸出,這裡打包輸出到lib目錄下,lib目錄是打包後的目錄。

這裡需要注意的是同時要配置package.json中的相關欄位(main,module),這樣釋出之後,使用者才知道入口檔案是哪個,詳見 文件

這裡還需要注意output.libraryTarget的配置,要根據需求來配置對應的值,詳見文件

{
  entry: {
  index: resolve('src/index.ts')
  },
  output: {
    path: resolve('lib'),
    filename: '[name].js',
    libraryTarget: 'umd',
    libraryExport: 'default',
    umdNamedDefine: true,
    library: 'xxx'
  },
}

按需引入

以下是按需的入口和輸出,入口是解析到所有的元件路徑,outputlibraryTarget 也不同,因為按需載入沒法支援瀏覽器載入,所以不需要umd模式

// 解析路徑函式
function getComponentEntries(path) {
  const files = fs.readdirSync(resolve(path))
  const componentEntries = files.reduce((ret, item) => {
    if (item === 'themes') {
      return ret
    }
    const itemPath = join(path, item)
    const isDir = fs.statSync(itemPath).isDirectory()
    if (isDir) {
      ret[item] = resolve(join(itemPath, 'index.ts'))
    } else {
      const [name] = item.split('.')
      ret[name] = resolve(`${itemPath}`)
    }
    return ret
  }, {})
  return componentEntries
}
// webpack配置
{
  entry: {
    // 解析每個元件的入口
    ...getComponentEntries('components')
  },
  output: {
    path: resolve('lib'),
    filename: '[name]/index.js',
    libraryTarget: 'commonjs2',
    chunkFilename: '[id].js'
  },
}

樣式處理

使用gulp處理樣式,對入口樣式(所有樣式)/ 元件樣式 / 公共樣式 進行相關處理(less -> css, 字首,壓縮等等),然後放在對應的目錄下

// ./gulpfile.js
function compileComponents() {
  return src('./components/themes/*.less') // 入口樣式,元件樣式
    .pipe(less())
    .pipe(autoprefixer({
      cascade: false
    }))
    .pipe(cssmin())
    .pipe(dest('./lib/css'))
}

function compileBaseClass() {
  return src('./components/themes/src/base.less') // 公共樣式
    .pipe(less())
    .pipe(autoprefixer({
      cascade: false
    }))
    .pipe(cssmin())
    .pipe(dest('./lib/css'))
}

主題定製

實現主題定製,主要的思路是樣式變數覆蓋,比如本專案中使用的是less來書寫樣式,而在less中,同名的變數,後面的會覆蓋前面的,詳見 文件

作為元件庫,支援主題定製,需要做兩點:

  • 會把可能需要變化的樣式定義成樣式變數,並告訴使用者相關的變數名
  • 提供.less型別的樣式引入方式

專案中的樣式本就是通過.less格式編寫的,且定義了部分可修改的變數名 components\themes\src\variable.less,需要提供引入less樣式的方式即可,要將將less樣式整體複製到lib

// ./gulpfile.js
function copyLess() {
  return src('./components/themes/**')
    .pipe(cssmin())
    .pipe(dest('./lib/less'))
}

需要自定義樣式時,需要使用者,引入less樣式檔案。如果此時需要按需引入的話,要require對應的元件js檔案,不能通過babel外掛來實現,因為後者會引入預設的元件樣式,和less樣式相互影響且重複。

文件化

考慮能有一個入口網站,能包含元件庫的所有示例和使用文件。

本專案使用了 storybook 來實現,詳見 文件

所有的內容都在.storybook/ 目錄中,需要為每一個元件都編寫一個對應的 story

型別檔案

本專案本身是採用ts編寫的,本來考慮採用取巧的方式,通過 typescript編譯器 自動生成型別檔案的

獨立有一份tsconfig.json,配置了需要生成型別檔案

    "declaration": true,
    "declarationDir": "../types",
    "outDir": "../temp",

"types": "rimraf types && tsc -p build && rimraf temp",執行時會把.ts編譯為.js,隨便生成型別檔案,然後刪掉生成的js檔案即可,這樣就只會留下.d.ts型別檔案。

但是這種方式生成的型別檔案有點亂,有的還需要自己調整,所以就還是手寫。除了檢視 typescript官網外,還可以檢視 文件

目錄結構

最終,整體的目錄結構是

xxx                             
├─ build                                 webpack配置                                                       
│  ├─ config.js                                                
│  ├─ tsconfig.json                                            
│  ├─ utils.js                                                 
│  ├─ webpack.components.config.js                             
│  └─ webpack.main.config.js                                   
├─ components                            元件原始碼                                       
│  ├─ form-factory                                          
│  │  ├─ formFactory.tsx                                       
│  │  └─ index.ts                                                                               
│  └─ themes                             元件樣式                     
│     ├─ src                                                   
│     │  ├─ base.less                                          
│     │  ├─ mixins.less                                        
│     │  └─ variable.less                                      
│     ├─ form-factory.less                                     
│     ├─ index.less                                                                            
├─ demos                                  除錯檔案                                                                  
├─ dist                                   storybook打包目錄                                                  
├─ lib                                    元件庫打包目錄                   
│  ├─ css                                                      
│  │  ├─ base.css                                              
│  │  ├─ form-factory.css                                      
│  │  ├─ index.css                                                                              
│  ├─ form-factory                                             
│  │  └─ index.js                                              
│  ├─ less                                                     
│  │  ├─ src                                                   
│  │  │  ├─ base.less                                          
│  │  │  ├─ mixins.less                                        
│  │  │  └─ variable.less                                      
│  │  ├─ form-factory.less                                     
│  │  ├─ index.less                                                                       
│  ├─ section-wrapper                                          
│  │  └─ index.js                                              
│  └─ index.js                                                 
├─ public                                                                                                  
├─ src
│  ├─ utils                               工具函式                    
│  │  ├─ directives                                         
│  │  ├─ tools                                                                                                  
│  ├─ global.d.ts                                              
│  ├─ index.ts                            元件庫入口                          
│  └─ shims-tsx.d.ts                                           
├─ tests                                  測試檔案                                                       
├─ types                                  型別檔案                                                              
├─ babel.config.js                        babel配置                   
├─ gulpfile.js                            gulp配置                     
├─ jest.config.js                         jest配置                                                            
├─ package.json                                                
├─ readme.md                                                   
├─ tsconfig.json                          typescript配置                    
└─ vue.config.js                          vue-cli配置                    

釋出

釋出時需要注意的是package.json的相關配置,除了上面提到的main,module外,還需要配置以下欄位

{
    "name": "xxx",
    "version": "x.x.x",
    "typings": "types/index.d.ts", // 型別檔案 入口路徑
    "files": [ // 釋出時需要上傳的檔案
      "lib",
      "types",
      "hcdm-styles"
    ],
    "publishConfig": { //釋出地址
      "registry": "http://xxx.xx.x/"
    }
}

其他

環境變數的使用

通過 cross-env 在執行指令碼時可以傳入變數來做一些事情,本專案用到了兩處

  • 通過 BABEL_ENV 來讓 babel.config.js 配置來區分環境;vue-cli中提供的@vue/cli-plugin-babel/preset裡面配置的東西太多了,導致元件庫打包出來體積增大,所以只在變數為dev的時候使用,build的時候使用更簡單的必要配置,如下:
module.exports = {
  env: {
    dev: {
      presets: [
        '@vue/cli-plugin-babel/preset'
      ]
    },
    build: {
      presets: [
        [
          '@babel/preset-env',
          {
            loose: true,
            modules: false
          }
        ],
        [
          '@vue/babel-preset-jsx'
        ]
      ]
    },
    utils: {
      presets: [
        ['@babel/preset-typescript']
      ],
      plugins: [
        ['module-resolver', {
          root: ['xxx'],
          alias: {
            'xxx/src': 'yyy/lib'
          }
        }]
      ]
    }
  }
}
  • 通過 BUILD_TYPE 來控制是否需要引入打包分析外掛
if (process.env.BUILD_TYPE !== 'build') {
  configs.plugins.push(
    new BundleAnalyzerPlugin({
      analyzerPort: 8123
    })
  )
}

&&串聯執行指令碼

"build:lib": "npm run clean &&cross-env BUILD_TYPE=build npm run build:main && cross-env BUILD_TYPE=build npm run build:components && gulp",

&& 可以串聯執行指令碼,前一個命令執行完才會執行下一個指令碼,可以將一組有前後關係的指令碼組合在一起

相關文章