5
抛开之前自己捯饬的小项目,工作之后第一次独自承担一个完整的 vue 项目。记录一下所学习到的以及自己沉淀下来的东西。

初始化项目

通常会使用 vue-cli3 脚手架工具进行项目的初始化,完成之后添加自定义的 webpack 配置文件 vue.config.js

// vue.config.js
module.exports = {
    //打包之后不出现 404
    publicPath: './',

    devServer: {
        // 开发端口
        port: 3000,

        // 请求转发以及重定向路径
        proxy: {
            '/api': {
                target: 'http://localhost:3001/',
                pathRewrite: {
                    "^/api": "/"
                }
            }
        }
    }
};

vue-router

模块化路由配置

对于单页面应用来说,每个路由对应一个页面,随着应用功能的丰富,路由数量也会逐渐增多,因此,一个便于维护的路由配置是至关重要的。

将整个项目按功能划分为多个模块,每个模块内部分别对自身的路由进行管理。例如“用户”模块下,可能有 /#/user/#/user/setting/#/user/list 等这几个路由,可以将这个模块下的路由(包含了相同的路由前缀 /user )单独写入一个文件 user.js 进行管理。

// user.js
export default [
    {
        // 匹配 '/#/user'
        path: '',
        component: () => import('../views/User/Index.vue')
    }, {
        // 匹配 '/#/user/setting'
        path: 'setting',
        component: () => import('../views/User/Setting.vue')
    }, {
        // 匹配 '/#/user/list'
        path: 'list',
        component: () => import('../views/User/List.vue')
    }
];

// index.js
import Vue from 'vue';
import VueRouter from 'vue-router';
import user from './user';

// 避免 router.push 相同路由的错误
const originalPush = VueRouter.prototype.push;
VueRouter.prototype.push = function push(location) {
    return originalPush.call(this, location).catch(err => err)
};

Vue.use(VueRouter);

const routes = [
    {
        path: '/user',
        component: () => import('../views/CommonWrap.vue'),
        children: user
    }
];

const router = new VueRouter({
    routes
});

export default router;

index.js 文件的根路由配置中,利用 children 属性引入了 user 模块的路由配置,但与 express 框架的模块化路由写法不同的是, vue-routerchildren 属性中的路由只能渲染父组件中 <router-view /> 的那部分。所以需要一个临时工组件来作为各模块子路由的出口。

// CommonWrap.vue
// 临时工组件,只需要提供子路由的渲染出口
<template>
    <router-view />
</template>

登录状态保持、页面访问权限

单页面应用中如何做到登录状态的保持,不同用户权限对页面的访问权限(后台可以做到数据层面的控制,路由的访问权限则需要前端去做)。

单页应用中登录状态保持和页面访问权限控制的解决办法

vuex

通常还是会进行模块化状态管理

// index.js
import Vue from 'vue';
import Vuex from 'vuex';
import user from './user';

Vue.use(Vuex);

export default new Vuex.Store({
    modules: {
        user
    }
});

// user.js

export default {
    namespaced: true,
    state: {},
    getters: {}, // (state)
    mutations: {}, // (state, payload)
    actions: {} // ({ commit })
}

个人感觉 vuexreact-redux 用起来简单方便,不用在好几个文件里找来找去……

样式

scoped

添加 scoped 属性可以让样式只在组件内生效,但是会发现有些选择器没有达到预期的效果。例如在使用 el-menu 组件时,直接设置类名 el-submenu__title 的样式没有效果,或者在父组件设置子组件中类名的样式也没法生效。

deep

利用 /deep/ 或者 ::v-deep 进行深度选择(在使用 scss 的情况下只能用 ::v-deep),就不必采用设置全局样式的方式导致类名覆盖了。

flex

实际使用 display: flex 进行布局之后发现,会出现嵌套的情况,并且这种情况是很常见的。预先将 flex 配置写成全局类名,是否是一个解决办法呢?

Element UI

多级目录(递归)

通常会将目录配置成数据的形式,数据的结构可能是下面这样

interface menu {
    name: string;
    route: string;
    icon: string;
    children: menu[];
}

显然,有 children 属性的目录代表有子目录,它需要作为 el-submenu 组件,而没有这个属性就作为 el-menu-item 组件。对于不定层数的目录结构,需要对自身进行递归。

// MenuTemplate.vue
<template>
    <div>
        <template v-for="(menu, index) in menus">
            <!-- 没有子目录,作为 el-menu-item 组件 -->
            <template v-if="!menu.children || menu.children.length == 0">
                <el-menu-item :index="menu.name" :key="index">
                    <span>{{ menu.name }}</span>
                </el-menu-item>
            </template>
            <!-- 有子目录 -->
            <template v-else>
                <el-submenu :index="menu.name" :key="index">
                    <template slot="title">
                        <span>{{ menu.name }}</span>
                    </template>
                    <!-- 递归 children -->
                    <menu-template :menus="menu.children" />
                </el-submenu>
            </template>
        </template>
    </div>
</template>

<script>
export default {
    name: 'menuTemplate',
    props: {
        menus: {
            type: Array,
            default() {
                return [];
            }
        }
    }
}
</script>

// Menu.vue
<el-menu>
    <menu-template :menus="menusData" />
</el-menu>

El-Datagrid

Element UI 分别提供了表格组件 Table 和 分页组件 Pagination,管理系统中,表格是使用最多的组件,也都是需要分页功能的,并且前端分页和后端分页的需求都会有,于是便学习 Easy UI 中的方式封装了一个符合大部分业务场景的数据表格组件 Datagrid

类似 easyui 中 datagrid 使用习惯的 element-ui 数据表格组件(el-datagrid)

但往往理想是丰满的,而现实是骨感的。随着各种各样不同的功能需要加在数据表格中,例如需要各种各样的权限条件来控制显示与否等等,对于不按套路出牌的情况,想一劳永逸是没那么容易的,还是需要在这个组件上稍作修改……

拓展 Vue.prototype

Axios

目的:

  • 统一发送 get/post 请求的参数形式
  • 在具体业务逻辑之前处理一些特定的响应状态码
  • 提前取一下 response.data

这部分我写的这个比较简单,没有做超时等错误处理……

message 和 confirm

目的:

  • 统一某些配置
  • 减少、简化业务逻辑中的重复代码
// extend.js
import axios from 'axios';
export default function(obj) {
    // 发送 ajax
    obj._ajax = function(type, url, params={}) {
        switch (type) {
            case 'get':
            case 'delete':
                return axios[type](url, { params }).then(res => {
                    // 根据后端的不同返回值提前处理错误,正常情况则返回数据
                    return res.data;
                }).catch(err => err);
            
            case 'post':
            case 'put':
                return axios[type](url, params).then(res => {
                    // 根据后端的不同返回值提前处理错误,正常情况则返回数据
                    return res.data;
                }).catch(err => err);
        }
    }
    
    // 成功消息。info、warning、error 等消息类型写法类似
    obj._success = function(message) {
        this.$message({
            type: 'success',
            message,
            duration: 1500
        });
    };
    
    // confirm 确认消息
    obj._confirm = function(message, callback) {
        this.$confirm(message, '提示', {
            confirmButtonText: '确认',
            cancelButtonText: '取消',
            type: 'warning',
            dangerouslyUseHTMLString: true
          }).then( callback ).catch( () => {});
    }
}

把增加在 obj 中的方法赋给 Vue.prototype,这样就能在组件中直接以 this[METHOD] 的形式使用 。

// main.js
import Vue from 'vue';
import extend from './utils/extend';
extend(Vue.prototype);

// Test.vue
<script>
export default {
    data() {
        return {
            id: 1
        }
    },
    methods: {
        async getDataById() {
            const res = this._ajax('delete', '/api/delete', { id: this.id });
            if (res.status == 200) {
                this._success('操作成功');
                // ……
            }
        },
        showConfirm() {
            this._confirm('确认删除***吗?', this.getDataById);
        }
    }
}
</script>

自动化部署 node.js 应用

对于单页面应用而言,打包之后就是一些静态内容,比较常规的做法是将这些静态文件部署到后端应用的指定位置,但最终目的也只是通过一个指定的请求返回一个静态的 html 文件。那么如果有一个 node.js 应用能够达到同样的目的就可以实现前后端分开部署了。

开启服务的端口

// start.js
const express = require('express');
const app = express();
const path = require('path');

app.use(express.static(path.join(__dirname, 'dist')));

app.get('/', (req, res) => {
    res.sendFile( __dirname + '/dist/index.html');
});

app.listen(3000);

关闭服务的端口

这是一段可以在 windowslinux 中运行的 node.js 关闭指定端口的代码。

// stop.js
var port = '3000';
var exec = require('child_process').exec;

if (process.platform == 'win32') {
    exec('netstat -ano | findstr ' + port, (err, stdout, stderr) => {
        if (err) {
            return;
        }
        const line = stdout.split('\n')[0].trim().split(/\s+/);
        const pid = line[4];
        exec('taskkill /F /pid ' + pid, (err, stdout, stderr) => {
            if (err) {
                return;
            }
            console.log('占用指定端口的程序被成功杀掉!');
        });
    });
} else {
    exec('netstat -anp | grep ' + port, (err, stdout, stderr) => {
        if (err) {
            return;
        }
        const line = stdout.split('\n')[0].trim().split(/\s+/);
        const pid = line[6].split('/')[0];
        exec('kill -9 ' + pid, (err, stdout, stderr) => {
            if (err) {
                return;
            }
            console.log('占用指定端口的程序被成功杀掉!');
        });
    });
}

开发环境、测试环境、正式环境

开发环境、测试环境、正式环境最基本的差异就是请求路径的不同。为了确保各环境对应请求路径的准确性,必然要通过代码来控制,vue-cli 刚好也提供了这么一套机制。

通过配置 package.json.env.*** 文件,通过环境变量判断当前应该选择的请求路径。

// package.json
{
    "scripts": {
        "build-test": "vue-cli-service build --mode test",
        "build-online": "vue-cli-service build --mode online"
    }
}

// .env.test
NODE_ENV = 'production';
VUE_APP_ENV = 'test';

// .env.online
NODE_ENV = 'production';
VUE_APP_ENV = 'online';

// setting.js
let BASE_URL = '';
if (process.env.NODE_ENV == 'production') {
    if (process.env.VUE_APP_ENV == 'online') {
        BASE_URL = 'https://online.xxx.com';
    } else {
        BASE_URL = 'https://test.xxx.com';
    }
} else {
    BASE_URL = 'http://localhost:3000'
}

服务器打包 VS 本地打包

与其他服务端语音一样,node.js 应用需要的也是一个打包之后的静态文件夹。利用 vue-cli 脚手架构建的项目,通常会利用其集成好的命令进行打包输出。

服务器打包

直接将源代码发布到服务器,安装好 package.json 中的依赖之后运行 npm run build-online 进行打包,这看起来是最简单直接的方法。我使用的自动化构建平台需要将构建机器上面的内容传输到发布机器上之后,再执行 npm run start 监听服务的指定端口。缺点是速度较慢,npm install 的耗时不稳定,node_modules 文件夹的传输很慢……

本地打包

服务器打包的优势是操作简单,但消耗的时间比较久,但对于 node.js 应用来说,有哪些是最少需要的依赖呢?仔细想想,node.js 应用只需要监听一个服务端口以及相应的静态文件就可以了,vue-cli等开发依赖,vueelement-ui等运行依赖对于只作为静态文件服务的 nodejs 应用来说,其实都不是必须的。

在发布到测试(正式)环境前,先在本地运行 npm run build-testnpm run build-online),因为不需要进行 npm install 和传输 node_modules 文件夹,所以这个时间是稳定可控的。最后将包含静态文件的 dist 文件夹一并推送到远端,就可以在服务器上面直接运行 npm run start 开启服务了。

Vue 的使用总结

在这里总结一下开发 vue 中用到的特性以及对这些特性的理解,由于涉及到的业务不复杂,有很多特性其实还没有用到……

v-for、v-if

很多场景下,在列表渲染的组件中需要判断每一项的某些属性的值来决定是否显示或者是否有某个不一样效果,官方教程不推荐在同一个组件中同时使用 v-forv-if,我通常会在外层增加一个 <template> 使用 v-for,将 v-ifkey 值写在实际需要渲染的组件中。

// Test.vue
<template>
    <div>
        <template v-for="(item, index) in datas">
            <span v-if="item.name" :key="index">
                {{ item.name }}
            </span>
            <p v-else>暂无数据</p>
        </template>
    </div>
</template>

data、computed、watch

watch 用来监听变量的改变,这个变量可以是 data 中的属性,也可以是 computed 中的属性。除了最直接的将需要监听的属性赋值为一个函数的用法,完整的用法包括以下三个属性

watch: {
    someData: {
        handle(newVal, oldVal) {
            // 监听变量变化的处理函数
        },
        deep: true, // 是否深度监听,例如对象某些属性的变化
        immediate: true // 是否在第一次赋值时执行
    }
}

data 中的变量一般是通过重新赋值实现响应式效果,而 computed 中的变量只会在初始化的时候写好处理逻辑,之后的响应式效果不需要再操作这些变量。例如这个场景,根据路由中的参数的改变获取不同的数据,有以下两种实现方式

// 变量在 data 中
data() {
    return {
        id: this.$route.params.id
    }
},
methods: {
    getData() {
        console.log(this.id)
    }
},
watch: {
    id: {
        handler() {
            this.getData();
        },
        immediate: true
    },
    '$route.params.id': {
        handler() {
            this.id = this.$route.params.id;
        }
    }
}

// 变量在 computed 中
computed: {
    id() {
        return this.$route.params.id;
    }
},
methods: {
    getData() {
        console.log(this.id)
    }
},
watch: {
    id: {
        handler() {
            this.getData();
        },
        immediate: true
    }
}

将路由的参数存储在 data 中,就必须额外监听路由参数的改变,然后再手动的修改 data 中的值;而将路由参数存储在 conmputed 中,这个变量就会随着路由的改变而改变,不需要再显式的重新赋值。

消息传递

vue 的组件之间消息传递方式很多,这里只列举在开发过程中常用、能满足大部分场景的几种方式。

props

最常用的父组件给子组件传递方法,子组件不能直接修改这个数据,但可以在子组件的 data深拷贝一份之后再修改。

$emit

子组件向父组件传递一个包含数据的事件,父组件监听这个事件名然后做出相应的处理。

$refs

多用来父组件通知子组件做出一些操作,通过 $refs 获取到子组件的实例,调用实例上的方法。

event bus

利用一个临时组件,分别在两个需要通信的组件中进行 $on$emit 的操作。

vuex

这是终极解决方案。


李逍
69 声望6 粉丝