【工程化】从 0 搭建 VueJS 移动端组件库开发框架

写于 2017.06.05css

以前发表过一篇《Vue-Donut——专用于构建Vue的UI组件库的开发框架》,仅仅是对框架一个粗略的介绍,并无针对里面的实现方式进行详细说明。html

最近参与维护公司内部的一个针对移动端的UI组件库,该组件库缺少文档和严格的文件组织结构。Vue-Donut的功能比较简单,并不能方便地建立针对移动端UI组件库的文档和预览。在参考了mint-ui等业界内成熟的方案以后,我在Vue-Donut的基础上进行了拓展,最后搭建出了一个很是方便且自动化的开发框架。vue

因为以为开发的过程很是有意思,也想记录一下本身的开发思路,所以决定好好地写一篇文章做为记录分享。node

项目地址:github.com/jrainlau/vu…webpack

1. 功能分析

首先咱们来规划一下这个框架的最终目的是什么:git

如图所示,经过该框架能够生成一个文档页面。这个页面分为三个部分:导航、文档、预览。github

  1. 导航:经过导航切换不一样组件的文档和预览。web

  2. 文档:该类型组件所对应的文档,以markdown形式书写。vue-router

  3. 预览:该类型组件所对应的预览页面。vue-cli

为了让组件的开发和文档的维护更加高效,咱们但愿这个框架能够更加自动化。若是咱们只要开不一样组件的预览的页面及其对应的说明文档README,框架就能自动帮咱们生成对应的导航和HTML内容,岂不妙哉?除此以外,当咱们已经把全部的UI组件都开发好了,通通放在/components目录下,若是可以经过框架进行一键构建打包,最后产出一个npm包,那么别人使用这套UI组件库也会变得很是简单。带着这个想法,咱们来分析一下咱们可能须要用到的关键技术。

2. 技术分析

  • 使用webpack2做为框架核心:使用方便,高度可定制。同时webpack2文档已经至关齐全,生态圈繁荣,社区活跃,遇到的坑基本上均可以在google和stackoverflow找到。

  • 预览页面以iframe的形式插入到文档页面中:维护组件库的时候只须要聚焦于组件的开发和预览页面的组织,无需分心维护导航和文档,实现了解耦。所以意味着这是一个基于Vue.js的多页应用

  • 自动生成导航:使用vue-router进行页面切换。每当新建一个预览页面,就会自动在页面上生成对应的导航,并自动维护导航和路由的关系。所以,咱们须要一套机制去监听文件结构的变化。

  • 自动生成文档:一个预览页面对应一份文档,因此文档理应以README.md的形式存放在对应的预览页面文件夹内。咱们须要一个可以把README.md直接转化成html内容的办法。

  • 开发者模式:经过一条命令,启动一个webpack-dev-server,提供热更新和自动刷新功能。

  • 构建打包模式:经过一条命令,自动把/components目录下的全部资源打包成一个npm包。

  • 页面构建模式:经过一条命令,生成可以直接部署使用的静态资源文件。

经过对技术的梳理,咱们脑海里面已经有了一个印象,接下来就是一步一步地进行开发了。

3. 梳理框架目录结构

一个好的目录结构,可以极大地方便咱们接下来的工做。

.
├── index.html  // 文档页的入口html
├── view.html  // 预览页的入口html
├── package.json  // 依赖声明、npm script命令
├── src
│   ├── document  // 文档页目录
│   │   ├── doc-app.vue  // 文档页入口.vue文件
│   │   ├── doc-entry.js  // 文档页入口.js文件
│   │   ├── doc-router.js  // 文档页路由配置
│   │   ├── doc_comps  // 文档页组件
│   │   └── static  // 文档页静态资源
│   └── view  // 预览页目录
│       ├── assets  // 预览页静态资源
│       ├── components // UI组件库
│       ├── pages // 存放不一样的预览页
│       ├── view-app.vue // 预览页入口.vue文件
│       ├── view-entry.js  // 预览页入口.js文件
│       └── view-router.js  // 预览页路由配置
└── webpack
    ├── webpack.base.config.js // webpack通用配置 
    ├── webpack.build.config.js  // UI库构建打包配置
    ├── webpack.dev.config.js  // 开发模式配置
    └── webpack.doc.config.js  // 静态资源构建配置
复制代码

能够看到,目录结构并不复杂,接下来咱们首先对webpack进行配置,以便咱们可以把项目跑起来。

4. webapck配置

4.1 基础配置

进入到/webpack目录,新建一个webpack.base.config.js文件,其内容以下:

const { join } = require('path')
const hljs = require('highlight.js')

// 配置markdown解析、以便高亮显示markdown中的代码块
const markdown = require('markdown-it')({
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return '<pre class="hljs"><code>' +
               hljs.highlight(lang, str, true).value +
               '</code></pre>';
      } catch (__) {}
    }

    return '<pre class="hljs"><code>' + markdown.utils.escapeHtml(str) + '</code></pre>';
  }
})

const resolve = dir => join(__dirname, '..', dir)

module.exports = {
  // 只配置输出路径
  output: {
    filename: 'js/[name].js',
    path: resolve('dist'),
    publicPath: '/'
  },

  // 配置不一样的loader以便资源加载
  // eslint是标配,建议加上
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          'babel-loader',
          'eslint-loader'
        ]
      },
      {
        enforce: 'pre',
        test: /\.vue$/,
        loader: 'eslint-loader',
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        loader: 'url-loader'
      },
      {
        test: /\.css$/,
        use: [{
          loader: 'style-loader'
        }, {
          loader: 'css-loader'
        }]
      },
      {
        test: /\.less$/,
        use: [{
          loader: 'style-loader' // creates style nodes from JS strings
        }, {
          loader: 'css-loader' // translates CSS into CommonJS
        }, {
          loader: 'less-loader' // compiles Less to CSS
        }]
      },
      // vue-markdown-loader可以把.md文件直接转化成vue组件
      {
        test: /\.md$/,
        loader: 'vue-markdown-loader',
        options: markdown
      }
    ]
  },
  resolve: {
    // 该项配置可以在加载资源的时候省略后缀名
    extensions: ['.js', '.vue', '.json', '.css', '.less'],
    modules: [resolve('src'), 'node_modules'],
    // 配置路径别名
    alias: {
      '~src': resolve('src'),
      '~components': resolve('src/view/components'),
      '~pages': resolve('src/view/pages'),
      '~assets': resolve('src/view/assets'),
      '~store': resolve('src/store'),
      '~static': resolve('src/document/static'),
      '~docComps': resolve('src/document/doc_comps')
    }
  }
}

复制代码

4.2 开发模式配置

基础配置好了,咱们就能够开始开发模式的配置了。在当前目录下,新建一个webpack.dev.config.js文件,并写入以下内容:

const { join } = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const basicConfig = require('./webpack.base.config')
const HtmlWebpackPlugin = require('html-webpack-plugin')

const resolve = dir => join(__dirname, '..', dir)

module.exports = merge(basicConfig, {
  // 因为是多页应用,因此应该有2个入口文件
  entry: {
    app: './src/document/doc-entry.js',
    view: './src/view/view-entry.js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  devtool: 'inline-source-map',

  // webpack-dev-server配置
  devServer: {
    contentBase: resolve('/'),
    compress: true,
    hot: true,
    inline: true,
    publicPath: '/',
    stats: 'minimal'
  },
  plugins: [
    // 热更新插件
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NamedModulesPlugin(),
    
    // 把生成的js注入到入口html文件
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'index.html',
      inject: true,
      chunks: ['app']
    }),
    new HtmlWebpackPlugin({
      filename: 'view.html',
      template: 'view.html',
      inject: true,
      chunks: ['view']
    })
  ]
})

复制代码

很是简单的配置,值得注意的是由于多页应用的缘故,入口文件和HtmlWebpackPlugin都要写多份。

4.3 构件打包配置

接下来,还有把UI组件库构建打包成npm包的配置。新建一个名为webpack.build.config.js的文件:

const { join } = require('path')
const merge = require('webpack-merge')
const basicConfig = require('./webpack.base.config')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')

const resolve = dir => join(__dirname, '..', dir)

module.exports = merge(basicConfig, {
  // 入口文件
  entry: {
    app: './src/view/components/index.js'
  },
  devtool: 'source-map',
  // 输出位置为dist目录,名字自定义,输出格式为umd格式
  output: {
    path: resolve('dist'),
    filename: 'index.js',
    library: 'my-project',
    libraryTarget: 'umd'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [
    // 每一次打包都把上一次的清空
    new CleanWebpackPlugin(['dist'], {
      root: resolve('./')
    }),
    // 把静态资源复制出去,以便实现UI换肤等功能
    new CopyWebpackPlugin([
      { from: 'src/view/assets', to: 'assets' }
    ])
  ]
})

复制代码

4.4 一键生成文档配置

最后,咱们一块儿来配置一键生成文档网站的webpack.doc.config.js

const { join } = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const basicConfig = require('./webpack.base.config')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const CleanWebpackPlugin = require('clean-webpack-plugin')

const resolve = dir => join(__dirname, '..', dir)

module.exports = merge(basicConfig, {
  // 相似开发者模式,两个入口文件,多了一个公共依赖包vendor
  // 以`js/`开头可以自动输出到`js`目录下
  entry: {
    'js/app': './src/document/doc-entry.js',
    'js/view': './src/view/view-entry.js',
    'js/vendor': [
      'vue',
      'vue-router'
    ]
  },
  devtool: 'source-map',

  // 输出文件加hash
  output: {
    path: resolve('docs'),
    filename: '[name].[chunkhash:8].js',
    chunkFilename: 'js/[name].[chunkhash:8].js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          loaders: {
            css: ExtractTextPlugin.extract({
              use: ['css-loader']
            }),
            less: ExtractTextPlugin.extract({
              use: ['css-loader', 'less-loader']
            })
          }
        }
      }
    ]
  },
  plugins: [
    // 提取css文件并指定其输出位置和命名
    new ExtractTextPlugin({
      filename: 'css/[name].[contenthash:8].css',
      allChunks: true
    }),
    
    // 抽离公共依赖
    new webpack.optimize.CommonsChunkPlugin({
      names: ['js/vendor', 'js/manifest']
    }),
    
    // 把构建出的静态资源注入到多个入口html中
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'index.html',
      inject: true,
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
      },
      chunks: ['js/vendor', 'js/manifest', 'js/app'],
      chunksSortMode: 'dependency'
    }),
    new HtmlWebpackPlugin({
      filename: 'view.html',
      template: 'view.html',
      inject: true,
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
      },
      chunks: ['js/vendor', 'js/manifest', 'js/view'],
      chunksSortMode: 'dependency'
    }),
    new webpack.LoaderOptionsPlugin({
      minimize: true,
      debug: false
    }),
    new webpack.optimize.OccurrenceOrderPlugin(),
    new CleanWebpackPlugin(['docs'], {
      root: resolve('./')
    })
  ]
})

复制代码

经过上面这个配置,最终会产出一个index.html和一个view.html,以及各自所需的css和js文件。直接部署到静态服务器上便可进行访问。

多说一句,webpack的配置乍一看上去好像很复杂,但其实是至关简单,webpack2的官方文档也挺完善且易读,推荐对webpack2不熟悉的朋友花点时间认真阅读一下文档。

至此,咱们已经把/webpack目录下的相关配置都弄好了,框架的基础骨架已经搭建完毕,接下来开始对业务逻辑进行开发。

5. 业务逻辑开发

在根目录下新建两个入口文件index.htmlview.html,分别添加一个<div id="app"></div><div id="view"></div>标签。

进入/src目录,新建/document/view目录,按照前文目录结构所示新建须要的目录和文件。

具体的内容能够看这里,简单来讲就是初始化vue应用,请暂时忽略router.js当中的这一段代码:

routeList.forEach((route) => {
  routes.splice(1, 0, {
    path: `/${route}`,
    component: resolve => require([`~pages/${route}/index`], resolve)
  });
});
复制代码

这个是监听目录变化自动管理导航相关的功能,会在后面详细介绍。

逻辑很简单。/document/view分别属于文档预览两个应用,其中预览iframe的形式内嵌到文档应用页面内,相关的操做其实都是在文档当中进行。当点击导航的时候,文档应用会自动加载/view/pages/下相关预览页文件夹的README.md文件,同时修改iframe的连接,实现内容的同步切换。

接下来,咱们一块儿来研究一下如何监听文件目录变化,自动维护router导航。

6. 自动维护router导航

若是你有用过Nuxt,必定对其自动维护router的功能不会陌生。若是没有用过也不要紧,咱们本身来实现这个功能!

使用vue-router的同窗可能都经历过这么一个痛点,每当新建页面,都要往router.js的数组里面添加一个声明,最终router.js极可能会变成这样:

const route = [
  { path: '/a', component: resolve => require(['a'], resolve) },
  { path: '/b', component: resolve => require(['b'], resolve) },
  { path: '/c', component: resolve => require(['c'], resolve) },
  { path: '/d', component: resolve => require(['d'], resolve) },
  { path: '/e', component: resolve => require(['e'], resolve) },
  { path: '/f', component: resolve => require(['f'], resolve) },
  ...
]
复制代码

很烦,对不对?若是能够自动维护就行了。首先咱们要作一个约定,约定好不一样的“页面”应该如何组织。

/src/view/pages目录下,每新建一个“页面”,咱们就要新建一个和该页面同名的文件夹,往里添加文档README.md和入口index.vue,效果以下:

└── view
    └── pages
        ├── 页面A
        │   ├── index.vue
        │   └── README.md
        ├── 页面B
        │   ├── index.vue
        │   └── README.md
        ├── 页面C
        │   ├── index.vue
        │   └── README.md
        └── 页面D
            ├── index.vue
            └── README.md
复制代码

约定好了文件的组织方式,接下来咱们须要用到一个工具去负责监听和处理。这里咱们使用了chokidar来实现。

/webpack目录下新建一个watcher.js文件:

console.log('Watching dirs...');
const { resolve } = require('path')
const chokidar = require('chokidar')
const fs = require('fs')
const routeList = []

const watcher = chokidar.watch(resolve(__dirname, '../src/view/pages'), {
  ignored: /(^|[\/\\])\../
})

watcher
  // 监听目录添加
  .on('addDir', (path) => {
    let routeName = path.split('/').pop()
    if (routeName !== 'pages' && routeName !== 'index') {
      routeList.push(`'${routeName}'`)
      fs.writeFileSync(resolve(__dirname, '../src/route-list.js'), `module.exports = [${routeList}]`)
    }
  })
  // 监听目录变化(删除、重命名)
  .on('unlinkDir', (path) => {
    let routeName = path.split('/').pop()
    const itemIndex = routeList.findIndex((val) => {
      return val === `'${routeName}'`
    })
    routeList.splice(itemIndex, 1)
    fs.writeFileSync(resolve(__dirname, '../src/route-list.js'), `module.exports = [${routeList}]`)
  })

module.exports = watcher

复制代码

这里面主要作了3件事:监听目录变化、维护目录名列表、把列表写入文件。当开启watcher后,能够在/src底下看到一个route-list.js文件,内容以下:

module.exports = ['页面A','页面B','页面C','页面D']
复制代码

而后咱们就能够愉快地使用了……

// view-router.js

import routeList from '../route-list.js';

const routes = [
  { path: '/', component: resolve => require(['~pages/index'], resolve) },
  { path: '*', component: resolve => require(['~pages/index'], resolve) },
];

routeList.forEach((route) => {
  routes.splice(1, 0, {
    path: `/${route}`,
    component: resolve => require([`~pages/${route}/index`], resolve)
  });
});
复制代码
// doc-router.js

import routeList from '../route-list.js';

const routes = [
  { path: '/', component: resolve => require(['~pages/index/README.md'], resolve) }
];

routeList.forEach((route) => {
  routes.push({
    path: `/${route}`,
    component: resolve => require([`~pages/${route}/README.md`], resolve)
  });
});
复制代码

同理,在页面的导航组件里面,咱们也加载这个route-list.js文件,实现导航内容的自动更新。

放个视频,你们能够感觉一下(SF居然不容许内嵌视频,不科学): v.qq.com/x/page/a051…

7. UI库文件组织约定

这个框架的根本目的,实际上是为了UI库的开发。那么咱们也应该对UI库的文件组织进行约定。

进入/src/view/components目录,咱们的整个UI库就放在这里面:

└── components
    ├── index.js // 入口文件
    ├── 组件A
    │   ├── index.vue
    ├── 组件B
    │   ├── index.vue
    ├── 组件C
    │   ├── index.vue
    └── 组件D
        └── index.vue
复制代码

当中的index.js,将会以vue plugin的方式编写:

import MyHeader from './组件A'
import MyContent from './组件B'
import MyFooter from './组件C'

const install = (Vue) => {
  Vue.component('my-header', MyHeader)
  Vue.component('my-content', MyContent)
  Vue.component('my-footer', MyFooter)
}

export {
  MyHeader,
  MyContent,
  MyFooter
}

export default install

复制代码

这样,就可以在入口.js文件中以Vue.use(UILibrary)的形式对UI库进行引用了。

扩展一下,考虑到UI可能有“换肤”的功能,那么咱们能够在/src/view目录下新建一个/assets目录,专门存放样式相关的文件,这个目录最终也会被打包到/dist目录下,在使用的时候引入相应样式文件便可。

8. 构建运行命令

前面作了那么多,最终咱们但愿可以经过简单的npm script命令就把整个框架运行起来,应该怎么作呢?

还记得在/webpack目录下的三个config.js文件吗?它们就是框架跑通的关键,可是咱们并不打算直接运行它们,而是在其之上封装一下。

/webpack目录下新建一个dev.js文件,内容以下:

require('./watcher.js')
module.exports = require('./webpack.dev.config.js')

复制代码

一样的,分别新建build.jsdoc.js文件,分别引入webpack.build.config.jswebpack.doc.config.js便可。

为何要这么作呢?由于webpack运行的时候会读取config.js文件,若是咱们但愿在webpack工做以前先进行一些预处理,那么这种作法就很是方便了,好比这里添加的监听目录文件变化的功能。若是未来有什么扩展,也能够经过相似的方式进行。

接下来就是在package.json里面定义咱们的npm script了:

"dev": "node_modules/.bin/webpack-dev-server --config webpack/dev.js",
"doc": "node_modules/.bin/webpack -p --config webpack/doc.js --progress --profile --colors",
"build": "node_modules/.bin/webpack -p --config webpack/build.js --progress --profile --colors"
复制代码

值得注意的是,在生产模式下,须要加-p才能充分启动webpack2的tree-shaking功能。

在根目录下经过npm run 命令的方式测试一下是否已经跑起来了呢?

9. 后续工做

  • 添加单元测试
  • 加入PWA功能

10. 尾声

本文篇幅较长,可以看到这里的估计已经有点晕了吧。好久都没有写文章了,终于被我攒了个大招发出来,特别爽。搭建开发框架的过程是一个不断尝试,不断google和stackoverflow的过程。在这个过程当中,大到对架构设计,小到对文件组织、工具使用,都有了更进一步的理解。

这个框架的运做模式,其实也是参考了不少业界内的方案,更多的是想要“偷懒”。能让机器自动帮忙搞的,绝对不本身手动搞,这才是技术进步的动力嘛。

该项目已经被改装成vue-cli的模板,经过vue init jrainlau/vue-donut#mobile便可使用,欢迎尝试,期待反馈和PR,谢谢你们~

相关文章
相关标签/搜索