開始搭建之前要明確需要支援什麼能力,再逐個考慮要如何實現。本專案搭建時計劃需要支援以下功能:
- 支援元件測試/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/a
中import B from 'xxx/src/utils/b'
,打包之後,會到 'xxx/lib/utils/'
下去找對應的資源
{
plugins: [
['module-resolver', {
root: ['xxx'],
alias: {
'xxx/src': 'xxx/lib'
}
}]
]
}
不需要被打包的依賴
本專案中會使用到ant-design-vue
和vue
庫,但是都不需要被打包,這應該是由使用者自己引入的。
webpack.externals
在上面有用到過,在打包時可以排除依賴
peerDependencies
可以保證所需要的依賴被安裝,詳見文件
這兩個配合就可以實現不打包ant-design-vue
和vue
不被打包,也不會影響元件庫的執行
實現
綜上,簡單總結下,我們在打包時需要做的事情
- 全部引入和按需載入需要分開打包
- 支援全部引入需要,以
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.externals
、 loader
、 plugins
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'
},
}
按需引入
以下是按需的入口和輸出,入口是解析到所有的元件路徑,output
的 libraryTarget
也不同,因為按需載入沒法支援瀏覽器載入,所以不需要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",
&&
可以串聯執行指令碼,前一個命令執行完才會執行下一個指令碼,可以將一組有前後關係的指令碼組合在一起