开始搭建之前要明确需要支持什么能力,再逐个考虑要如何实现。本项目搭建时计划需要支持以下功能:

  • 支持组件测试/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",

&& 可以串联执行脚本,前一个命令执行完才会执行下一个脚本,可以将一组有前后关系的脚本组合在一起

04-15 02:15