谈谈 vue 和 vuex

最近一段时间写了两个玩意, 一个是基于 PEG.js 的 XML Parser, 前面有一条博客说了 PEG.js 这东西, 事后自己也模仿着写出了这个 XML 解析器, 感觉并不难, 写着玩玩而已.

另外, 最近花时间特别多的另一件事就是写了一个 RSS 订阅器. 一开始写这个订阅器, 心想上一个项目代码不忍直视, 感觉自己需要写一些能拿出手的代码, 加上学校课程刚好要求做一些东西, 以及自己最近迷上了使用 RSS 订阅器这个东西(这么多理由=.=), 于是就自己动工开搞.

目前订阅器已经基本完工了, RSS 订阅器的网址是 www.enjoyrss.com, 项目开源在 Github 上面, 对于对 RSS 有兴趣或者想学习 Vue2, Vuex 或 Angular1 的人可能会有一些帮助. 额对了, 还有就是后端用的是 Koa2, 前后端鉴权专门在上一篇博客提了下, 想了解 Koa2 的人也可以看下.

数据流动问题

其实每次做一个新的东西的时候, 我都会尽量尝试去使用各种新的技术和用法. 这样才能学到更多的东西. 在这个 RSS 订阅器中, 一开始我只是把自己认为的各种 Angular 最佳实践在项目中都运用了下, 想写出能够体现自己 Angular 水平的代码, 为此前面还写了一篇博客说一些我认为的哪些算是最佳实践. 但其实, 对 Angular 使用已经相当熟悉的我, 并没有在这一次中收获什么新的知识. 要说有, 大概就是前面那博客提到的一些 Angular 最新版本的一些新特性例如 Component 之类的吧, 然而自己并没有花时间去看.

在这个项目中, 另外的一点感受就是 Angular 的跨组件通信难题. 确切的说我觉得这个项目并不适合使用 Angular 来写. 例如

website.png

左边有一个订阅源栏, 它的未读数量要相应右侧的点击文章, 标记全部已读等事件. 它的订阅源列表也要对右侧的订阅和取消订阅事件做出相应. 为了缩减频繁的跨组件通信, 我将下方状态栏直接拆分成三条, 由各自的组件提供其状态栏覆盖原默认只有背景色的状态栏. 但跨组件通信仍然存在, 单单左侧面板就存在着五个事件监听.

$scope.$on('EXPAND', () => vm.expand = !vm.expand)
$scope.$on('FOLD', () => vm.expand = false)
$scope.$on('ADD_FEED', (event, data) => {
  if (vm.feeds.default) {
    vm.feeds.default.push(data)
  } else {
    vm.feeds['default'] = [data]
  }
})
$scope.$on('DELETE_FEED', (event, data) => {
  vm.feeds = _.mapObject(vm.feeds, feeds => feeds = _.filter(feeds, feed => feed.feed_id !== data.feed_id))
})
$scope.$on('READ_POST', (event, data) => {
  vm.feeds = _.mapObject(vm.feeds, feeds => _.each(feeds, feed => feed.feed_id === data ? feed.unread-- : ''))
})

我需要监听折叠事件, 这个动作在其他组件被触发. 需要监听添加订阅源和取消订阅源事件, 并修改订阅源列表. 需要监听已读事件, 并对相应订阅源的未读文章数做减1操作...

在有些应用场景, 存在着需要大量父子组件通信, 兄弟组件通信, 以及没有父子和兄弟关系的组件之间的通信的行为, 这种时候, Angular 虽然也能解决, 但是不得不说 Angular 这种频繁的跨组件通信很容易产生问题, 特别是当一个组件可以被多个组件修改的时候.

Angular 是双向数据绑定, 现在也有一种叫单向数据流的思想很火.

flow.png

当多个视图依赖同一份状态, 一个状态可能受到多个视图的动作改变的时候, 数据流动就会开始变得混乱起来, 而在这个单向数据流里面, state 是我们的数据, view 是我们的视图, actions 是触发数据更改的可能方式. 试想, 如果我们把整个应用的数据都提取出来存放在一棵树里面, 组件的数据从该树获得, 而这个组件想要修改数据时, 就得通过另外一个东西即 actions 来触发 state 的更改, 而 state 的更改又自动同步的影响组件的数据. 这样的话, 上面我们说的那些问题会变得非常容易解决起来, 如果你还理解不了的话, 可以看下我后面怎么说的.

Angular 的应用场景

既然有 Angular 做不太来的事, 那么 Angular 到底适合用来做什么, 不适合做什么呢? 关于这点, 由于我没有接触真正的项目开发, 我只能谈谈自己的一些个人看法

Angular 的适用场景

首先, 结论是 Angular 最适合用来做 CRUD 类型的针对桌面浏览器端的单页面应用(SPA)

CRUD

CRUD 即 Create, Read, Update, Delete. CRUD 类型, 即应用涉及的数据查询更改较为频繁的应用. 得益于双向绑定机制, 我们不需要做任何处理就可以实现 View 和 ViewModel 的同步, 我们只要在 View 层使用 ngModel 绑定变量, 就可以在控制器里直接使用它而无需担心它的值是否是我们需要的最新的值. 在 CRUD 应用中我们使用 Angular 这一类 MVVM 框架开发就会非常舒畅. 如果你不能感受到, 那么你可以试试用 React 去写一个表单的管理, 你需要监听每个 input 上面的 onChange 事件, 然后通过 setState 来修改, 并最终在该组件中更新修改. 相比起来, ngModel 简单得不能再简单了. 另外, 我们可以直接在 HTML 中使用诸如 ng-show, ng-repeat 等一类指令, 简单粗暴. 数据的变化和显示都变得非常简单.

桌面浏览器端

之所以要特别提着一点, 是因为 Angular 体积不小. 这个 RSS 订阅器我桌面浏览器端用 Angular1 开发, 移动端用 Vue2 开发, 都做了打包压缩操作, 开启了 Gzip, 最后情况是 Angular 需要加载的 JS 以及各种辅助类库加起来在 250KB 左右.

Angular.png

而 Vue 只要 100KB 左右(图中的 build.js 文件合并了CSS文件), 不到 Angular 一半的大小.

vue.png

当然, Angular 体积大是有原因的, 它本身就是一套大而全的框架, 拿体积和 Vue 说事是不太合适的. Vue 非常轻, 专注于 View 这一层, 而 Angular 自带了路由, 内置了 HTTP 的处理模块, 内置了 promise, 还集成了一个简化版的 jq... 如果你刚好 Angular 这些东西你都要用到, 那么 Angular 也是一个不错的选择了, 但如果你只是用到它的一部分或一点点功能并且你追求文件大小, 那么你可能要考虑一下.

SPA

这点没什么好说的, Angular 就是为这个而生的. Angular1 目前在全球中还是有很多的市场, 并且由于 Angular1 已经相当成熟稳定, 社区支持活跃, 而且是 Google 在维护, 估计未来几年仍然会由较大规模的应用, 目前国内有饿了么移动端, 微信网页版以及七牛, Coding 等在使用,

感觉说了一通废话, 还是看看 Google 怎么说的吧.

Google 在 AngularJS 下批注的是 "HTML enhanced for web apps!"

AngularJS.png

下方有说明 Why AngularJS?

HTML is great for declaring static documents, but it falters when we try to use it for declaring dynamic views in web-applications. AngularJS lets you extend HTML vocabulary for your application. The resulting environment is extraordinarily expressive, readable, and quick to develop.

其实感觉还不如我说的明白 =.=

Angular 的不适用场景

个人认为, Angular 在下面几点场景中不合适

  • 移动端

    其实 Angular 开发起移动端的 Webapp 很合适, 就是 Angular 的体积不太合适... 过去的可能要考虑迁移的成本以及必要性, 新的可能需要考虑下更合适的选项比如 Vue. 感觉在未来的移动端 Webapp 上面, Angular 的市场会逐渐降低

  • 跨组件通信频繁

    当你写的 Angular 应用中涉及较多的跨组件通信的时候, 你就应该考虑这个用 Angular 是不是真的合适了. 当然其实这个事情应该在最开始的时候就该考虑清楚了. 跨组件通信的问题在 Flux 中并不存在, Flux 能够轻松应对这种问题

谈谈 Vue.js

订阅器移动端我用 Vue.js 来做. 接触了 Angular, 学起 Vue 也特别轻松, 三两下就搞定了 Vue 和 Vue-resource. Vue 和 Angular 的相似度比我想象的还要高, 像 vue-resource 这个和 ngresource 用起来没啥区别.

// ng-resource
(function () {
    angular
        .module('app')
        .factory('User', $resource => {
            return $resource('/api/user', {}, {
                update: {
                    method: 'PUT'
                },
                logout: {
                    method: 'POST',
                    url:    '/auth/logout'
                }
            })
        })
}())


// vue-resource
const User = Vue.resource('/api/user', {}, {
    update: {
        method: 'PUT'
    },
    logout: {
        method: 'POST',
        url:    '/auth/logout'
    }
})

export User

(⊙o⊙)… 我直接复制粘贴过去都 OK 了...

另外, 像 v-showng-show ,v-forng-for 之类的就更不用多说了.

但是, 不得不说一点, Vue 做的更好.

例如, Vue 可以把 v-for 提出来

<ul>
  <template v-for="item in items">
    <li>{{ item.msg }}</li>
    <li class="divider"></li>
  </template>
</ul>

例如, Vue 可以使用 v-else

<div v-if="Math.random() > 0.5">
  Sorry
</div>
<div v-else>
  Not sorry
</div>

虽然 Angular 也可以做这些事, 但是我说了, Vue 做的更好.

当然了, Vue 不完全是在 Angular 上面重造轮子, Vue2 引入了虚拟 DOM, 又轻又快, 虽然 Vue 可以像 Angular 一样写 MVVM 应用. 但我个人更喜欢 Vuex 即 Flux 的写法, 这个后面我会说.

在使用 Vue 的过程中, 有几点感受特别深.

例如, 在 Vue 中, 当我在 /post/123 中跳到 /post/456 时, 我发现虽然路径变化了, 但是页面根本没重新渲染. 这对于从 Angular 过来或者说第一次接触 Vue 的人来说应该感觉一脸懵逼.

当然, 很快就查找到了答案, Vue 要求我们手动去更新数据. 至于为什么这么做, 是因为 Vue 可以在我们获取新数据后在原 DOM 上面做最小的修改, 这种修改的成本自然会比整个页面的销毁和重绘要节能和迅速的多. Vue 配合虚拟 DOM 技术可以比对找出最小的修改, 使得 DOM 的修改成本降到最低, 这大概就是 React 和 Vue 在视图上面更新之高效的秘诀了.

不过可能有人会问了, 这样不会很麻烦吗, 要手动监听路由变化然后重新获取数据. 感觉也还好其实, 例如下面这个例子, 这个例子在后面会进行分析.

export default {
    computed: mapGetters({
        post: 'post'
    }),

    async beforeRouteEnter (to, from, next) {
        await store.dispatch('getPost', to.params.id)
        await store.dispatch('read')
        next()
    },

    watch: {
        async '$route' (to, from) {
            await store.dispatch('getPost', to.params.id)
            await store.dispatch('read')
        }
    },
    
    components: {
        headbar, postOption
    }
}

可以看到 watch 的部分和 beforeRoute 的部分重复了, Vue 给的说法好像是封装为一个函数来执行, 我感觉还好, 就这样子算了...

上面都是 Vuex 的写法, 其实最开始我还是按 MVVM 那一套写法写的, 写完了之后才开始改到 Vuex 上面去.

另外, Vue 的动画和过渡效果虽然和 Angular 也是很像, 但比 Angular 强大很多. 再者 Vue 支持服务端渲染, 服务端渲染好不好用我不知道, 但确实解决了一些问题.

另外, 还有很多很不错的一些技巧啊, 比如 slot 这个超实用感觉. 不过我只是简单的用了下而已.

例如我定义了这样一个组件

<template>
    <div id="head" v-bind:class="{expand2: expand}">
    <header>
        <span class="icon-paragraph-left" v-on:click="move()" ></span>
        <h2><slot>主页</slot></h2>
    </header>
</div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
export default {
    computed: mapGetters({
        expand: 'expand',
        user: 'user'
    }),
    
    methods: {
        move: function() {
            if(this.expand) {
                this.$store.commit('COLLAPSE')
            } else {
                this.$store.commit('EXPAND')
            }
        }
    }
}
</script>

<style lang="sass">

</style>

这里省去 CSS 样式, 反正放出来也没用, 做好这样一个组件之后, 下面这样用的结果

<!-- src -->
<headbar>文章</headbar>
<!-- dist -->
<header>
  <span class="icon-paragraph-left"></span>
  <h2>文章</h2>
</header>

如果 headbar 里面不加任何东西, 那就会使用默认的值主页. slot 可以使用多个, 可以编号. 具体的用法可以查看官方文档, 貌似 Angular 也有类似的东西, 没了解过...

上面的例子中你可能注意到了, 我把 CSS, HTML, JavaScript 都写到了一起去了, 这就是 Vue 的 vue 单文件写法. 比起 Angular 里面我要建立一个文件夹, 文件夹里面放模板, 控制器, 样式表的做法, 我更喜欢 Vue 的做法, 特别好用, 当然了 vue 文件的编译依赖于 webpack. 我们还可以在 vue 文件中使用 pug 来书写 html, 使用 ts 来书写 vue, 使用 sass 来书写 css, 好用到爆.

估计再也回不去 Angular 了...

谈谈 Vuex

我要单独把 Vuex 拿出来说一说, 因为它真的太好用了.

首先, 什么是 Vuex?

Vuex 是一个专门为 Vue.js 应用设计的 状态管理模型 + 库。它为应用内的所有组件提供集中式存储服务,其中的规则确保状态只能按预期方式变更。它可以与 Vue 官方开发工具扩展(devtools extension) 集成,提供高级特征,比如 零配置时空旅行般(基于时间轴)调试,以及状态快照 导出/导入。

可以看看官方文档的说明

Vuex 和 Flux 类似, 大概可以用下面这个图来表示

vuex.png

一次完整的 vuex 数据流动大概如下:

  • vue 组件通过 dispatch 一个 actions 或者直接 commit 一次 mutations 来间接修改 state.

    通常我们在进入一个页面的时候, 需要获取一些请求资源, 我们可以通过 beforeRouteEnter 钩子在路由进入前获取数据, 例如(省去了部分代码):

    // post.vue
    async beforeRouteEnter (to, from, next) {
      await store.dispatch('getPost', to.params.id)
      await store.dispatch('read')
      next()
    }
    

    在路由进入前, 触发了一个 getPost 事件.

  • actions 处理数据逻辑

    专门定义一个文件负责处理 actions, 在 Vue 中, actions 是用来处理异步的, 例如这个 getPost 事件:

    // actions.js
    export const getPost = ({ commit }, id) => {
        return Post.get({
            id
        }).then(res => {
            commit(types.RECEIVE_POST, res.data.data)
        })
    }
    

    获取到数据后, commit 一次修改, 这里使用了常量名, 其实对应的就是 RECEIVE_POST 这个字符串, 我们新建一个类型文件专门处理 mutations 的常量名和字符串的映射. 这主要是为了书写方便和重构方便吧我猜, 其实在 Redux 我们也是类似的处理.

    // mutation-types.js
    export const RECEIVE_POST = 'RECEIVE_POST'
    
  • module 接收 mutation 并处理

    module 是为了更合理的划分 store, 这点和 redux 一样. 其用法和 redux 也是很相像的

    // post.js
    import * as types from '../mutation-types'
    
    const state = {
        post: {}
    }
    
    const mutations = {
        // 获取文章信息
        [types.RECEIVE_POST](state, post) {
            state.post = post
        }
    }
    
    export default {
        state,
        mutations
    }
    

    自此, 我们就完成了一次更新 state 操作.

  • 通过 getters 获取数据

    那数据更新是更新了, 怎么用呢, 这时候就要依靠 getters 了.

    // getters.js
    export const post = state => state.post.post
    

    就一句话就可以了, 但这只是简单的获取数据而已, 你也可以在里面对数据进行一些处理, 比如过滤, 排序之类的, 但要注意, 不能修改原 state 上面的数据. 这点和 redux 又是一致的.

最后完整的一个组件的 JS 部分就是这样:

import { Post } from '../resource/resource.js'
import headbar from '../components/headbar.vue'
import postOption from '../components/post-option.vue'
import store from '../store'
import { mapGetters, mapActions } from 'vuex'
export default {
    computed: mapGetters({
        post: 'post'
    }),

    async beforeRouteEnter (to, from, next) {
        await store.dispatch('getPost', to.params.id)
        await store.dispatch('read')
        next()
    },

    watch: {
        async '$route' (to, from) {
            await store.dispatch('getPost', to.params.id)
            await store.dispatch('read')
        }
    },
    
    components: {
        headbar, postOption
    }
}

通过 mapGetters 和 mapActions 我们可以很方便的绑定 acitons 和 getters.

上面还有个地方没说的就是 read 这个事件, 它还有一个和 getPost 事件很大区别的地方, 在于它会触发多个 state 的修改.

结合 vuex, 我已经轻松的实现了最近未读文章, 订阅源列表, 收藏文章, 广场热门订阅源的缓存自动更新从而实现了这四个页面只要一次请求. 可以访问网站体验下.

enjoyrss.png

就未读而言, 首屏这里就由未读信息的显示了, 当我们进入一篇文章时, 触发了 read 这个 actions, 这个 action 只是做一个异步的 http 请求操作, 然后触发一个 mutation. 关键的地方是我们需要在 posts.js 这个文件中也监听这个 mutation , 然后对其 state 进行操作. 注意这里说的是 posts.js 而不是 post.js.

再换个例子, 取消订阅, 当用户在一个订阅源处点击取消订阅时, 由 action 发出的 mutation, 我们需要最近未读那里移除这个订阅源, 需要订阅源列表也移除这个订阅源, 需要广场那里热门订阅源的这个订阅源的订阅人数减一. 并且要注意的是, 这些对象是分开的. 在 vuex 中, 我们只要注意 payload 即 muitations 的传递数据以及在对应需要修改的 module 中监听 mutation 就好了. 这是非常自然的事情. 换做 Angular, 各种 on 和 emit 以及 broadcast 的能忍? 并且 vuex 提供了状态管理, 但是 angular 没有, 我们要自己缓存数据, 然后要去修改缓存, 这些在 angular 里面写起来特别不优雅.

本来还想对比以下 vuex 和 redux, 但由于篇幅有限, 加之我经验还不足, 且 redux 已经忘得差不多且没有重拾欲望, 想要了解下 redux 可以看看我这篇博客. vuex 也是 flux 的实现, 而且也是和 redux 一样采用单一的一棵状态管理, 因此两者自然由很多相似之处.

最后

昨晚班游轰趴, 才睡了三个多钟, 中午补了四个小时觉, 写了这么多我感觉已经快撑不下去了, 其实我个人一直都认为要多做少说, 我不喜欢和人争论技术, 因为我觉得自己很菜很容易说错话, 虽然意识到错误了我可以去学习这是一种提升, 但那也只是在对方意识到你错的时候, 不然就很容易误导别人了, 博客也一样, 我写博客更多就是为了记录一下, 可能也存在很多错误的地方.

用了 Vue 之后, 感觉再也不会去用 Angular 了, 至于 Angular2, 额, 还是再看吧 =.=