掘金 Vue.js 2.0 重构

最佳实践

Ming Yin

@kalasoo

About

@kalasoo

I'm Ming Yin

 

 

 

 

 

CUHK & UCam

Frontend Developer

Xitu Inc. Founder & CEO

掘金 juejin.im

Refactor History
Architechture
Best Practices

Special Announcement

立个 Flag:这真的是我最最最最最最最最后一次作技术分享

Refactor History

我们有一个漫长的重构历史

First Version 0.12.X

2015年5-6月

单纯的组合业务,只有一个大的页面,全部放到一个独立的 Root Vue Object 里

  • 没有组件化
  • 没有分页面(URL)
  • 没有共享的 state
  • 没有 build 文件定义

1st Refactoring

2015年10月

  1. 组件化
  2. vue-loader & webpack
  3. 自定义 filter (utils function)
  4. 分页 director(还不是 router 分页)

2nd Refactoring Vue 1.0

2016年3-4月

  1. 分页使用 vue-router 替代 director
  2. 分割前后端 webpack 设置
  3. 组件化加了一个非官方的状态管理(自己实现的)

3nd Refactoring - Not me

2016年7-8月

  1. 接入 Vuex 状态管理
  2. 重构了大多数组件里的数据传递

🌟4th Refactoring - Vue 2.0🌟

2016年12月 - 2017年2月

  1. 完全重新清理了文件结构(按照官方推荐最佳实践)
  2. 完全重新清理了 Web 业务架构
  3. 完全抽象了 Model 层的数据 CRUD 操作
  4. 完全重新写了 Webpack/Server 在不同环境下的配置
  5. 完全接入部分 SSR(根据需求)
  6. 完全接入新域名 juejin.im

7. 完全没有理我😭

For Those who Think Vue.js is Not Good

掘金从每天十几个人访问

到每天十几万人访问

业务逻辑越来越复杂

但是 Vue.js 依然好用

❤️

Architecture

直到今日,掘金的整体业务架构

业务复杂度

  • 用户登陆不状态
  • > 30 个页面
  • > 40 个组件
  • 富文本/md 编辑器
  • 组合页面无限下拉刷新
  • 后端渲染
  • CDN 加速
  • 许多许多小的组件等
  • ......

开发复杂度

  • prod
  • test
  • dev
  • build:client
  • build:server
  • deploy

整个 Web 项目

juejin/
├── backend/          # 后端业务
├── build/            # build 脚本
├── cache/            # 页面 Redis 缓存
├── config/           # prod/dev build config 文件
├── src/              # Vue.js 核心业务
├── static/           # 静态文件
├── cdn.js            # CDN 上传脚本
├── index.html        # 最基础的网页
├── ...
├── renderer.js       # SSR 脚本
└── server.js         # Express 后端处理请求 
{
  ...
  "scripts": {
    "start": "cross-env NODE_ENV=production node server",
    "test": "cross-env NODE_ENV=production WRK_CNT=1 node server",
    "stop": "pkill --signal SIGINT juejin-web",
    "dev": "supervisor -q -n error -w build,config,backend server",
    "build": "rimraf dist && npm run build:client && npm run build:server && npm run build:version",
    "build:client": "cross-env NODE_ENV=production VUE_ENV=client webpack
                     --config build/webpack.client.conf.js --progress --hide-modules",
    "build:server": "cross-env NODE_ENV=production VUE_ENV=server webpack
                     --config build/webpack.server.conf.js --progress --hide-modules",
    "build:version": "cross-env NODE_ENV=production webpack 
                     --config build/webpack.version.conf.js --progress --hide-modules",
    "cdn": "node cdn",
    "lint": "eslint --ext .js,.vue src"
  },
  ...
}
package.json

Domain Driven Design

Separate monolithic applications into multiple stand alone service applications (domain-subdomain) within different bounded contexts.

- ThoughtWorks

https://www.thoughtworks.com/insights/blog/domain-driven-design-services-architecture

掘金整体业务架构

Credit to Zhende @ xitu.io

Vue 核心文件

src/
├── api/              # 接入微服务的基础 API
├── App/              # App Root Component
├── asset/            # 静态文件
├── business/         # 业务
├── component/        # 组件
├── const/            # 常量
├── event-bus/        # Event Bus 事件总线,类似 EventEmitter
├── global/           # 通用定义的 directive, mixin 还有绑定到 Vue.prototype 的函数
├── model/            # Model 抽象层
├── repository/       # 仓库,接入 Vuex 中
├── router/           # 路由
├── service/          # 服务
├── state/            # Vuex 状态管理
├── style/            # 样式
├── util/             # 通用 utility functions
├── view/             # 各个页面
├── client-entry.js   # 前端业务 & build
├── server-entry.js   # SSR业务 & build
├── ...
└── main.js           # Vue Object Initiation

基础设施层

api/
util/

这个比较容易理解

领域层 Domain

service/

各个 Domain 下的基础功能业务

 

repository/

某一个独立 Domain 下的获取数据的业务

 

model/

数据抽象层

业务层

business/

各个 Domain 下的具体业务,会引用 service 和 repository 中定义的功能

 

validator/

不同数据的 validation 过程

表现层

state/
router/
component/
view/

Vue 下具体的交互展示层业务

Event Bus

类似于 Node 中的 EventEmitter

通过事件管理和监听处理异常、Alert、Scroll 触发等

Best Practices

一些不成熟的小经验

404

需求:

不通过跳转 URL 来显示 Not Found

 

解决方案:

  • 我们在路由表的最后配置了 404 路由,如果当前 URL 没有匹配前面的任意一条规则
  • Vuex 状态树中有专门的 error module 存储异常
  • ​​然后 dispatch 一个 action 设定为 404 展示
{
  path: '*',
  name: 'notFound',
  component: process.BROWSER
    ? () => System.import('view/NotFoundView')
    : require('view/NotFoundView')
}
{
  location: null,
  errorView: null,
  statusCode: 200
}
[SHOW_NOT_FOUND_VIEW] ({ commit }) {
  commit(UPDATE_STATE, {
    errorView: 'NotFoundView',
    statusCode: 404
  })
}
component.error-view(
    v-if="errorView",
    :is="errorView"
)
router-view(v-if="!errorView")
/entry/id-not-existed

SSR

需求:

后端渲染解决性能问题

 

解决方案:

多层缓存

  • 数据层缓存
  • 组件层缓存 lru-cache
  • 页面层缓存 redis
function createRenderer (bundle) {
  return vueServerRenderer.createBundleRenderer(bundle, {
    cache: LRU({
      max: 5000,
      maxAge: 1000 * 60 * 60
    }),
    directives: {
      link: require('./src/global/directive/link').server
    }
  })
}
renderStream.on('end', () => {
  if (!shouldContinue) { return }
  const initialState = encodeURIComponent(context.initialState)
  const html = `
    <script id="jjis">
      window.__JJIS__="${initialState}"
    </script>
    ${htmlTemplate[2]}
  `
  cachedHtml = cachedHtml + html
  if (!user && context.code === 200) {
    cache({
      url: req.originalUrl,
      html: cachedHtml
    })
  }
  res.end(html)
})

数据一致性

需求:

前后端渲染数据一致性问题

 

解决方案:

  • 通过某种事件广播机制实现数据的最终一致性

  • Vuex 本身就有事件广播模型,我们定义了 3 个 mutation 类型:

    • ENTITY_CREATED - 实体已创建
    • ENTITY_UPDATED - 实体已更新
    • ENTITY_DELETED - 实体已删除
import {
  ENTITY_CREATED,
  ENTITY_UPDATED,
  ENTITY_DELETED
} from 'state/type'

let store = null

export function setup (tarStore) {
  store = tarStore
}

export function emitEntityCreatedEvent (model, data) {
  return store.commit(ENTITY_CREATED, { model, data })
}

export function emitEntityUpdatedEvent (model, data) {
  return store.commit(ENTITY_UPDATED, { model, data })
}

export function emitEntityDeletedEvent (model, data) {
  return store.commit(ENTITY_DELETED, { model, data })
}
import {
  LIKE_ENTRY,
  ...
} from 'state/type'
import {
  emitEntityUpdatedEvent,
  emitEntityDeletedEvent
} from 'state/entity-event'
import * as entryService from 'service/entry'
import * as entryRepository from 'repository/entry'

const UPDATE_STATE = 'entry/UPDATE_STATE'

export default {
  state: {},
  mutations: {
    [UPDATE_STATE] (state, newState) {
      Object.assign(state, newState)
    }
  },
  actions: {
    [LIKE_ENTRY] ({ commit }, entry) {
      return entryService.likeEntry(entry.id)
      .then(() => {
        entry.liked = true
        entry.collectionCount += 1
      })
    },
    ...,
    [ADD_ENTRY] ({ commit }, entry) {
      return entryRepository.add(entry)
    },
    [UPDATE_ENTRY] ({ commit }, entry) {
      return entryRepository.update(entry)
      .then(() => emitEntityUpdatedEvent('Entry', entry))
    },
    [DELETE_ENTRY] ({ commit }, entryId) {
      return entryRepository.remove(entryId)
      .then(() => emitEntityDeletedEvent('Entry', { id: entryId }))
    }
  }
}

所有界面上对实体的变更操作都通过 dispatch action 来完成

完全通过事件传递行为

this.$store.dispatch(DELETE_ENTRY, entryId)
// src/state/module/common/entry.js
[DELETE_ENTRY] ({ commit }, entryId) {
  return entryRepository.remove(entryId)
  .then(() => emitEntityDeletedEvent('Entry', { id: entryId }))
}
[ENTITY_DELETED] (state, info) {
  if (shouldReact(info)) {
    state.list = state.list.filter(item => item.id !== info.data.id)
  }
}

❤️ Thanks ❤️

谢谢大家陪我讲完最后一次技术分享

我去好好做掘金 juejin.im

我相信每一个新技术的出现的最终目的是创造价值

如果一个技术真的好

我就会推荐它,宣传它,帮助它普及给更多的开发者

---

用开放的心态去尝试、学习、接受新事物

这即是我做掘金的态度,也是我做技术的态度

Made with Slides.com