開始搭建之前要明確需要支持什么能力,再逐個考慮要如何實現。本項目搭建時計划需要支持以下功能:
- 支持組件測試/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",
&& 可以串聯執行腳本,前一個命令執行完才會執行下一個腳本,可以將一組有前后關系的腳本組合在一起
