如何用上webpack/gulp/rollup,搭建一个基于Vue的UI库脚手架

3,850 阅读5分钟

目标:搭建一个基于vue的ui脚手架,库的加载方式支持script标签模块化加载,组件引入方式支持按需加载和全量加载。 脚手架链接

目录、文件约定

完整的一个组件库脚手架不但应该有合理的组件存放目录,也应该拥有组件demo展示页方便组件的开发和展示,所以需要我们提供两种webpack配置,一个用于组件打包,一个用于组件单页应用,然后,慢慢加上gulp/rollup。

出于开发调试方便的考虑,现在直接把包打进了node_modules中去,正式使用时按需修改build/const.jsLIB_ROOT的值。

正确的调试顺序:

  1. npm install
  2. npm run build:lib 这个命令会先把目录清空,所以放在build:es前
  3. npm run build:es
  4. npm run dev

如何使用webpack打包类库

平时使用webpack打包应用程序较多,如何使用它来打包工具库ui库

秘密藏在ouput配置项里,webpack既可以打包一般的网站应用,也可以打包出支持多种环境下使用的类库。libraryTarget声明打包出来的模块类型,library跟模块导出命名相关。

简单来说,我们平时打包应用就是生成多个提供给script标签引用的文件。配置了上面两个字段,生成出来的文件就可以被script/require来使用了。而其他配置大概一致,也是按你的文件类型,生成物要求添加对应的loader、plugin。譬如需要vue-loader处理.vue文件,使用了vue-jsx需要安装@vue/babel-preset-jsx相关插件支持,babel-loader等。

output: {
  path: path.resolve(__dirname, '../', LIB_DIR),
  // filename: '[name]/index.js',
  library: 'wind-ui-vue',
  libraryTarget: 'umd',
},

组件库组件的开发模式,书写模板及全量打包

组件库的组件如何被使用

平时做应用,我们一个组件就是一个.vue文件,需要的时候就import进来。但是开发组件库是不太一样的,因为组件库环境没有Vue变量,需要开发者提供,所以需要做一层包装去获取实际开发环境才提供的Vue变量,每个组件都被自己的index.js包裹一层。使用打包器时,这里用到了Vue.use的功能,它会调用对象参数提供的install方法。只要我们在方法里添加了注册函数,就自动完成组件的全局注册了。

        import Button from './Button.vue'
        
        Button.install = Vue => {
        
          Vue.component(Button.name, Button)
        }
        
        if (typeof window != undefined && window.Vue) {
          Button.install(Vue)
        }
        
        export default Button
        import Vue from 'vue'
        import { Button } from 'wind-ui-vue'
        Vue.use(Button)

如何全量引入组件

既然每个组件有自己的注册文件了,那么只要再搞一个入口文件,把每个组件都引入进去。

    import Button from './components/button/index'
    import './components/button/index.scss'
    
    import Card from './components/card/index'
    import './components/card/index.scss'
    
    const install = (Vue) => {
      Vue.use(Button)
      Vue.use(Card)
    }
    
    if (typeof window != undefined && window.Vue) {
      install(Vue)
    }
    
    export {
      Button,
      Card
    }
    
    export default {
      install
    }

全量打包来了

全量组件的入口文件已经写好,这时把它做成一个webpack入口,那么就能把所有的组件打包成一个大包了。

组件如何打包以支持按需加载 —— webpack 多入口

组件库越来越大,如何让每个组件单独打包,一个组件一个包

按需加载的前提是什么?

是全部组件不能打包到一起。webpack是一个入口对应一个出口,所以我们需要为每个组件生成一个入口。

主要是用node的fs模块读写文件操作居多,把所有组件的入口文件index.js找出来,做成webpack入口,再配置对应的出口路径,我们设计成源码和打包代码的目录结构大致相同。

const getFiles = function (dirs, fileReg) {

  if (!Array.isArray(dirs)) {
    dirs = [ dirs ]
  }

  return dirs.reduce((arr, dir) => {

    let files = fs.readdirSync(dir)
    if (!files.length) return [] 
  
    files = files.reduce((arr, file) => {
      let res = []
      const filePath = path.join(dir, file)
  
      fileReg.test(file) && res.push(filePath)
  
      // if is directory
      if (fs.statSync(filePath).isDirectory()) {
        res = res.concat(getFiles(filePath, fileReg))
      }
  
      return arr.concat(res)
  
    }, [])
  
    return arr.concat(files)

  }, [])
}

const entries =  (() => {

  let files = getFiles('./src/components', /^index\.js$/)
  files = files.reduce((obj, file) => {
    return Object.assign(obj, {
      [file.replace('src', '').replace('.js', '')]: './' + file
    })
  }, {})
  return files
})()

module.exports = merge(baseConf, {
  mode: 'development',
  entry: {
    ...entries,
    'index': './src/index.js'
  },
  output: {
    path: path.resolve(__dirname, '../', LIB_DIR),
    // filename: '[name]/index.js',
    library: 'wind-ui-vue',
    libraryTarget: 'umd',
  },
  plugins: [
    new CleanWebpackPlugin()
  ]
})

看起来,类库的配置相比于一般的应用配置复杂度可以降低一点,因为不用考虑缓存,开发打包效率等。

如何按需加载?

每个组件自己一个文件这个前提有了。接下来呢?简单啊!!譬如需要Button组件就import Button from 'wind-ui-vue/lib/components/button/index.js'这样引入,和我们平时在项目里引入文件一样的道理。但这么写多了,不得不说有点累,而且需要记住组件的目录位置,谁会去node_modules里找啊。这时该babel-import-plugin出场了,它能帮我们优化这么烦人的操作,看看文档。

get到了没?它在帮我们替换源码!!!把es6的import语法转换到对应的路径下去找组件。虽然这不是tree-shaking,是掩眼法。但是确实帮我们大忙了。可是我的Button不是在wind-ui-vue/lib/button,而是在wind-ui-vue/lib/components下,怎么办?其实还有个字段libraryDirectory可以声明组件的路径前缀。按实际情况处理就可以了。

webpack打包后的文件结构

组件样式与组件逻辑分离,单独打包 —— gulp

不同的组件的样式分开打包,组件的样式表可单独打包,不与组件js逻辑捆绑。看看以下几种使用姿势

按需加载组件

import { Button } from 'wind-ui-vue',理想情况下当然希望组件对应的样式一并加载进来。对于vue组件,可以直接把<style>写进.vue文件是可以的。但如果想考虑得通用些,希望把样式表单独拿出来,这样样式就与逻辑分离了。

现在考虑的问题:样式分离后如何被自动地被组件引用?上面提到的babel-import-plugin插件提供了解决办法,看下图,style字段看起来是声明组件样式表所在的目录(true时为style目录,其他值表示子路径)。

因此我们要把样式文件放在每个组件的style目录中去。这里我们可以生成一个.js文件,在里面去引入对应的.scss文件。该文件会被插件自动插入到组件中去。

需要注意的是,这种思路下样式文件是与组件逻辑相互独立的文件,因此库打包时样式文件没经过webpack的处理,是不是更干净?只有在应用打包时再由webpack处理这些scss文件。gulp处理后的文件结构如图所示。

核心的代码就这个,也是在声明目录下找出所有scss文件进行加工。

    gulp.task('esScss', () => {
      return scssTask(ES_DIR)
    })
    
    function scssTask (DIR) {
      // 把所有组件的样式表找出来
      const files = getFiles(['./src/components'], /^index\.scss$/)
      
      files.forEach(file => {
        const {
          dir,
          base
        } = path.parse(file);
    
        // 拷贝库的一些公用的样式
        fse.copySync(
          path.resolve(__dirname, 'src/style'),
          path.resolve(__dirname, DIR, 'style')
        )
    
        // generate a .js file to require matched .scss file in every component directory and copy it
        const destPath = path.join(__dirname,
          DIR,
          dir.replace(/[^\/]+\//, '')
        )
    
        // 把.scss源码文件也拷贝过去
        fse.copyFileSync(
          path.resolve(__dirname, file),
          path.resolve(
            destPath,
            base
          )
        )
    
        // 生成一个引入了scss文件的js文件,给babel-import-plugin用的
        mkdirp.sync(path.resolve(destPath, 'style'))
        fs.writeFileSync(path.resolve(destPath, 'style', 'index.js'), `require ('../index.scss')`)
    
      })
    }

全量加载组件

import win from 'wind-ui-vue',此时没有了babel插件的帮忙,我们只能手动引入全部的组件样式,在入口文件挨个地import .scss文件就是了,和import .js文件一个道理

    import Button from './components/button/index'
    import './components/button/index.scss'
    
    import Card from './components/card/index'
    import './components/card/index.scss'
    
    const install = (Vue) => {
      Vue.use(Button)
      Vue.use(Card)
    }
    
    if (typeof window != undefined && window.Vue) {
      install(Vue)
    }
    
    export {
      Button,
      Card
    }
    
    export default {
      install
    }

考虑到umd这种模块化方案使用,需要提供.css文件。

  • 组件单独引用时,每个组件的样式是单独的样式文件,用gulp处理。
    gulp.task('lib', ['libScss'], () => {
      return gulp.src('src/**/*.scss')
      .pipe(sass.sync().on('error', sass.logError))
      .pipe(postcss([ autoprefixer() ]))
      .pipe( gulp.dest(LIB_DIR, { sourcemaps: true }) )
    })
  • 全量引入组件时,将所有组件样式打包成一份.css。回头看看我们注册了所有组件的index.js文件里已经引入了所有的组件样式,所以把它们抽离出来就是了。
    • webpack打包器使用mini-css-extract-plugin插件(自己控制生成目录)

      const MiniCssExtractPlugin = require('mini-css-extract-plugin');
      
      module.exports = {
          module: {
          rules: [
            {
              test: /\.scss$/,
              use: [
                {
                  loader: MiniCssExtractPlugin.loader,
                },
                'css-loader',
                'postcss-loader',
                {
                  loader: 'sass-loader',
      
                }
              ]
            }
          ]
        },
        plugins: [
          new MiniCssExtractPlugin()
        ]
      }
      
    • rollup打包器使用rollup-plugin-scss插件(自己控制生成目录)

      import scss from 'rollup-plugin-scss'
      module.exports = {
        input: './' + file,
        output: {
          file: file.replace('src', ES_DIR),
          format: 'esm'
        },
        plugins: [
          scss(),
        ]
      }
      

提供ui库的es6模块版本 —— rollup

像上面打包成umd规范的模块,虽然提供了所有的环境支持,但是依然无法通过 import win from 'wind-ui' 这种es6的语法引入,即不支持esModule。另外,由于webpack兼容上的考虑,打包组件上加入了自己一些多余的编译代码,造成冗余。那有没办法单独打包es6的版本呢?可以试试 rollup

emmmm... rollup-plugin-vue相当于webpack的vue-loaderoutput.format配置为esm即为esModulebabel.runtimeHelpers设为true可以减少内联的runtime代码,让打出来的包更精简。

思路和webpack一致,也是把所有组件文件找出来,进行转码后输出。rollup因为目标更单一,所以输出文件体量更可观。和webpack互为补充吧。

import commonjs from 'rollup-plugin-commonjs' 
import VuePlugin from 'rollup-plugin-vue'
import resolve from '@rollup/plugin-node-resolve';
import babel from 'rollup-plugin-babel';
import scss from 'rollup-plugin-scss'

const babelConf = require('./babel.config')

const { getFiles } = require('./build/util')
const { ES_DIR } = require('./build/const')

const entries =  (() => {

  let files = getFiles('./src/components', /^index\.js$/)
  console.log('files', files)
  files.push('src/index.js')
  files = files.map(file => {
    return {
      input: './' + file,
      output: {
        file: file.replace('src', ES_DIR),
        format: 'esm'
      },
      // .replace('.js', '')
      plugins: [
        scss(),
        resolve(),
        babel({
          runtimeHelpers: true,
          exclude: 'node_modules/**'
        }),
        commonjs(),
        VuePlugin()
      ]
    }
  })
  console.log('files', files)
  return files
})()

module.exports = entries

执行npm run build:es后,webpack生成的Lib目录同级多了一个es目录了。当然对生成资源还可以视实际情况进行更多的发挥。

在项目上使用ui库

具体在demo页上查看效果,源码在site/src/main.js(省略了<script>标签引入的展示)

// esModule方式全量引入
import win from 'wind-ui-vue/es'
import 'wind-ui-vue/es/index.css'
Vue.use(win)
// commonjs module方式全量引入
const win = require('wind-ui-vue').default
import 'wind-ui-vue/lib/index.css'
Vue.use(win)
// 按需加载,需配合`babel-import-plugin`使用
// 此时Card组件并没有打包进去,Button组件的样式已自动加载
import { Button, Card } from 'wind-ui-vue'
Vue.use(Button)

参考资料

  • vant-ui 之前项目用了vant,本组件库搭建脚手架的思路也多参考于vant,谢谢开源