单文件组件下的vue,可以擦出怎样的火花
Vue.js
leftstick
1人收藏 2880次学习

单文件组件下的vue,可以擦出怎样的火花

原文链接:单文件组件下的vue,可以擦出怎样的火花

2016注定不是个平凡年,无论是中秋节问世的angular2,还是全面走向稳定的React,都免不了面对另一个竞争对手vue2。喜欢vue在设计思路上的“先进性”(原谅我用了这么一个词),敬佩作者尤小右本人的“国际范儿”,使得各框架之间的竞争略显妖娆(虽然从已存在问题的解决方案上看,各框架都有部分相似之处)。

因为vue2已经正式release,本教程做了一些修改(针对vue2)
所谓设计上的先进性,以下几点是我比较喜欢的:

数据驱动的响应式编程体验

不同于AngularJS里基于digest cycle的脏检查机制,执行效率更高。内部基于Object.defineProperty特性做漂亮的hack实现(而且不支持IE8,大快人心)。更多细节,看这里

因为这个机制的出现,我们再也也不需要顾虑双向绑定的效率问题;亦或是像React那样搞什么immutability(对这块感兴趣可以看[[译]JavaScript中的不可变性](https://segmentfault.com/a/11...,因为Object.definePropery洞悉你的一切,妈妈再也不用担心你忘记实现shouldComponentUpdate了.

我猜不来个栗子你是不会买账的!假设我们有一个字段fullName,它依赖其他字段的变化,在AngularJS里,我们这样写:

$scope.user = {
    firstName: '',
    lastName: ''
};
      
$scope.fullName = '';
      
$scope.$watch('user', function(user) {
    $scope.fullName = user.firstName + ' ' + user.lastName;
}, true);

若是vue,写法如何?

data: {
    firstName: '',
    lastName: ''
},
computed: {
    fullName: function () {
        return this.firstName + ' ' + this.lastName;
    }
}

相对于AngularJS里命令式的告诉框架,fullName一定要监视user对象的变化(注意里面还是deepWatch,效率更差),并且随之改变;vue以数据驱动为本质,声明式的定义fullName就是由firstName和lastName组成,无论怎么变化,都是如此。这种写法,更优雅有没有?

单文件组件模式

还在为一堆代码文件,到底哪个是JavaScript逻辑部分、哪个是css/less/sass样式部分、哪个是html/template模板部分;他们又该如何组织,怎么“编译”、如何发布?

有了单文件组件范式,配合webpack,组件自包含,完美、没毛病!还有强大的开发工具支持,看着都赏心悦目,来个效果图:

用了这么多版面,说了一些好处,那么当我们真正需要面对一个应用,需要上规模开发时,vue又能带来怎样的变化呢?憋了几天,我想今天就写一个小游戏来试试整体感觉,先来看看我们今天的目标:

 

完整源码在这里:vue-memory-game

看了效果,知道源码在哪里了,那我们继续?

组件分解

Break the UI into a component hierarchy,相信写过React的朋友对这句话都不陌生,在使用一种基于组件开发的模式时,最先考虑,而且也尤为重要的一件事,就是组件分解。下面我们看看组件分解示意图:

我们根据分解图,先把未来要实现的组件挨个儿列出来:

  1. Game, 最外层的游戏面板

  2. Dashboard, 上面的logo,游戏进度,最佳战绩的容器

  3. Logo,左上角的logo

  4. MatchInfo, 正中上方的游戏进度组件

  5. Score, 右上角的最佳战绩组件

  6. Chessboard, 正中大棋盘

  7. Card, 中间那十六个棋牌

  8. PlayStatus, 最下方的游戏状态信息栏

带薪搭环境(又来了?^^)

#创建目录
mkdir vue-memory-game

#创建一个package.json
npm init

#进入目录
cd vue-memory-game

#安装开发环境依赖
npm install --save-dev babel-core babel-loader babel-plugin-transform-object-rest-spread babel-plugin-transform-runtime babel-preset-es2015 css-loader file-loader html-webpack-plugin style-loader vue-hot-reload-api vue-html-loader vue-loader vue-style-loader webpack webpack-dev-server

#安装运行时依赖
npm install --save vue vuex

这里开发环境依赖内容有点多,但不要害怕,大部分时候你不太关心里面的东西(当然,如果你要进阶,你要升职、加薪、迎娶白富美,那你最好搞清楚他们每一项都是什么东西)
另外在运行时依赖里不仅看到了vue,还看到了vuex。这又是个什么鬼?先不要慌,也别急着骂娘,我们来考虑一个问题,试想下,整个游戏按照上面分解的组件开发时,各个组件之间想必在逻辑上多少是有关系的,譬如:Card在Chessboard中的翻牌、配对,当然会影响到上方的Dashboard和下面的PlayStatus。那么“通信”,就成了待解决问题。

以前我们试图用事件广播来做,但随之而来的问题是,在应用不断的扩展、变化中,事件变得越来越复杂,越来越不可预料,以至于越来越难调试,越来越难追踪错误的root cause。这当然不是我们想要的,我们希望应用的各个部分都易维护、可扩展、好调试、能预测。

于是一种叫单向数据流的方式就冒了出来,用过React的人想必也不陌生,各组件的间的数据走向永远是单向、可预期的:

这当然也不是facebook的专利,都说vue牛逼了,那一定也有一个单向数据流的实现,就是我们这里用到的vuex。

掌握目录结构

vue-memory-game
├── css
│   └── main.css
├── img
│   ├── ...
│   └── zeppelin.png
├── js
│   ├── components
│   │   ├── card
│   │   │   ├── Card.vue
│   │   │   └── Chessboard.vue
│   │   ├── dashboard
│   │   │   ├── Dashboard.vue
│   │   │   ├── Logo.vue
│   │   │   ├── MatchInfo.vue
│   │   │   └── Score.vue
│   │   ├── footer
│   │   │   └── PlayStatus.vue
│   │   │
│   │   └── Game.vue
│   │
│   ├── vuex
│   │   ├── actions
│   │   │   └── index.js
│   │   ├── getters
│   │   │   └── index.js
│   │   ├── mutations
│   │   │   └── index.js
│   │   └── store
│   │       ├── index.js
│   │       └── statusEnum.js
│   │
│   └── index.js
│
├── index.html_vm
├── package.json
├── webpack.config.js
└── webpack.config.prod.js

 

配置webpack

看了上面的文件目录结构图,要配置webpack,已经没有难度了,直接上代码:

var path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: {
        index: './js/index.js'
    },
    output: {
        path: path.resolve(__dirname, 'build'),
        filename: '[name].[hash].bundle.js'
    },
    debug: true,
    devtool: '#eval-source-map',
    module: {
        loaders: [
            {
                test: /\.vue$/,
                loader: 'vue',
                exclude: /node_modules/
            },
            {
                test: /\.js$/,
                loader: 'babel?{"presets":["es2015"],"plugins": ["transform-object-rest-spread"]}',
                exclude: /node_modules/
            },
            {
                test: /\.css$/,
                loader: 'style!css'
            },
            {
                test: /\.(png)$/,
                loader: 'file'
            }
        ]
    },
    vue: {
        loaders: {
            js: 'babel?{"presets":["es2015"],"plugins": ["transform-object-rest-spread"]}',
            css: 'vue-style!css'
        }
    },
    resolve: {
        root: [
            path.resolve(__dirname),
            path.resolve(__dirname, 'js')
        ],
        extensions: [
            '',
            '.js',
            '.vue'
        ],
        alias: {
            vue: 'vue/dist/vue.min.js'
        }
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: 'index.html',
            inject: 'body',
            template: 'index.html_vm',
            favicon: 'img/favicon.ico',
            hash: false
        })
    ]
};

我在这儿没有过多的涉及webpack的基本使用,看官网文档学习效果最佳,或者阅读webpack-howto都行

这里我们用了html-webpack-plugin里自动将编译后的bundle注入index.html_vm里,并生成最终的html。所以index.html_vm作为模板,我们也要先写出来:

touch index.html_vm

再将如下内容填入其中:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>vue-memory-game</title>

    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimal-ui"/>

    <meta name="renderer" content="webkit"/>
    <meta http-equiv="Cache-Control" content="no-siteapp" />
</head>
<body>
    <!-- 这里以一个div#application作为入口,vue2使用body作为入口已废弃 -->
    <div id="application">
        <!-- 注意,这里就是我们整个游戏应用的组件Game -->
        <Game></Game>
    </div
</body>
</html>

 

编写应用入口

在webpack.config.js里,我们看到了

entry: {
    index: './js/index.js'
}

这也是本章整个vue应用的入口:

//载入一些初始化的简单样式
import 'css/main.css';
import Vue from 'vue';
import Game from './components/Game';
//store就是vuex里用来存储组件树用到的所有状态的对象
import store from 'js/vuex/store';

//the main entrance
/* eslint-disable no-new */
new Vue({el: '#application', components: {Game}, store});//在这里注入store,vue会自动将实例注入到所有子组件中

本章代码本采用ES2015语法编写,譬如:components: {Game},相当于components: {Game: Game},这是enhanced-object-literals

我在这里没有过多介绍vue2的基本使用,不过我尽量列出可能涉及的知识点,便于学习

全局初始化样式

上面js/index.js里第一行就引用了全局初始化样式的css/main.css,我们就先把它写了吧:

* {
    box-sizing: border-box;
    padding: 0;
    margin: 0;
}

html, body {
    width: 100%;
    height: 100%;
}

body {
    display: flex;
    justify-content: center;
    align-items: center;
}

 

本章大量使用flexbox来布局排版,不了解的可以学习一下(虽然我也是半吊子)

这段css/main.css之所以能被加载成功,多亏了webpack.config.js中的这段配置:

{
    test: /\.css$/, 
    loader: 'style!css'
}

得利于css-loader和style-loader,一切成为可能。

第一个组件Game

刚才的入口js/index.js里,我们注入了游戏主界面组件js/components/Game,下面就来创建它吧:

<template>
    <div class="game-panel">
       TBD...
    </div>
</template>

<script>
export default {
    //TBD
}
</script>

<style scoped>
.game-panel{
    width: 450px;
    height: 670px;
    border: 4px solid #BDBDBD;
    border-radius: 2px;
    background-color: #faf8ef;
    padding: 10px;
    display: flex;
    flex-direction: column;
}
</style>

单文件组件的魅力,到这里终于可以瞄一眼了,第一部分是模板<template></template>,第二部分是逻辑<script></script>,第三部分是样式<style></style>。

这里<style>上还有个scoped属性,表示样式仅对当前组件以及其子组件的模板部分生效。
单文件组件的加载由webpack.config.js中的配置:

{
    test: /\.vue$/,
    loader: 'vue',
    exclude: /node_modules/
}

然后因为我们为vue-loader配置了一些参数,如下:

vue: {
    loaders: {
        js: 'babel?{"presets":["es2015"],"plugins": ["transform-object-rest-spread"]}',
        css: 'vue-style!css'
    }
},

所以我们可以在.vue文件中使用ES2015语法进行开发。

写了这么多,不运行一下,都说不过去了,现在请打开package.json文件,为其添加如下代码:

"scripts": {
    "start": "webpack-dev-server --content-base build/ --hot --inline --host 0.0.0.0"
}

然后在项目根目录调用:

#启动调试
npm start

浏览器访问:http://localhost:8080/,可以看到如下效果:

注意js/components/Game里的两个"TBD"部分,我们现在来补齐:

<template>
    <div class="game-panel">
       <!-- 组装上、中、下三个部分组件 -->
       <Dashboard></Dashboard>
       <Chessboard></Chessboard>
       <Status></Status>
    </div>
</template>

<script>
import Dashboard from './dashboard/Dashboard';
import Chessboard from './card/Chessboard';
import Status from './footer/PlayStatus';

//从vuex中拿出mapActions工具
import { mapActions } from 'vuex';
//状态枚举
import { STATUS } from 'vuex/store/statusEnum';

export default {

    //通过mapActions将actions映射到methods里
    methods: {
        ...mapActions([
            'updateStatus',
            'reset'
        ])
    },
    
    //生命周期钩子,组件实例创建后自动被调用
    created: function() {
        //触发一个状态更新的action
        this.updateStatus(STATUS.READY);
        //触发一个游戏重置的action
        this.reset();
    },
    //子组件注入
    components: {Dashboard, Chessboard, Status}
}
</script>
<style scoped>
.game-panel{
    width: 450px;
    height: 670px;
    border: 4px solid #BDBDBD;
    border-radius: 2px;
    background-color: #faf8ef;
    padding: 10px;
    display: flex;
    flex-direction: column;
}

@media screen and (max-width: 450px) {
    .game-panel{
        width: 100%;
        height: 100%;
        justify-content: space-around;
    }
}
</style>

子组件注入的方式,和angular2类似,有木有?这里vuex/actions/controlCenter.js和vuex/store/statusEnum.js,我就不分别在这里写源码了,内容很简单,官网基本教程读完理解无障碍。


因为功能比较简单,大部分组件仅样式有差别,为了节省时间,我只挑一个最具代表性的components/card/Chessboard.vue来讲讲

components/card/Chessboard.vue

<template>
    <div class="chessboard">
        <!-- 遍历Card组件,为每个Card传入option定制其状态;并接收一个flipped的事件hook -->
        <Card v-for="cart in cards" :option="cart" v-on:flipped="onFlipped"></Card>
    </div>
</template>

<script>
//引入Card子组件
import Card from './Card';

//从vuex中拿出mapActions和mapGetters工具
import { mapActions, mapGetters } from 'vuex';

import { STATUS } from 'js/vuex/store/statusEnum';

export default {

    data: function() {
        return {
            //初始化一个空的lastCard属性
            lastCard: null
        };
    },
    
    //通过mapGetters映射各getter为computed属性
    //可以响应vuex对state的mutation
    //我们压根儿不用关心这些数据什么时候被改的
    //只管拿来用,数据和UI就是up-to-date
    //这个feel倍儿爽
    computed: {
        ...mapGetters([
            'leftMatched',
            'cards',
            'status'
        ])
    },

    methods: {
    
        //通过mapActions映射各action为local method
        ...mapActions([
            'updateStatus',
            'match',
            'flipCards'
        ]),

        onFlipped: function(e) {
            //游戏开始后,第一次翻牌时,开始为游戏计时
            if(this.status === STATUS.READY){
                this.updateStatus(STATUS.PLAYING);
            }
            //如果之前没有牌被翻开,把这张牌赋值给lastCard
            if(!this.lastCard){
                return this.lastCard = e;
            }
            //如果之前有牌被翻了,而且当前翻的这张又正好和之前那张花色相同
            if(this.lastCard !== e && this.lastCard.cardName === e.cardName){
                //将lastCard置空
                this.lastCard = null;
                //触发配对成功的action
                this.match();
                //如果棋盘内所有牌都配对完毕,触发状态变更action,并告知已过关
                return this.leftMatched || this.updateStatus(STATUS.PASS);
            }
            //之前有牌被翻了,当前翻的这张花色与之前的不同
            let lastCard = this.lastCard;
            this.lastCard = null;
            setTimeout(() =>{
                //一秒钟后将之前那种牌,当前牌再翻回去
                this.flipCards([lastCard, e]);
            }, 1000);
        }

    },
    //这里只用到了Card子组件
    components: {Card}
}
</script>

<style scoped>
.chessboard{
    margin-top: 20px;
    width: 100%;
    background-color: #fff;
    height: 530px;
    border-radius: 4px;
    padding: 10px 5px;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    align-content: space-around;
}

.container:nth-child(4n){
    margin-right: 0px;
}

@media screen and (max-width: 450px) {
    .chessboard{
        height: 480px;
        padding: 10px 0px;
    }
}
@media screen and (max-width: 370px) {
    .chessboard{
        height: 450px;
    }
}
</style>

 

写在最后,整体写完的效果,可以在这里把玩。

线上demo另加入了排行榜功能,如需查看源码的,请git checkout stage-1切换到stage-1分支

整个项目结构清晰,尤其单文件组件的表现力尤为突出,使得每个组件的逻辑都没有过于复杂,而且在vuex的统筹下,action -> mutation -> state的单向数据流模式使得所有的变化都在可控制、可预期的范围内。这点非常利于大型、复杂应用的开发。

另,vue2已经问世,对于之前跟着一起操作过vue版的朋友,发现源码里有疑惑的变更,请参考升级指南

vue作为一个仅7000多行的轻量级框架而言,无论生态系统、社区、工具的发展都非常均衡、成熟,完全可以适应多业务场景以及稳定性需求。而且,vue2中对服务器端渲染的支持(而且是前所未有的流式支持),使得你不必再为单页应用的SEO问题、首屏渲染加速问题而担忧。欲知详情,看SSR

总的来说,2016年,vue让你的编程生涯,又多了一丝情怀(原谅我实在找不到什么好词儿了)。

如果关于代码有疑问,欢迎issue,也欢迎start

加入1KE学习俱乐部

1KE学习俱乐部是只针对1KE学员开放的私人俱乐部
标签:
JavaScript Vue.js