使用快应用实现一个TodoList

快应用教程 Dec 9, 2020

快应用是一种新的应用形态,以往的手机端应用主要有两种方式:网页、原生应用;网页无需安装,却体验不是很好;原生应用体验流畅,却需要从应用商店下载安装,难以一步直达用户;快应用的出现,就是希望能够让用户无需下载安装,并且还能流畅的体验应用内容。

为了达到上面的目标,快应用建立一种新的语言开发规范,同时提供一系列的开发套件辅助支持。简单来说,开发者主要利用前端知识与技能,以及对应的 IDE,手机设备就可以做原型的开发。快应用使用前端技术栈开发,原生渲染,同时具备 H5 与原生应用的双重优点,开发者使用的前端技术栈资料多,学习成本低。

(本文的完整代码可以通过使用 IDE 新建项目,并选择 TodoList 项目作为模板之后查看。)

一、准备

我们需要使用快应用来实现一个 TodoList,所以首先我们需要学习如何使用快应用进行开发

  • 安装 NodeJS

需安装8.0以上版本的 NodeJS (建议使用 10.0+ 以上),请从NodeJS 官网下载

  • 安装快应用开发工具(IDE)

快应用开发工具(IDE)提供了开发快应用所需要的功能,无需再额外安装其他的工具和环境

二、新建项目

1.在 IDE 中点击【文件】【新建快应用工程】菜单,在打开的页面中输入项目名称、项目路径等项目相关信息,点击【完成】即可完成新建项目

2.此时预览界面会提示依赖未安装,点击安装依赖进行依赖安装,等到安装完毕再点击重新启动编译,即可在预览中运行项目。

3.如果想在手机上查看运行效果,需要将手机连上电脑并打开【开发者选项】【USB 调试】,此时会弹出是否信任该设备的弹窗,点击确认即可。然后,在点击 IDE 上的【USB 调试按钮】,会自动在手机上安装调试所需要的调试器和环境,之后,就可以在手机上运行项目了。

三、开始开发

1.项目使用 vuex 进行状态管理,所以首先我们需要安装 quickapp-vuex

npm install quickapp-vuex -S

之后我们就可以在项目中使用 vuex 了。首先我们在 src 目录下新建一个 store 目录,再在这个目录里面新建一个 store.js 文件:

import Vuex from "quickapp-vuex";
import storage from "../helper/storage.js";
import { KEYS } from "../helper/constant.js";
import { filters } from "../helper/utils";

export default new Vuex.Store({
  state: {
    todoList: [],
    tagList: [],
  },
  getters: {
    filteredList: (state) => (status) => {
      return filters[status](state.todoList);
    },
  },
  mutations: {
    setTodoList(state, list) {
      state.todoList = list;
    },
    setTagList(state, list) {
      state.tagList = list;
    },
    addNewTodo(state, item) {
      state.todoList.push(item);
    },
    addNewTag(state, item) {
      state.tagList.push(item);
    },
    updateTodo(state, item) {
      const idx = state.todoList.findIndex((el) => el.id === item.id);
      state.todoList.splice(idx, 1, item);
    },
    delTodo(state, item) {
      const idx = state.todoList.findIndex((el) => el.id === item.id);
      state.todoList.splice(idx, 1);
    },
  },
  actions: {
    async init({ commit }) {
      const todoList = (await storage.get(KEYS.STORAGE_KEY_QUICKAPP)) || [];
      const tagList = (await storage.get(KEYS.STORAGE_KEY_TAG)) || [];
      commit("setTodoList", todoList);
      commit("setTagList", tagList);
    },
  },
});

store.js 里面主要是对 todoList 和 tagList 进行管理,包括初始化、新增、修改、删除等操作。还提供了一个 getter 用于对 todoList 做过滤操作。之后,需要在 app.ux 中引入 store.js,并挂到全局对象上

import store from "./store/store.js";
import Vuex from "quickapp-vuex";

Vuex.install(store);

这样,我们就可以在 ux 文件中使用this.$store操作 vuex 了。(更多关于在快应用中使用 vuex 的介绍可以查看《在快应用开发中使用 Vuex》《在快应用开发中使用 vuex module》

2.我们需要新建三个页面:main 页面用于展示 todo item,add 页面用于添加新的 item,edit 页面用于修改 item。所以,先在 src 目录下新建 pages 目录,用于存放页面文件,然后在 pages 页面下新建三个目录,代表三个页面,并在每个目录下都新建一个 index.ux 文件

VC20201207-160455

然后,需要在 manifest.json 文件中配置这三个页面,并将 main 页面设为入口页面

{
  ...
  "router": {
    "entry": "pages/main",
    "pages": {
      "pages/main": {
        "component": "index"
      },
      "pages/add": {
        "component": "index"
      },
      "pages/edit": {
        "component": "index"
      }
    }
  },
  ...
}

2.除了 pages 目录之外,还需要在 src 目录下新建其他几个目录:assets,用于存放图片、样式等静态资源文件;components,用于存放项目中用到的组件(当项目比较大,组件比较多时,推荐在这个 components 文件中存放项目公用的组件,在 pages 下的各个页面目录下再新建一个 components 目录,用来存放只在这个页面中使用到的组件);helpers,用于存放项目中用到的各种辅助工具

VC20201207-162850

3.接下来,我们先在 helpers 里面添加三个文件,用于存放项目中使用到的辅助工具

constant.js,用于存放项目中用到的常量,此处只定义了一个 KEYS,用于存放操作 storage 时使用的 key 值:

export const KEYS = {
  STORAGE_KEY_QUICKAPP: "todolist-quickapp",
  STORAGE_KEY_TAG: "todolist-tag",
};

storage.js,定义了 set 和 get 两个方法,用于存放 storage 和获取 storage(注意,使用 storage 时,需要通过import storage from '@system.storage'引入快应用提供的 storage 接口)

import storage from "@system.storage";

export default {
  set: (key, value) => {
    const cvalue = typeof value === "object" ? JSON.stringify(value) : value;
    storage.set({
      key,
      value: cvalue,
      success: function (data) {
        console.log(`handle success: data = ${data}`);
      },
      fail: function (data, code) {
        console.log(`Somthing Error[@storage set]: data = ${data}`);
      },
    });
  },

  get: (key) => {
    return new Promise((resolve, reject) => {
      storage.get({
        key,
        success: (data = "{}") => {
          try {
            resolve(JSON.parse(data));
          } catch (error) {
            resolve(data);
            console.log(`Somthing Error[@storage get]: error = ${error}`);
          }
        },
        fail: function (data, code) {
          console.log(`Somthing Error[@storage get]: data = ${data}`);
          reject(data);
        },
        complete: () => {
          resolve();
        },
      });
    });
  },
};

utils.js,用于存放项目中使用到的一些工具方法

//格式化时间
export function formatTime(date) {
  const year = date.getFullYear();
  const month = date.getMonth() + 1;
  const day = date.getDate();
  const hour = date.getHours();
  const formatHour = hour < 10 ? "0" + hour : hour;
  const minute = date.getMinutes();
  const formatMinute = minute < 10 ? "0" + minute : minute;

  return year + "-" + month + "-" + day + " " + formatHour + ":" + formatMinute;
}

//过滤器,用于对todo列表做过滤操作
export const filters = {
  all: (list, target) => {
    return list;
  },
  active: (list, target) => {
    return list.filter(function (task) {
      return !task.completed;
    });
  },
  completed: (list, target) => {
    return list.filter(function (task) {
      return task.completed;
    });
  },
  repeated: (list, target) => {
    return list.filter((task) => task.title === target).length > 0
      ? true
      : false;
  },
  tag: (list, target) => {
    return list.filter(function (task) {
      return task.tag === target;
    });
  },
};

//获取id
export function getId() {
  const time = +new Date();
  const rand = Math.random().toString().slice(-5);
  return time + rand;
}

4.接下来,就正式开始开发了。我们先来开发比较简单的 add 页面,通过 main 页面上的添加按钮可以进入 add 页面,然后在 add 页面输入 todo item 相关的一些信息,再点击完成就可以新建一条 todo item

Screenshot_20201208_104438-1

<template>
  <div class="page column">
    <div class="content">
      <text class="content-label">任务</text>
      <input
        id="add-task-input"
        class="content-input"
        type="text"
        placeholder="请输入新任务"
        @change="onChangeNewTitle"
      />
    </div>
    <div class="content">
      <text class="content-label" style="align-self:flex-start;">描述</text>
      <textarea
        class="content-textarea"
        placeholder="任务描述(可选)"
        @change="onChangeDesc"
      ></textarea>
    </div>
    <div class="content">
      <text class="content-label">标签</text>
      <input
        class="content-input"
        type="text"
        @change="onChangeTag"
        placeholder="可选"
      />
    </div>
    <div class="btn">
      <text class="add-btn" @click="onAddNewTask" disabled="{{disabled}}"
        >确认添加</text
      >
      <text class="cancel-btn" @click="onCancelAdd">取消</text>
    </div>
  </div>
</template>

<script>
  import router from "@system.router";
  import prompt from "@system.prompt";
  import { filters, formatTime, getId } from "../../helper/utils";
  import { mapState, Component } from "quickapp-vuex";

  export default Component({
    data: {
      title: "",
      desc: "",
      tag: "",
    },

    computed: {
      ...mapState(["todoList", "tagList"]),
      disabled() {
        return !this.title;
      },
    },

    onReady() {
      this.$element("add-task-input").focus({ focus: true });
    },

    onChangeDesc(e) {
      this.desc = e.value;
    },

    onChangeNewTitle(evt) {
      this.title = evt.value;
    },

    onChangeTag(evt) {
      this.tag = evt.value;
    },

    onAddNewTask() {
      const tempTitle = this.title.trim();
      if (filters.repeated(this.todoList, tempTitle)) {
        prompt.showToast({
          message: "请勿输入重复的任务",
        });
        return;
      }
      let tempTag = this.tag.trim();
      if (tempTag === "") {
        tempTag = "默认";
      }
      //保存新标签
      if (!filters.repeated(this.tagList, tempTag)) {
        this.$store.commit("addNewTag", tempTag);
      }
      const todo = {
        id: getId(),
        title: tempTitle,
        desc: this.desc.trim(),
        createTime: formatTime(new Date()),
        deadlineTime: "",
        completeTime: "",
        tag: tempTag,
        completed: false,
      };
      this.$store.commit("addNewTodo", todo);
      this.$element("add-task-input").focus({ focus: false });
      router.back();
    },

    onCancelAdd() {
      this.$element("add-task-input").focus({ focus: false });
      router.back();
    },
  });
</script>

<style lang="less">
  ...;
</style>

todo item 需要的信息比较简单,只需要提供 title、desc 和 tag 三个字段。其中 title 是必填的,desc 和 tag 是选填的,当 tag 未填时,会设置一个默认值。为了保证 title 必填,可以给完成按钮设置disabled属性,并通过 computed 计算是否要让这个属性生效。当 title 为空时,computed 返回 true,按钮无法点击;否则返回 false,按钮可以正常点击添加数据。同时,title 应该是唯一的,所以添加数据的时候还需要做一下去重的判断

保存数据时,除了上述几个用户输入的字段外,还需要增加其他的一些字段来记录 todo Item 相关的一些信息,包括 id、createTime、completed 等。

5.然后,再来开发同样比较简单的 edit 页面。点击首页里的 todo item,即可进入 edit 页面,可以对点击的 item 进行编辑。被点击的 item 的数据通过 router 接口的 params 属性传入,一般是将 item 数据用JSON.stringify转为字符串传入,再在 edit 页面的onInit回调里面使用JSON.parse重新解析成对象。

Screenshot_20201208_104527

<template>
  <div class="page all column">
    <text class="title">任务详情</text>
    <div class="content">
      <text class="content-label">任务名</text>
      <input
        id="edit-task-input"
        class="content-input"
        type="text"
        value="{{item.title}}"
        @change="onEditTitle"
      />
    </div>
    <div class="content">
      <text class="content-label" style="align-self:flex-start;">描述</text>
      <textarea
        class="content-textarea"
        placeholder="添加备注"
        onchange="onEditDetail"
        >{{ item.desc }}</textarea
      >
    </div>
    <div class="content">
      <text class="content-label">任务状态</text>
      <text class="content-msg">{{
        item.completed ? '已完成' : '未完成'
      }}</text>
    </div>
    <div class="content">
      <text class="content-label">标签</text>
      <input
        class="content-input"
        type="text"
        value="{{item.tag}}"
        onchange="onEditTag"
      />
    </div>
    <div class="content">
      <text class="content-label">创建时间</text>
      <text class="content-msg">{{ item.createTime }}</text>
    </div>
    <div class="content" if="{{item.completed}}">
      <text class="content-label">完成时间</text>
      <text class="content-msg">{{ item.completeTime }}</text>
    </div>
    <div class="content">
      <text class="content-label">截止日期</text>
      <picker
        class="picker"
        type="date"
        start="{{new Date()}}"
        value="{{item.deadlineTime}}"
        onchange="getDate"
      ></picker>
    </div>
    <text class="add-btn" @click="onEditDone" disabled="{{!item.title}}"
      >确认</text
    >
  </div>
</template>

<script>
import router from '@system.router'
import prompt from '@system.prompt'
import { filters } from '../../helper/utils'
import { mapState, Component } from 'quickapp-vuex'

export default {
  public: {
    itemStr: ''
  },

  private: {
    item: {},
    oldTaskTitle: ''
  },

  computed: {
    ...mapState(['todoList', 'tagList'])
  },

  async onInit() {
    this.item = JSON.parse(this.itemStr)
    this.oldTaskTitle = this.item.title
  },

  onReady() {
    this.$element('edit-task-input').focus({ focus: true })
  },

  getDate(e) {
    this.item.deadlineTime = e.year + '-' + (e.month + 1) + '-' + e.day
  },

  onEditDetail(evt) {
    this.item.desc = evt.value.trim()
  },

  onEditTitle(evt) {
    this.item.title = evt.value.trim()
  },

  onEditTag(evt) {
    this.item.tag = evt.value.trim()
  },

  onEditDone() {
    if (
      this.item.title != this.oldTaskTitle &&
      filters.repeated(this.todoList, this.item.title)
    ) {
      prompt.showToast({
        message: '请勿输入重复的任务'
      })
      return
    }
    if (this.item.tag === '') {
      this.item.tag = '默认'
    }
    //保存新标签
    if (!filters.repeated(this.tagList, this.item.tag)) {
      this.$store.commit('addNewTag', tempTag)
    }
    this.$store.commit('updateTodo', this.item)
    this.$element('edit-task-input').focus({ focus: false })
    router.back()
  }
}
</script>

<style lang="less">
...
</style>

编辑页面除了可以修改 title、desc 和 tag 之外,还可以设置截止时间。设置时间需要使用快应用提供的 picker 组件,可以查看文档了解。

同样,保存数据时需要对 title 做去重判断和非空判断

6.最后来讲解一下 main 页面,main 页面使用到了两个组件,我们先来一一介绍一下这两个组件。

首先是 filterButton 组件,这个组件位于 main 页面底部,用于筛选不同状态的 todo item。包括三种状态:全部、已完成、未完成

<template>
  <div class="filter-btn-warp">
    <div class="filter-btn-content" onclick="changeStatus('all')">
      <image
        class="filter-icon"
        src="{{ curStatus === 'all' ? '../../assets/images/all-active.png' : '../../assets/images/all.png' }}"
      />
      <text class="filter-btn-text {{ curStatus === 'all' ? 'checked' : ''}}"
        >全部</text
      >
    </div>
    <div class="filter-btn-content" onclick="changeStatus('completed')">
      <image
        class="filter-icon"
        src="{{ curStatus === 'completed' ? '../../assets/images/complete-active.png' : '../../assets/images/complete.png' }}"
      />
      <text
        class="filter-btn-text {{ curStatus === 'completed' ? 'checked' : ''}}"
        >已完成</text
      >
    </div>
    <div class="filter-btn-content" onclick="changeStatus('active')">
      <image
        class="filter-icon"
        src="{{ curStatus === 'active' ? '../../assets/images/uncomplete-active.png' : '../../assets/images/uncomplete.png' }}"
      />
      <text class="filter-btn-text {{ curStatus === 'active' ? 'checked' : ''}}"
        >未完成</text
      >
    </div>
  </div>
</template>

<script>
export default {
  props: ['filterStatus'],
  data() {
    return {
      curStatus: this.filterStatus
    }
  },

  onInit() {
    this.$watch('filterStatus', 'handleWatchProps')
  },

  handleWatchProps(value) {
    this.curStatus = value
  },

  changeStatus(status) {
    this.curStatus = status
    this.$emit('changeFilterStatus', {
      filterStatus: this.curStatus
    })
  }
}
</script>

<style lang="less">
...
</style>

每当点击筛选按钮后,就会使用$emit给父组件发送一条消息,父组件接受到消息后,再执行相应的过滤操作。(对于子组件给父组件发消息,个人比较推荐使用$emit,因为这样在父组件中比较容易分辨消息来自哪个子组件)

接下来是 todoItem 组件,这个组件用于展示一项 todo Item,同时可以对这个 todo item 做一些操作,包括切换状态和删除操作。

<template>
  <stack>
    <div class="del-wrap" onclick="onDelTodo">
      <image src="../assets/images/delete.png" class="del-btn" />
    </div>
    <div
      class="content border-bottom"
      style="right: {{right}}px;"
      onclick="onShowEditTodo"
      ontouchstart="touchstart"
      ontouchmove="touchmove"
      ontouchend="touchend"
    >
      <image
        class="todo-icon"
        src="{{item.completed?'../../assets/images/click.svg':'../../assets/images/unclick.svg'}}"
        @click="onChangeTodoStatus"
      />
      <text class="todo-title">{{ item.title }}</text>
    </div>
  </stack>
</template>

<script>
import router from '@system.router'
import prompt from '@system.prompt'
import { Component } from 'quickapp-vuex'

export default Component({
  props: ['todoItem'],

  data() {
    return {
      item: this.todoItem,
      startPos: '',
      right: 0,
      canMove: true
    }
  },

  onInit() {
    this.$watch('todoItem', 'handleWatchProps')
  },

  handleWatchProps(value) {
    this.item = value
  },

  touchstart(e) {
    console.log('start', e)
    if (this.right !== 0) {
      this.right = 0
      this.canMove = false
    } else {
      this.startPos = e.touches[0].clientX
      this.canMove = true
    }
  },

  touchmove(e) {
    console.log('move', e)
    if (this.startPos > e.touches[0].clientX && this.canMove) {
      //左滑
      const right = this.startPos - e.touches[0].clientX
      this.right = right
    }
  },

  touchend(e) {
    console.log('end', e)
    if (this.right >= 40) {
      this.right = 60
    } else {
      this.right = 0
    }
  },

  onDelTodo() {
    prompt.showDialog({
      title: '',
      message: '确认删除?',
      buttons: [
        {
          text: '确认',
          color: '#000'
        },
        {
          text: '考虑一下',
          color: '#000'
        }
      ],
      success: data => {
        console.log('handling commit', data)
        if (data.index === 0) {
          this.$store.commit('delTodo', this.item)
          this.right = 0
        } else {
          return
        }
      }
    })
  },

  onChangeTodoStatus(e) {
    e.stopPropagation()
    if (!this.item.completed) {
      prompt.showToast({
        message: '任务完成'
      })
    }
    this.item.completed = !this.item.completed
    this.$store.commit('updateTodo', this.item)
  },

  onShowEditTodo() {
    router.push({
      uri: 'pages/edit',
      params: { itemStr: JSON.stringify(this.todoItem) }
    })
  }
})
</script>

<style lang="less">
...
</style>

切换操作比较简单,只是点击以后改变一下 todo item 的状态。删除操作通过使用stack组件,将 todo item 设置为两层,上一层展示 todo item 的 title 以及用于切换状态的选择框,下一层则是执行删除操作的按钮。组件通过监听 touchstarttouchmovetouchend三个触摸事件,计算出 right 的值,然后将这个值绑定到上一层的 style 属性上。这样,就可以通过左滑操作,漏出下面的删除按钮。

最后,介绍一下 main 页面

Screenshot_20201208_104519

<import name="todoItem" src="../../components/todoItem.ux"></import>
<import name="filterButton" src="../../components/filterButton.ux"></import>

<template>
  <div class="all column page">
    <div class="header border-bottom">
      <image class="header-title" src="../../assets/images/header.png" />
    </div>
    <div class="all no-result" if="filteredTaskArr.length < 1">
      <image src="/assets/images/no-result.png"></image>
      <text>暂时还没有任务</text>
      <text>快去添加一条吧</text>
    </div>
    <block for="filteredTaskArr">
      <todoItem todo-item="{{$item}}"></todoItem>
    </block>
    <filterButton
      filter-status="{{filterStatus}}"
      onchange-filter-status="handleChangeFilterStatus"
    ></filterButton>
    <div class="add-btn" @click="turnToAdd">
      <image src="/assets/images/add.png"></image>
    </div>
  </div>
</template>

<script>
import router from '@system.router'
import prompt from '@system.prompt'
import storage from '../../helper/storage.js'
import { KEYS } from '../../helper/constant.js'
import { mapState, Component } from 'quickapp-vuex'

export default Component({
  data: {
    filterStatus: 'all'
  },

  computed: {
    ...mapState(['todoList', 'tagList']),
    filteredTaskArr() {
      return this.$store.getters.filteredList(this.filterStatus)
    }
  },

  onInit() {
    this.$watch('todoList', 'watchtodoList')
    this.$watch('tagList', 'watchtagList')
    this.$store.dispatch('init')
  },

  watchtodoList(newV, oldV) {
    console.log('todoList change happen')
    storage.set(KEYS.STORAGE_KEY_QUICKAPP, newV)
  },

  watchtagList(newV, oldV) {
    console.log('tagList change happen')
    storage.set(KEYS.STORAGE_KEY_TAG, newV)
  },

  handleChangeFilterStatus(evt) {
    this.filterStatus = evt.detail.filterStatus
    console.log('ChangeFilterStatus', this.filterStatus)
  },

  turnToAdd() {
    router.push({
      uri: 'pages/add'
    })
  }
})
</script>

<style lang="less">
...
</style>

main 页面在 onInit 方法中通过this.$store.dispatch('init'),通知 vuex 更新 todoList 和 tagList。然后使用 computed 获取到 vuex 中保存的 todoList 和 tagList,并对这两个 list 设置了 watch,每当 list 更新时,持久化 list。在 computed 中还使用 vuex 的 getter 通过不同 filterStatus 获取不同的过滤结果,再将过滤结果使用 todoItem 组件展示出来。通过点击底部的 filterButton 组件,可以切换不同的 filterStatus 从而使用不同的 filterStatus 获得不同状态下的 todoList。这里有一个坑需要注意一下,展示时使用的 list 和 getStorage 获取到的不是一个 list,所以不能直接使用 for 指令中的$idx 直接去操作 getStorage 获取到的数组。这边我使用的是查找 id 的方式找到所操作 todo item 的真实 index,然后进行操作。当然,你也可以将两个 list 的 index 做一下映射,也是可以的。

参考文档

Tags