最近对vue很感兴趣,趁闲暇时间,模仿了wunderlist里面的部分功能,编写了先后端分离的基于vue技术栈和express的todolist小项目。写这篇博文来总结思考下。项目所在github,能够自行参考克隆。javascript
本人博客css
整个项目最终作成的样子以下:html
你们都看到了,总体实现的功能仍是比较简单的。因为对express也很感兴趣,就干脆本身动手作了全栈。另外说一下:这个项目只是本身摸索vue与express过程当中,作出的成果,若是有哪一个地方不对的,还请大神多多指教。前端
整个todolist界面分为左侧的目录分类和右侧的list。用户切换不一样的目录能够对应到相应的list任务中,而且在该任务中可以添加list和删除list,也可以标记已完成与未完成。这些看上去都很简单,可是里面存在了挺多小细节的,我认为,做为一个新手,尤为是对vue新,对express新又对他们很感兴趣的新手,拿这个项目来练手我的以为很合适。vue
很少说废话了,先来看看个人项目目录与大体的介绍吧。java
├── README.md //这里是readme说明文档 ├── node_modules //一些依赖在这里 ├── build │ ├── build.js │ └── dev.js ├── db //数据库相关的东西放在这里 │ └── dbConfig.js //数据库配置文件 ├── dist //webpack编译后的目标文件夹 ├── package.json //这个就不说了,是个前端都懂 ├── server //服务器端相关的文件都在这里 │ └── app.js //server服务,后台服务入口文件 ├── src //前端源文件都在这里 │ ├── App.vue //vue顶级组件,包含了vue-router │ ├── components //各个子组件 │ │ ├── common //包含了页面的公共模块,好比header,footer等 │ │ ├── menu-item.vue //左侧菜单栏组件 │ │ ├── search.vue //搜索框组件 │ │ └── todo-list.vue //list组件 │ ├── config //一些前端配置的东西能够放在这里 │ ├── directives //vue的一些指令封装能够放在这里 │ ├── filters //vue的一些过滤器能够放在这里 │ ├── images //放置图片 │ │ ├── Shapes.jpg │ │ ├── article.jpeg │ │ └── avatar.png │ ├── less //公共样式less相关放在这里 │ │ ├── common.less │ │ ├── fonts │ │ ├── index.less │ │ ├── lessfont │ │ ├── mixin.less │ │ ├── reset.less │ │ └── variable.less │ ├── main.js //前端入口文件 │ ├── pages //放置不一样的页面,本项目只有一个页面,因此暂定只有一个 │ │ └── index.vue │ ├── route.js //路由配置文件 │ └── store //vuex相关逻辑放在这里 │ ├── actions.js //actions相关 │ ├── getters.js //getter相关 │ ├── index.js //顶端vuex设置,入口文件 │ ├── modules //放置模块 │ ├── mutations.js //mutation相关 │ ├── plugins.js //插件相关 │ └── state.js //state相关 ├── webpack.config.base.js //webpack基本配置 ├── webpack.config.dev.js //开发环境配置 └── webpack.config.prod.js //线上环境配置,但没有测试过也没有怎么研究,能够暂时忽略
下面来分先后端两个模块大体讲解一下:node
首先要准备mysql数据库服务,我用MYSQLWorkbench客户端界面新建数据库,初始化库表信息,固然你也能够不用图形界面,直接用命令行。在dbConfig.js
中配置好数据库设置mysql
module.exports = { host : 'localhost', user : 'your user name default root', password : 'your password', database : 'taskiller' }
本项目中,数据库数据表的新建,并无写在server服务中,在实际的项目中应该有个自动化的脚本自动建立你须要的数据表和须要的字段信息。由于是当成练手项目作的,因此一切都从简了,把项目用到的数据库和数据表都事先创建好了。个人数据库名是taskiller
,须要在这个数据库中,建两个表:todo_list
和menu
,todolist
用来存储list信息,menu
用来存储目录信息。示例:
menu表webpack
+----+--------+----------+ | id | name | selected | +----+--------+----------+ | 1 | sdd | 0 | +----+--------+----------+
todo_list表ios
+--------------+----+------+---------+ | text | id | done | menu_id | +--------------+----+------+---------+ | sdfs | 46 | 0 | 1 | | this is life | 47 | 0 | 4 | +--------------+----+------+---------+
接下来就是后端服务文件app.js
了,看代码说话吧:
const path = require('path') const express = require('express') const mysql = require('mysql'); const dbConfig = require('../db/dbConfig'); const bodyParser = require('body-parser') const insertMenu = 'INSERT INTO menu SET ?' const getMenu = 'SELECT * FROM menu WHERE id = ?' const getAllMenu = 'SELECT * FROM menu' const getTodolist = 'SELECT * FROM todo_list WHERE menu_id = ?' const insertTodolist = 'INSERT INTO todo_list SET ?' const deleteTodo = 'DELETE from todo_list WHERE id = ?' const updateTodolist = 'UPDATE todo_list SET done = ? WHERE id = ?' let menus; //全部menu列表缓存 const app = express(); app.use(bodyParser()); //链接数据库 let connection = mysql.createConnection(dbConfig); app.all('*', function(req, res, next) { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS"); res.header("X-Powered-By",' 3.2.1') res.header("Content-Type", "application/json;charset=utf-8"); next(); }); //添加目录 app.post('/menu/add', function (req, res, next) { let reqParam = req.body; connection.query(insertMenu, reqParam, function(error, results, fields) { if(error) throw error; console.log(results, fields) }) res.sendStatus(200); next() }); //获得全部目录 app.get('/menu/get', function(req, res, next) { connection.query(getAllMenu, function(error, results, fields) { if(error) throw error; menus = results; res.json(results); next() }) }) //获得指定id的目录 app.get('/menu/get/:id', function(req, res, next) { console.log('ID:', req.params.id); connection.query(getMenu, req.params.id, function(error, results, fields) { if(error) throw error; res.json(results); }) }) //根据目录获取todolist app.get('/todolist/get/:id', function(req, res, next) { connection.query(getTodolist, req.params.id, function(error, results, fields) { if(error) return error; res.json(results); }) }) //添加todolist到数据库中 app.post('/todolist/add', function(req, res, next) { //text,done, menu_id let reqParam = { "text": req.body.data.text, "done": false, "menu_id": req.body.data.curMenu }; var insertId; connection.query(insertTodolist, reqParam, function(error, results, fields) { if(error) throw error; insertId = results.insertId; reqParam.id = insertId; res.json(reqParam) }) }) //删除todolist app.post('/todolist/delete', function(req, res, next) { let reqParam = req.body.id connection.query(deleteTodo, reqParam, function(error, results, fields) { if(error) throw error; console.log(results, fields) }) res.sendStatus(200); }) //改变todolist状态 app.post('/todolist/toggle', function(req, res, next) { let reqParam = req.body; console.log(reqParam) connection.query(updateTodolist, [!reqParam.done, reqParam.id], function(error, results, fields) { if(error) throw error; console.log(results) res.sendStatus(200); }) }) app.listen(3001, function() { console.log('listening on port 3001') }) connection.connect(function(err) { if (err) { console.error('error connecting: ' + err.stack); return; } console.log('connected as id ' + connection.threadId); });
很简单,全部的接口功能应该都是一目了然。由于笔者主要锻炼的仍是vue相关的,express只是有兴趣略微带过,所以没有考虑很复杂很完善的一些逻辑。有几个注意事项:
由于前端的地址端口是3000,后端server的端口又设定了3001,这就涉及到了跨域,所以我加了这段代码,来解决跨域的问题。
app.all('*', function(req, res, next) { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS"); res.header("X-Powered-By",' 3.2.1') res.header("Content-Type", "application/json;charset=utf-8"); next(); });
其实这里仍是有漏洞的,坐等高手指出来(微笑脸)
后台express没有用express-generator生成一个完整的架构。笔者以前尝试过用这种一键生成的工具快速搭建后台环境,可是这样就都用现成的,好多东西概念就会很是模糊,不太好掌握一些技术细节,也不会很透彻理解这样写的架构究竟是为何,为何不采用其余架构方式?因此,笔者决定本身纯手写后台的server,这个最初的版本是写的最简单的版本,等后期再深刻研究express的时候,再把这个雏形向着可扩展性和模块化发展。不过已经对express很熟的同窗彻底能够不照着我这个小白的写法写~
前端刚开始我遇到的门槛就是webpack一些配置,网上的教程真的是五花八门,因为本人的目的是学习vue的,并非捣鼓webpack,因此,在webpack配置方面并无花不少的时间研究,也是在网上找教程,慢慢摸索倒腾出来的。不过通过此次倒腾我认识到,我有必要研究下webpack的一些东西了。否则配置个东西,太痛苦,而且前端技术突飞猛进,网上的教程五花八门,有老旧版本的,有新的版本的,很容易让人摸不着头脑,建议仍是去官网学习比较好。后期我研究了再写博文总结下经验。
这里先贴一下个人webpack配置,有些地方作了简要的注释。
webpack.config.base.js
const webpack = require('webpack'); const path = require("path"); const fs = require("fs"); const ExtractTextPlugin = require("extract-text-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const autoprefixer = require('autoprefixer'); const PATHS = { src: path.resolve(__dirname, './src'), dist: path.resolve(__dirname, './dist') } module.exports = { entry: { app: './src/main.js', // 整个SPA的入口文件, 一切的文件依赖关系从它开始 vendors: ['vue', 'vue-router'] // 须要进行单独打包的文件 }, output: { path: PATHS.dist, filename: 'js/[name].js', publicPath: '/dist/', // 部署文件 相对于根路由 chunkFilename: 'js/[name].js' // chunk文件输出的文件名称 具体格式见webpack文档, 注意区分 hash/chunkhash/contenthash 等内容, 以及存在的潜在的坑 }, devtool: '#eval-source-map', // 开始source-map. 具体的不一样配置信息见webpack文档 module: { rules: [{ test: /\.vue$/, loader: 'vue-loader' }, { test: /\.js/, loader: 'babel-loader', exclude: /node_modules/ }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, loader: 'url-loader?limit=10240&name=images/[name].[ext]' }, { test: /\.less/, use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: [ 'css-loader', 'less-loader' ] }) }, { test: /\.css/, use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: [ 'css-loader' ] }) }, { test: /\.(eot|svg|ttf|woff|woff2|png)\w*/, loader: 'file-loader' } ] }, resolve: { alias: { 'vue$': 'vue/dist/vue.common.js', 'components': path.join(__dirname, 'src/components'), // 定义文件路径, 加速打包过程当中webpack路径查找过程 'lib': path.join(__dirname, 'src/lib'), 'less': path.join(__dirname, 'src/less'), 'filters': path.join(__dirname, 'src/filters'), 'directives': path.join(__dirname, 'src/directives'), }, extensions: ['.js', '.less', '.vue', '*', '.json'] // 能够不加后缀, 直接使用 import xx from 'xx' 的语法 }, plugins: [ new HtmlWebpackPlugin({ // html模板输出插件 title: 'task kill', template: `${PATHS.dist}/template/index.ejs`, inject: 'body', filename: `${PATHS.dist}/pages/index.html` }), new ExtractTextPlugin({ // css抽离插件,单独放到一个style文件当中. filename: `css/style.css`, allChunks: true, disable: false }), // 将vue等框架/库进行单独打包, 并输入到vendors.js文件当中 // 这个地方commonChunkPlugin一共会输出2个文件, 第二个文件是webpack的runtime文件 // runtime文件用以定义一些webpack提供的全局函数及须要异步加载的chunk文件 // 具体的内容能够看我写的blog // [webpack分包及异步加载套路](https://segmentfault.com/a/1190000007962830) // [webpack2异步加载套路](https://segmentfault.com/a/1190000008279471) new webpack.optimize.CommonsChunkPlugin({ names: ['vendors', 'manifest'] }) ] }
webpack.config.dev.js
const merge = require('webpack-merge'); module.exports = merge(require('./webpack.config.base'), { devServer: { proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true, secure: false, pathRewrite: { "^/api": "" } } } } })
webpack.config.prd.js就不贴了,此次我也没有用上,你们有兴趣能够直接去github里看。
在这里不得不感谢vuex,这个东西对开发效率的提高真的颇有帮助,vuex不熟悉的童鞋能够去官网查阅,建议既然学习了vue了,vuex必不可少,真的会节省你不少的开发时间。这里我就作个简要的介绍:
vuex是一个专为vue开发的状态管理模式,它采用集中式存储管理应用的全部组件的状态,并以相应的规则保证状态以一种可预测的方式变化。
这个状态自管理应用包含如下几个部分:
state,驱动应用的数据源;
view,以声明方式将state映射到视图;
actions,响应在view上的用户输入致使的状态变化。
好比如下的单一数据流示意图:
可是,有过组件编写经验的童鞋应该知道,当咱们的应用遇到多个组件共享状态时,单向数据流的简洁性很容易被破坏:
多个视图依赖于同一状态。
来自不一样视图的行为须要变动同一状态。
对于问题一,传参的方法对于多层嵌套的组件将会很是繁琐,而且对于兄弟组件间的状态传递无能为力。对于问题二,咱们常常会采用父子组件直接引用或者经过事件来变动和同步状态的多份拷贝。以上的这些模式很是脆弱,一般会致使没法维护的代码。
所以,诞生了vuex,用来把组件的共享状态抽取出来,以一个全局单例模式管理。在这种模式下,组件树构成了一个巨大的“视图”,无论在树的哪一个位置,任何组件都能获取状态或者触发行为!
另外,经过定义和隔离状态管理中的各类概念并强制遵照必定的规则,代码也会变得更结构化且易维护。
来看看这张经典的图例:
大体的就介绍到这里啦,须要更加深刻的童鞋能够移步官网。下面针对本项目的逻辑,介绍下我设计的vuex。
因为目前实现的逻辑仍是较为简单,所以,只有涉及了3个state:
export const state = { todos: [], //当前选中目录curMenu对应的todolist curMenu: {}, //选中的目录 menus: [] //全部目录列表 }
目录对应的todo列表的切换,我采用的方式是:curMenu
只要一有变更就会向后端发送请求,后台返回该目录下对应的全部todo列表,更新到todos。所以,这里只设计了一个todos状态就好了。
mutation只能同步,没法异步,所以mutations.js
只设计状态的改变。此处根据交互有这几处涉及到的状态改变:
向todos中添加todo
在todos中删除选中的todo
更新选中todo的完成状态
设置当前目录对应的todos数组
设置全部的目录列表menus
设置当前选中的目录curMenu
对应的js代码以下:
export const mutations = { //添加todo addTodo(state, {todo}) { state.todos.push(todo) }, //删除todos deleteTodo(state, {todo}) { state.todos.splice(state.todos.indexOf(todo), 1); }, //设置当前的todos setTodo(state, {todos}) { state.todos = todos }, //切换todo的完成状态 toggleTodo(state, {todo}) { todo.done = !todo.done; }, //左侧menu切换,设置当前menu值 setCurMenu(state, {menu}) { state.curMenu = menu; }, //设置menu值, getMenu(state, {menus}) { state.menus = menus; } }
因为须要后台存储一些todo列表的状态,须要将一些动做的变更告知后台,后台更新相应的数据库信息,须要添加actions。
这里要注意:actions.js
主要放置一些与后台交互相关的操做,而mutations.js
只用做状态的改变。
仔细看看交互,会发现这里存在如下几个动做:
获取全部的目录,即得到menus列表
切换menu时,向后台获取对应的todolist,并更新对应todos列表
用户添加list时,向后台发送请求存储在数据库中
用户删除list时,向后台发送请求将数据库中该条记录删除
用户改变todolist中的某个list的状态时,向后台发送请求更新数据库中该条记录的done
字段
import axios from 'axios'; const host = 'http://localhost:3001' //获取全部的menu export const getMenus = ({commit}) => { axios.get(host + '/menu/get') .then((response) => { var data = response.data; var initMenu data.forEach(function(item) { if(item.selected) { initMenu = item } }) commit('getMenu', {menus: data}); //提交mutation,初始化menus列表 commit('setCurMenu', {menu: initMenu}); //提交mutation,初始化curMenu。 axios.get(host + '/todolist/get/'+initMenu.id) .then((response) => { var data = response.data; commit('setTodo', {todos: data}) //根据初始化的curMenu获取todolist,并提交mutation更新todos列表 }) .catch( error => { console.log(error) }) }) .catch( error => { console.log(error) }) } //确切的讲是getCurTodoList,获得当前menu对应的todolist export const setCurMenu = ({commit}, menuData) => { axios.get(host + '/todolist/get/'+menuData.id) .then((response) => { var data = response.data; commit('setTodo', {todos: data}) //用数据库返回的数据提交mutation,更新todo列表 commit('setCurMenu', {menu: menuData.menu}) //更新当前目录,提交mutation来更新curMenu值 }) .catch( error => { console.log(error) }) } //添加todo export const addTodo = ({commit}, data) => { axios.post(host + '/todolist/add', { data: data, }) .then((response) => { commit('addTodo', {todo: response.data}) // 提交mutation,向todos中添加数据库返回的记录 }) .catch( error => { console.log(error) }) } //删除todo export const deleteTodo = ({commit}, todo) => { axios.post(host + '/todolist/delete', { id: todo.todo.id, }) .then((response) => { commit('deleteTodo', todo) //提交mutation,在todos中删除该条记录 }) .catch( error => { console.log(error) }) } //完成与未完成的切换 export const toggleTodo = ({commit}, todo) => { axios.post(host + '/todolist/toggle', { id: todo.todo.id, done: todo.todo.done }) .then((response) => { console.log(response) commit('toggleTodo', {todo: todo.todo}) //提交mutation,更新该条todo的完成状态 }) .catch( error => { console.log(error) }) }
细心的同窗会发现,个人界面里面分门别类的显示了已完成和未完成的类别,所以须要经过getters来根据todos的数据得到对应的数据
export const doneTodos = state => { return state.todos.filter(item => item.done) } export const undoneTodos = state => { return state.todos.filter( item => !item.done) }
这里我就很少赘述了,都是组件相关的概念,要细讲的话概念点就太多了,看不懂的建议你们去vue官网学习相关概念再来看。
index.vue
<template> <article class="post-page"> <div class="g-left"> <menus></menus> </div> <div class="g-right"> <input class="adds" type="text" v-model="addlists" @keyup.enter="addList($event)" placeholder="添加任务..."> <todo-list class="todo" v-for="(todo, index) in undoneTodos" :todo="todo" :key="index"></todo-list> <div class="title" @click="isShowDone = !isShowDone"> {{isShowDone ? '隐藏已完成任务' : '显示已完成任务'}} <span class="subtitle">共{{doneTodos.length}}项</span></div> <todo-list v-show="isShowDone" class="todo" v-for="(todo, index) in doneTodos" :todo="todo" :key="index + 'done'"></todo-list> </div> </article> </template> <script> import TodoList from 'components/todo-list.vue' import Menus from 'components/common/menus.vue' import { mapState } from 'vuex' import {mapGetters} from 'vuex' const COMPONENT_NAME = 'post-page'; export default { name: COMPONENT_NAME, data() { return { addlists: '', isShowDone: false } }, computed: { ...mapState([ 'todos', 'curMenu' ]), ...mapGetters([ 'doneTodos', 'undoneTodos' ]) }, methods: { addList(e) { var text = this.addlists var curMenu = this.curMenu.id this.$store.dispatch('addTodo', {text: text, curMenu: curMenu}) this.addlists = ''; }, }, components: { TodoList, Menus } } </script> <style lang="less"> .post-page { width: 100%; } .adds{ width: 100%; border: 1px solid #dedede; margin-bottom: 40px; margin-left: auto; margin-right: auto; height: 32px; line-height: 32px; border-radius: 3px; padding-left: 10px; } .todo{ text-align: left; } .title{ width: 300px; height: 32px; line-height: 32px; text-align: left; cursor: pointer; .subtitle{ color: #999; font-size: 12px; } } </style>
这里的mapState
和mapGetters
等都在vuex官网里面有介绍,前面的三点...
是es6
的语法对象展开符,不懂的童鞋可google一下,网上一堆教程。
咱们能够看到,这个页面我用到了menus
组件和todo-list
组件,这里我遇到过一个坑,vue文件的模板template
中的元素只能有一个根节点,不能有多个根节点,你们写的时候在只在顶层写一个标签就行了。
menus.vue
<template> <div class="menu"> <menu-item-list v-for="menu in menus" :item="menu" ></menu-item-list> </div> </template> <script> import MenuItemList from 'components/menu-item.vue' import {mapActions} from 'vuex' import {mapState} from 'vuex' const COMPONENT_NAME = 'menu'; export default { name: COMPONENT_NAME, data() { return { } }, created() { this.getMenus(); }, components: { MenuItemList }, computed: { ...mapState(['menus']) }, methods: { ...mapActions(['getMenus']) } } </script> <style lang="less"> .menu { width: 100%; } </style>
menu-item.vue
<template> <div class="menu-list" @click="setCurMenu(item)" :class="{selected: curMenu === item}"> <div> <i class="fa fa-list-ul" aria-hidden="true"></i> <span>{{item.name}}</span> <span v-show="!item.selected" class="tips"></span> <i v-show="curMenu === item" class="fa fa-pencil-square-o tips" aria-hidden="true"></i> </div> </div> </template> <script> const COMPONENT_NAME = 'menu-item-list'; import {mapState} from 'vuex' export default { name: COMPONENT_NAME, data() { return { } }, computed: { ...mapState([ 'curMenu', ]), }, props: ['item'], methods: { setCurMenu(menu) { this.$store.dispatch('setCurMenu', {id: this.item.id, menu: menu}) } } } </script> <style lang="less"> .menu-list { width: 100%; padding-left: 20px; cursor: pointer; &:hover{ background: #29A9FF; div{ color: #fff; } } &.selected{ background: #29A9FF; div{ color: #fff; } } div{ height: 38px; line-height: 38px; color: #333; } i{ display: inline-block; height: 38px; line-height: 38px; } .tips{ float: right; margin-right: 20px; } } </style>
todo-list.vue
<template> <div class="list"> <input type="checkbox" :checked="todo.done" @change="toggleTodo({todo: todo})"> <span>{{todo.text}}</span> <span class="delete fa fa-times-circle" aria-hidden="true" @click="deleteTodo({todo: todo})"></span> </div> </template> <script> import { mapActions} from 'vuex' const COMPONENT_NAME = 'todo-list'; export default { name: COMPONENT_NAME, props: ['todo'], methods: { ...mapActions([ 'deleteTodo', 'toggleTodo' ]) } } </script> <style lang="less" coped> .list{ width: 300px; height: 32px; line-height: 30px; border: 1px solid #dedede; margin-bottom: 20px; } .delete{ float: right; margin-top: 8px; margin-right: 10px; cursor: pointer; color: #999; } </style>
至此,主要的功能结构都讲的差很少了,有哪里讲的不清楚的地方,还望指出来,互相学习。
不得不说,vue框架用来来真的不错,你们在开发的时候,记住:要从数据的角度思考问题,一切就会变得如此简单。
这里记录下接下来要添加的功能,若是有踩坑会继续带来博文分享互相交流学习下:
顶部路由添加user登陆信息
顶部单独辟出一个路由显示已完成任务或其余主题
左侧menu栏目添加新建目录功能,其实后端接口已经写好,前端须要加一下
左侧每一个目录须要有右键功能,右键弹出项目暂定: 重命名、删除
右侧添加任务的界面要改的好一点,样式细节须要调整
右侧输入框目前是点击回车以后会自动添加一个项目,可是在中文输入法时直接按回车切换英文时存在bug,会致使list存在空白的状况,这个要处理一下
每一个list是否须要能够编辑待定
左侧目录最右端显示有几条todolist功能添加