Vue 项目架构设计与工程化实践

刘博文

@Berwin

关于我

@Berwin

360导航 - 高级前端工程师

95后

极简主义

长得帅

智商高

技术好

爱吹牛逼!

最佳实践发展史

漫长的发展史

我在导航开发的第一个Vue项目

PHP渲染页面,在每个页面里引入组件使用

  • 没有前端路由
  • 没有state管理
  • 没有架构设计
  • 依赖直接在html里用script标签引入
  • 。。。

我在导航开发的第二个Vue项目

Nodejs + vue 前后端分离项目

  • 引入了前端路由
  • 所有通用组件和插件全都是自己实现
  • 各种自定义filter、指令等
  • 单页应用

我在导航开发的第三个Vue项目

Nodejs + vue 玩具项目

  • 引入了 vuex 进行 State 管理
  • 抽离了 api 层
  • 之前的项目都是在组件里直接调用http方法请求数据的!!!

我在导航开发的第四个Vue项目

主题词后台管理系统

  • 全新的架构设计
  • 全新的文件结构
  • 全新的开发模式
  • 全新的代码发布上线流程
  • 全面和完善的基础设施
  • 各种自动化,自动初始化项目、自动初始化配置文件、编译静态文件自动上传cdn、自动发布上线
  • mock数据
  • 反向检测server api接口是否符合预期
  • 。。。

直到今天

导航vue项目最佳实践

项目背景

快言管理后台

一个让你不吐不快,畅所欲言的主题社区

        快言:http://k.hao.360.cn/

快言后台:http://topic.***.net/

整体架构

目录结构

.
├── README.md
├── build                   # build 脚本
├── config                  # prod/dev build config 文件
├── hera                    # 代码发布上线
├── index.html              # 最基础的网页
├── package.json
├── src                     # Vue.js 核心业务
│   ├── App.vue             # App Root Component
│   ├── api                 # 接入后端服务的基础 API
│   ├── assets              # 静态文件
│   ├── components          # 组件
│   ├── event-bus           # Event Bus 事件总线,类似 EventEmitter
│   ├── main.js             # Vue 入口文件
│   ├── router              # 路由
│   ├── service             # 服务
│   ├── store               # Vuex 状态管理
│   ├── util                # 通用 utility,directive, mixin 还有绑定到 Vue.prototype 的函数
│   └── view                # 各个页面
├── static                  # DevServer 静态文件
└── test                    # 测试

纯前端工程

基础设施层

init

自动化初始化配置文件

API层

dev

启动dev-server,hot-reload,http-proxy 等辅助开发

deploy

编译源码,静态文件上传cdn,生成html,发布上线

api /

请求数据,Mock数据,反向校验后端api

util /

存放项目全局的工具函数

util层

业务层

service/

处理 server 返回的数据(类似data format)

例如 service 调用了不同的api接口拿到不同的数据整合在一起发送给 store

view层

store /

vuex 的 store

router /

前端路由

view /

各个业务页面组件

component /

通用组件

全局事件机制

event-bus /

主要用来处理特殊需求

工程化生命周期

生成项目

选择模板,初始化项目

问答交互

创建完成

进入项目 & 安装依赖

初始化配置文件

npm run init

config
├── dev.conf
│   └── index.js
├── init-tpl
│   ├── metadata.js
│   └── template
│       └── index.js
├── index.js
├── dev.env.js
├── prod.env.js
└── test.env.js
// init-tpl/template/index.js
module.exports = {
  serverDomain: '{{serverDomain}}'
}


// init-tpl/metadata.js
module.exports = {
  "prompts": {
    "serverDomain": {
      "type": "string",
      "message": "Please enter the server address",
      "default": "liubowen.topic.nav.qihoo.net"
    }
  }
}

// dev.conf/index.js
module.exports = {
  serverDomain: 'liubowen.topic.nav.qihoo.net'
}

Speike-cli

// package.json
{
  "scripts": {
    "init": "speike init ./config/init-tpl ./config/dev.conf"
  }
}
// .gitignore

config/dev.conf

开发

api-proxy

import proxy from '../base.js'
import fetchLogs from './fetchLogs.js'

export default proxy.api({
  fetchLogs
})
const actions = {
  fetchLogs ({ commit }, query) {
    return log.fetchLogs(query)
  }
}

/api/log/index.js

/store/modules/log.js

/*
 * /api/log/fetchLogs.js
 */
export default {
  options: {
    url: '/api/operatelog/list',
    method: 'GET'
  },
  rule: {
    'data': {
      'list|0-20': [{
        'id|3-7': '1',
        'path': '/log/opreate',
        'url': '/operate/log?id=3',
        'user': 'berwin'
      }],
      'pageData|7-8': {
        'cur': 1,
        'first': 1,
        'last': 1,
        'total_pages|0-999999': 1,
        'total_rows|0-999999': 1,
        'size|0-999999': 1
      }
    },
    'errno': 0,
    'msg': '操作日志列表'
  }
}

Mock 数据

反向校验后端api

可选,不强制使用

// 不使用api-proxy的api

import {api} from './base'

export default {
  getUserInfo (sid) {
    return api.get('/api/user/getUserInfo', {
      params: {
        sid,
        host: window.location.hostname
      }
    })
  }
}

编译

发布上线

npm run deploy

scripts: {
  "deploy": "hera compile online"
}

npm run build

index.html

服务器

docker

经验总结与踩坑

一些不成熟的经验

曾经遇到过的问题

开发环境问题

Dev Config 如何配置?

  • 将 dev.conf 写入 .gitignore
  • init 脚本初始化 dev.conf
  • 命令行交互提问个性化配置

登陆问题

纯静态单页开发登陆认证问题怎么解决?

解决方案:

  1. 后端认证cookie 与 sid,未登录返回错误码
  2. 前端拦截所有api请求判断错误码跳转公司统一用户登陆中心
  3. 前端拦截router在页面跳转回来的第一时间拿到 sid 请求后端接口把sid传给后端
  4. 后端拿sid种cookie
  5. 登陆完成

client

server

请求数据

返回未登录错误码

登陆中心

跳转

sid

请求数据(携带sid)

返回数据 + 种cookie

请求数据(携带cookie)

返回数据

登陆问题2

在本地启动 Dev server 域名与端口号并不是后端域名,cookie怎么种?

解决方案:

  • 前端将sid和当前开发环境host一起发送给后端

client

server

请求数据(携带sid+host)

返回数据 + 种cookie

开发环境怎么搞?

.js

.vue

.css

images

dependencies

...

index.html

打包上线

期望是编译出一个无任何依赖的纯html文件

如何生成html?

HtmlWebpackPlugin

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Webpack App</title>
  </head>
  <body>
    <script src="index_bundle.js"></script>
  </body>
</html>

静态文件怎么办?

cdn-loader + cdn-plugin 自动上传 cdn

cdn-loader

cdn-plugin

module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: path.join(__dirname, 'cdn-loader'),
        options: {
          disable: !isProduction,
          limit: 10000,
          name: utils.assetsPath('img/[name].[hash:7].[ext]')
        }
      }
    ]
  }
const CdnPlugin = require('./cdn-plugin')
plugins: [
  new CdnPlugin()
]

cdn-loader

cdn-loader 的作用是在编译过程中,代码内引入的图片,字体,等资源文件上传cdn

cdn-plugin

cdn-plugin 的作用是静态文件代码打包编译后,生成html文件的过程中,把编译好的 .js 与 .css 文件上传cdn并把上传cdn后得到的url写入html的 link 与 script 标签

var loaderUtils = require('loader-utils')
var qcdn = require('@q/qcdn')

module.exports = function(content) {
  this.cacheable && this.cacheable()
  var query = loaderUtils.getOptions(this) || {}

  if (query.disable) {
    var urlLoader = require('url-loader')
    return urlLoader.call(this, content)
  }

  var callback = this.async()
  var ext = loaderUtils.interpolateName(this, '[ext]', {content: content})

  qcdn.content(content, ext)
    .then(function upload(url) {
      callback(null, 'module.exports = ' + JSON.stringify(url))
    })
    .catch(callback)
}

module.exports.raw = true
var qcdn = require('@q/qcdn')

function CdnAssetsHtmlWebpackPlugin (options) {}

CdnAssetsHtmlWebpackPlugin.prototype.apply = function (compiler) {
  compiler.plugin('compilation', function(compilation) {
    var extMap = {
      script: {
        ext: 'js',
        src: 'src'
      },
      link: {
        ext: 'css',
        src: 'href'
      },
    }

    compilation.plugin('html-webpack-plugin-alter-asset-tags', function(htmlPluginData, callback) {
      console.log('> Static file uploading cdn...')

      var bodys = htmlPluginData.body.map(upload('body'))
      var heads = htmlPluginData.head.map(upload('head'))

      function upload (type) {
        return function (item, i) {
          if (!extMap[item.tagName]) return Promise.resolve()
          var source = compilation.assets[item.attributes[extMap[item.tagName].src].replace(/^(\/)*/g, '')].source()
          return qcdn.content(source, extMap[item.tagName].ext)
            .then(function qcdnDone(url) {
              htmlPluginData[type][i].attributes[extMap[item.tagName].src] = url
              return url
            })
        }
      }

      Promise.all(heads.concat(bodys))
        .then(function (result) {
          console.log('> Static file upload cdn done!')
          callback(null, htmlPluginData)
        })
        .catch(callback)
    })
  })
}

module.exports = CdnAssetsHtmlWebpackPlugin

cdn-loader

cdn-plugin

权限控制

  1. 路由权限
  2. api权限
  3. 按钮显示权限

路由权限

  1. 拦截路由
  2. 认证权限
  3. 跳转Error页

权限数据结构

category: {
  addImpl: true,
  deleteImpl: true,
  editImpl: true,
  itemImpl: true,
  getCategoryParentsImpl: true,
  listImpl: true,
  searchImpl: true
}

// or

category: false
const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/category',
      name: 'category',
      component: Category,
      meta: {
        permission: 'category'
      }
    }
  ]
})

const permission = {
  category: {
    addImpl: true,
    deleteImpl: true,
    editImpl: true,
    itemImpl: true,
    getCategoryParentsImpl: true,
    listImpl: true,
    searchImpl: true
  }
}

router.beforeEach(function (to, from, next) {
  if (permission[to.meta.permission] === false) {
    router.push({name: 'error-authorize'})
    return false
  }
  next()
})

按钮显示权限

使用v-if控制是否显示

api权限

server端会对api权限做验证,所以前端没做权限认证,直接走全局错误处理逻辑

axios.interceptors.response.use(function (response) {
  if (response.data.errno !== 0) {
    Bus.$emit('message', {
      message: response.data.msg || '服务器发生错误',
      type: 'error'
    })
    return Promise.reject(response)
  }
  return response
}, function (error) {
  return Promise.reject(error)
})
<el-button
  type="danger"
  size="mini"
  @click="remove(scope.row.id)"
  v-if="permission.category.deleteImpl">
  删除
</el-button>

computed: {
  ...mapState({
    permission: state => state.user.permission
  })
}

一个经典的列表需求

翻页

handleCurrentChange (val) {
  this.$router.push({
    name: 'word',
    query: {
      ...this.$route.query,
      page: val
    }
  })
}​

搜索

search () {
  this.$router.push({
    name: 'word',
    query: {
      search: this.searchKeyword,
      cate_id: this.cateId
    }
  })
}

刷新列表

watch: {
  '$route' () {
    this.refreshList()
  }
}
refreshList () {
  return this.changeLoadingState(true)
    .then(this.fetchList)
    .then(this.updateStore)
    .then(this.changeLoadingState.bind(this, false))
    .catch(this.changeLoadingState.bind(this, false))
}
fetchList () {
  const query = this.$route.query
  if (query.search) return this.search()
  if (query.cate_id) return this.searchByCid()
  return this.fetchWords()
}

为什么用路由维护状态?

我们曾犯过的错误

// src/store/modules/category.js
const actions = {
  refreshCategories ({ commit }, query) {
    return category.getCategories(query).then(({data}) => {
      commit(types.RECEIVE_CATEGORIES, { categories: data.data.list })
      commit(types.RECEIVE_CATEGORY_PAGEDATA, { pageData: data.data.pageData })
    })
  }
}
// src/view/Category/List.vue
this.Categories({
  pageno: this.currentPage,
  size: this.currentSize,
  pid: this.$route.query.parentIds
})

问题在哪?

正常情况

第一页

Server

请求第一页列表

响应第一页列表 并 更新store中的列表和页码数据

第二页

请求第二页列表

响应第二页列表 并 更新store中的列表和页码数据

第三页

请求第三页列表

响应第三页列表 并 更新store中的列表和页码数据

犯病情况

第一页

Server

请求第一页列表

响应第一页列表 并 更新store中的列表和页码数据

第二页

请求第二页列表

响应第二页列表 并 更新store中的列表和页码数据

第三页

请求第三页列表

响应第三页列表 并 更新store中的列表和页码数据

第一页

第二页

第三页

跳回第一页

跳回第二页

跳回第三页

watch $route 的问题

发送多余请求 切换页码,面板也会发送请求

只watch自己需要的query

// Panel.vue
'$route.query.id' () {
  this.search()
}
// List.vue
'$route.query.page' () {
  this.refresh()
}
有些路要自己走,
有些坑要自己踩
我无法通过一个PPT把所有经验都讲出来

Thanks

Vue 项目架构设计与工程化实践

By 刘博文

Vue 项目架构设计与工程化实践

Vue 项目架构设计与工程化实践

  • 8,367