不知不觉已经是自学小程序开发的第11天了。今天不打算继续往后赶进度,而是回头把前面断断续续接触过的列表渲染、条件渲染、事件绑定和数据处理串起来,做一个完整的待办事项小项目。这个项目不大,但该有的东西全都有:输入框、按钮交互、列表展示、删除操作、状态切换,还有本地缓存。对零基础的人来说,把这几样真正跑通,比囫囵吞枣多看十章教程都管用。
为什么第11天要停下来做整合?因为我前一周多踩过最大的坑,就是每个知识点单独看都懂,一放到真实场景里就不知道从哪下手。比如wx:for会写,但列表里要动态删掉某一项就懵了;bindinput会写,但输入框里的值怎么实时取出来又卡住。这些东西单独拆开都不难,难的是组合在一起的时候怎么让数据在整个页面里流动起来。今天的项目就是把它们全部串一遍,顺便把前端开发里最核心的一个思维——数据驱动视图——彻底搞清楚。
1. 今日学习内容整体设计:为什么要做待办事项,而不是继续追新章节
很多自学前端的朋友容易陷入一个误区:总觉得学得越快越好,今天看完组件明天就想看API,后天恨不得直接上手复杂项目。我自己前几天的状态也差不多,结果就是学完的东西没在脑子里留下多少。今天这个安排,核心思路就是四个字——回头整合。与其学一堆似懂非懂的新概念,不如把手头已有的知识练成肌肉记忆。
1.1 前10天知识回顾与Day11定位
简单梳理一下我前10天走过的路,大家可以对号入座看看自己处在哪个阶段:
- Day1到Day3:环境搭建,装了微信开发者工具和HBuilderX,搞明白了小程序的目录结构,知道
pages、app.json、app.js这些文件是干什么的。 - Day4到Day6:开始写页面,学了
view、text、image、input、button这些基础组件,会调整wxss样式,大概理解了rpx这个单位。 - Day7到Day8:接触了数据绑定和事件,会用
data里定义变量,然后通过{{}}渲染到页面上,也知道了bindtap可以给组件绑定点击事件。 - Day9到Day10:学了一些中级内容,包括
wx:if条件渲染、wx:for列表渲染,还简单了解了一下wx.setStorageSync本地缓存。
到了Day11,基础组件、数据绑定、事件、列表渲染、条件渲染这几个核心能力都已经接触过了。但问题也来了:这些能力是零散的,就像工具箱里摆了一堆螺丝刀和扳手,但还没亲手组装过一件东西。今天这个待办事项项目,就是第一次把这些工具组合起来使用。
1.2 为什么选择做一个待办事项应用,以及它涵盖的知识点
选待办事项这个题材,不是因为它新鲜,而是因为它足够经典,而且每个功能点都能对应到一个前端开发的高频知识点。你仔细拆解一下就会发现,几乎所有小程序或网页应用,底层逻辑都是这一套:
- 有一个输入框,用户往里输内容——对应
input组件的使用和数据获取。 - 有一个添加按钮,点了之后内容出现在下方列表里——对应事件绑定和数组追加数据。
- 列表里的每一项可以标记为已完成——对应条件渲染、样式切换和数据修改。
- 每一项可以删除——对应数组删除操作和视图刷新。
- 整个列表要能存下来,下次打开还在——对应本地缓存。
这个流程走完,你其实就把前端开发最核心的"数据驱动视图"这个概念彻底跑通了。我后面会详细解释什么叫数据驱动视图,但现在你只需要记住一句话:页面长什么样,完全由数据决定。你要改页面,不是直接去改界面,而是改数据,界面会自动跟着变。这个思维一旦建立起来,后面学vue、react这些框架都会轻松很多。
1.3 今日项目的预期目标和前置要求
开始之前先明确一下今天做完你能得到什么。最直观的成果是一个能正常运行的待办事项小程序:输入文字、点按钮、添加条目、点勾标记完成、点删除移除条目、刷新页面数据还在。如果你能独立把这几件事做出来,那说明你的小程序开发基础已经真正打牢了,可以开始往更深的方向走了。
前置要求不多,只要满足两条:一是电脑上装好了微信开发者工具,并且能创建小程序项目;二是大概知道app.json和页面文件的组织方式。如果前两条还不熟悉,建议先花一两个小时把这两个东西搞清楚再来做今天的实践。因为今天的重点不在环境搭建,而在于代码逻辑本身。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四个核心知识点深度拆解:列表、条件渲染、事件和数据流
待办事项项目虽然不大,但它涉及到的几个知识点,每一个都是小程序开发里使用频率极高的。为了让大家后面做项目的时候不卡壳,我会把这几个知识点一个一个拆开讲清楚,并且特别说明它们为什么是这么设计的。搞懂了背后的道理,比记住几个写法要重要得多。
2.1 wx:for列表渲染:为什么列表一定需要key值
wx:for是小程序里用来循环渲染列表的指令。假设你的data里有一个数组todos,里面存着三件事,你想把它们都显示在页面上,不需要手写三个view标签,只需要写一个view,然后用wx:for让它循环三遍。
最基本的写法是这样:
html复制<view wx:for="{{todos}}" wx:key="id">
{{item.text}}
</view>
这里有几个细节需要解释一下。循环的时候,小程序默认把数组里的每一项命名为item,把每一项的索引命名为index。如果你想自己改名字,可以用wx:for-item指定循环项的变量名,用wx:for-index指定索引的变量名。不过通常情况下,直接用默认的item和index就够了。
然后是wx:key,这个属性非常重要。它的作用是给每一个循环出来的元素一个唯一标识,让小程序能够精准地知道哪个元素是哪个。为什么要这个标识?你想象一下,如果列表里有一百项,你删掉了第三项,如果没有key,小程序只能从头到尾重建这一百项;有了key,它可以精确地只处理变化的那一项。这在数据量小的时候感觉不出来,但数据量大了以后,性能差别非常明显。
而且wx:key还有一个作用,就是保持组件自身的状态。比如列表里的每一项都有一个输入框,用户在第一项里输入了内容。如果没有key,当列表顺序改变的时候,小程序可能分不清哪个输入框对应哪个数据,导致输入的内容错乱。加了唯一的key值,每一个组件就和数据绑定了,怎么排序都不会乱。
我自己在实际写的时候,key值通常就用数据里的id。这个id是每一条数据独有的,不会重复。如果你没有id,可以退而求其次用index,但那样会有潜在问题,因为通过index找数据在删除场景下会出错,所以建议大家从一开始就养成给数据加id的习惯。
2.2 wx:if与hidden:什么时候用条件渲染,什么时候用隐藏切换
待办事项里有一个很常见的需求:当列表为空的时候,显示一个"暂无待办事项"的提示;当列表里有内容的时候,显示列表本身。这种"根据条件决定显示什么"的写法,就是条件渲染。
wx:if的用法很直观:
html复制<view wx:if="{{todos.length === 0}}">
暂无待办事项,快添加一条吧
</view>
<view wx:else>
<!-- 列表内容 -->
</view>
当todos数组为空的时候,第一段显示;有内容的时候,显示wx:else后面的部分。这个逻辑在写业务代码的时候用得特别多。
还有一个容易混淆的指令叫hidden,它也可以控制元素的显示和隐藏:
html复制<view hidden="{{isHidden}}">
这一段可以通过hidden隐藏
</view>
那wx:if和hidden到底有什么区别?简单来说,wx:if是"惰性"的,当条件不满足的时候,它压根不会渲染这个组件,一个元素都不生成;hidden是"不管怎样都渲染",只是通过样式把元素藏起来,不让你看到。
这个区别带来了一个非常实际的选择标准:如果元素需要频繁地在显示和隐藏之间切换,比如用户在勾选和取消勾选待办事项的状态图标,那就用hidden,因为它只是切换样式,不需要重新创建和销毁组件,性能更好。如果元素几乎不会变化,比如空列表的提示文字,那就用wx:if,因为它可以避免无意义的渲染开销。
我在Day11之前的实际项目里踩过一个坑:用wx:if控制一个高频切换的元素,结果在快速操作的时候页面明显卡顿。后来换成hidden就好了。这就是为什么理解原理比单纯记住写法更重要。
2.3 事件绑定与事件对象:点击后怎么拿到你想操作的那一项
小程序里的交互,核心就是事件绑定。给一个按钮绑定点击事件,用bindtap:
html复制<button bindtap="addTodo">添加</button>
然后在js文件里定义addTodo函数:
javascript复制addTodo() {
// 处理添加逻辑
}
点按钮就会触发这个函数,这个很好理解。但到了列表项目,情况会稍微复杂一点:列表里有十项,每一项都有一个删除按钮,你点击任意一个删除按钮,函数怎么知道你要删的是哪一项?
答案是通过事件对象。在小程序的事件处理函数里,你可以接收一个参数event,这个event对象携带着和这次点击行为相关的大量信息。
关键写法是这样:
html复制<view wx:for="{{todos}}" wx:key="id">
<text>{{item.text}}</text>
<button bindtap="deleteTodo" data-id="{{item.id}}">删除</button>
</view>
在按钮上通过data-id自定义属性传入当前项的id,然后事件处理函数里这样取:
javascript复制deleteTodo(event) {
const id = event.currentTarget.dataset.id;
// 根据id找到对应的项并删除
}
这里要注意几个细节。第一,自定义属性必须以data-开头,这样才能被正确识别。第二,取值要用event.currentTarget.dataset,而不是event.target.dataset。currentTarget是绑定事件的元素本身,target是你实际点击的元素。比如你点击了删除按钮里的一个图标,target是图标,而currentTarget才是按钮。如果项目里层级关系复杂,用错就会取不到值,我在刚开始学的时候被这个问题卡过好几次。
事件绑定这个概念很重要,因为所有用户交互都离不开它。做完今天这个项目,你对bindtap的理解会比之前深很多,还会发现原来数据可以从页面"流"到逻辑层。
2.4 单数据流思维:理解了它,你就理解了前端开发的一半
这是今天最想带大家建立的一个思维模型。我前面提到"数据驱动视图",那数据变化之后,视图到底是怎么自动更新的?答案就在微信小程序的架构设计里。
在小程序里,js的data对象是整个页面的数据源。页面通过{{}}语法把data里的数据渲染出来。当你通过this.setData()修改数据的时候,小程序会把这个改动同步到视图层,然后视图自动更新。
这个机制叫"单向数据流",意思是:数据只能从逻辑层流向视图层,反过来不行。你不能在页面模板里直接修改数据,必须通过事件把用户的操作传给逻辑层,再在逻辑层用setData修改,修改完视图自动更新。
这个流程画成步骤就是:
- 用户在页面上做了某个动作,比如输入文字、点击按钮。
- 事件被触发,逻辑层对应的函数开始执行。
- 逻辑层把新数据通过
setData传给视图层。 - 视图层接收到新数据,自动更新页面上对应的部分。
这个设计的好处是,数据和视图完全解耦,你只需要关心数据怎么变,不需要手动去操作DOM元素。做前端开发的人经常说"不要直接操作DOM",就是这个意思。如果你之前学过用jQuery改页面内容,那你会深深体会到这两种方式的差别——前者是命令浏览器"把第三行的文字改成XXX",后者是告诉数据层"todos这个数组变了",然后界面自己就跟着变了。
在今天的待办事项项目里,你会在每一个功能点上都体会到这个过程:添加待办是往数组里push一条数据,然后setData;删除待办是filter掉匹配项,然后setData;切换完成状态是修改对应项的completed字段,然后setData。每一次交互,其实都是在执行"数据变了,视图跟着变"这个循环。
3. 完整实操:从零手写一个待办事项小程序
理论说再多,都不如实际把代码跑一遍。这一部分我会把整个待办事项小程序的开发过程完整走一遍,从项目准备开始,到页面结构的编写,再到逻辑层的实现,最后处理样式和本地缓存。每一步我都会说清楚在做什么、为什么这么做,以及有哪些可以优化的细节。
3.1 项目准备:创建项目与页面结构设计
打开微信开发者工具,新建一个小程序项目。项目名称随便起,注意AppID那里选"测试号"就可以了,不需要注册正式的AppID也可以开发调试。
创建完成之后,你会看到默认生成了pages/index页面。我们今天所有代码都写在这个页面里。如果你想要更规范一点,也可以新建一个专门的页面,比如pages/todo/todo。我是直接在index页面里操作的,因为项目规模小,没有拆页面的必要。
在动手写代码之前,先想好整个页面的结构。一个待办事项应用,从上到下应该是这样的:
- 最上面是标题,告诉用户这个页面是干什么的。
- 中间是输入区域,一个输入框加一个添加按钮。
- 下面是一个统计信息,显示"共X项,已完成Y项"。
- 再往下是待办事项列表,每一行左边是完成状态标记,中间是内容,右边是删除按钮。
- 最后是一个空状态的提示,当列表没有任何数据时显示。
页面结构想清楚之后,写代码就有方向了。
3.2 页面结构编写:wxml代码的逐行实现
先上完整的页面代码,然后再逐步解释:
html复制<view class="container">
<view class="header">
<text class="title">今日待办</text>
<text class="date">{{currentDate}}</text>
</view>
<view class="input-area">
<input
class="todo-input"
placeholder="输入新的待办事项..."
value="{{inputValue}}"
bindinput="handleInput"
confirm-type="done"
bindconfirm="addTodo"
/>
<button class="add-btn" bindtap="addTodo">添加</button>
</view>
<view class="stats">
<text>共 {{todos.length}} 项,已完成 {{completedCount}} 项</text>
</view>
<view class="todo-list">
<view
wx:for="{{todos}}"
wx:key="id"
class="todo-item {{item.completed ? 'completed' : ''}}"
>
<view class="todo-check" bindtap="toggleTodo" data-id="{{item.id}}">
<text wx:if="{{item.completed}}">✓</text>
</view>
<text class="todo-text">{{item.text}}</text>
<text class="todo-delete" bindtap="deleteTodo" data-id="{{item.id}}">删除</text>
</view>
</view>
<view class="empty-tip" wx:if="{{todos.length === 0}}">
<text>暂无待办事项,来添加一条吧</text>
</view>
</view>
这一段代码里有几个地方值得仔细说一下。
第一,输入框的处理方式。你可能已经注意到了,我用value="{{inputValue}}"给输入框绑定了一个值,然后又用bindinput="handleInput"监听输入事件。这样一个组合,在小程序里是实现"输入框值"和"data里的数据"双向同步的标准写法。bindinput事件在每次输入框内容变化时触发,我在事件处理函数里把输入框里最新的值取出来,然后存放到data的inputValue变量里。这样,我在任何地方要用到输入框内容的时候,直接读this.data.inputValue就行。
第二,我用了confirm-type="done"和bindconfirm="addTodo"。这个confirm-type是指键盘右下角显示什么按钮,设置为done就是显示"完成"。bindconfirm是在用户点击键盘上的完成按钮时触发的事件。这样做了之后,用户输入完内容,不需要移动手指去点页面上那个"添加"按钮,直接按键盘上的完成键就能添加待办,体验会好很多。这是一个细节,但很能体现产品思维。
第三,待办事项列表里每一项绑定了一个动态class。你看这行:
html复制<view class="todo-item {{item.completed ? 'completed' : ''}}">
item.completed是这条待办是否已完成的状态。如果为true,就给这个view额外加上一个completed的class。然后我在样式文件里定义了completed类对应的样式:文字变灰色、加删除线。这样一来,同一个视图,根据数据状态的不同,显示效果完全不同。这就是数据驱动视图最直观的体现。
第四,完成状态的标记。我在每一行最左边放了一个圆形的勾选区域,里面根据item.completed的值决定是否显示一个对勾。点这个区域,可以切换待办的完成状态。这里用wx:if="{{item.completed}}"实现有勾和无勾两种状态的切换。
3.3 逻辑层实现:js代码的每一步都在做什么
页面结构写完之后,最核心的部分就是index.js里的数据处理逻辑。先把整个js代码放出来,然后逐一说明:
javascript复制Page({
data: {
todos: [],
inputValue: '',
currentDate: ''
},
onLoad() {
const storedTodos = wx.getStorageSync('todos');
if (storedTodos) {
this.setData({
todos: storedTodos
});
}
this.updateDate();
},
handleInput(event) {
this.setData({
inputValue: event.detail.value
});
},
addTodo() {
const value = this.data.inputValue.trim();
if (!value) {
wx.showToast({
title: '内容不能为空',
icon: 'none'
});
return;
}
const newTodo = {
id: Date.now(),
text: value,
completed: false
};
const todos = [...this.data.todos, newTodo];
this.setData({
todos: todos,
inputValue: ''
});
this.saveTodos();
},
toggleTodo(event) {
const id = Number(event.currentTarget.dataset.id);
const todos = this.data.todos.map(item => {
if (item.id === id) {
return {
...item,
completed: !item.completed
};
}
return item;
});
this.setData({
todos: todos
});
this.saveTodos();
},
deleteTodo(event) {
const id = Number(event.currentTarget.dataset.id);
const todos = this.data.todos.filter(item => item.id !== id);
this.setData({
todos: todos
});
this.saveTodos();
},
saveTodos() {
wx.setStorageSync('todos', this.data.todos);
},
updateDate() {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const day = now.getDate();
this.setData({
currentDate: `${year}年${month}月${day}日`
});
}
});
我分几个重点来解释。
addTodo函数的细节。 首先我调用了this.data.inputValue.trim(),把输入框前后的空格去掉。为什么要trim?因为用户在输入的时候很可能不小心在开头或结尾打了一个空格,如果不去掉,就会存出一条看起来没问题但实际带空格的脏数据。学前端开发,处理用户输入数据的时候,第一件事就是要做清理和校验,这是一个好习惯。
如果清理之后内容是空的,我用wx.showToast弹了一个提示框提示"内容不能为空",然后直接return结束函数。这一步就是数据校验,避免垃圾数据进入列表。
通过校验之后,我构造了一个新的待办对象:
javascript复制const newTodo = {
id: Date.now(),
text: value,
completed: false
};
这里用Date.now()生成id。Date.now()返回的是当前时间戳,例如1698123456789,这个数字在每一毫秒都不一样,所以短时间内不会重复,非常适合用来做临时的唯一标识。
然后我用展开运算符[...this.data.todos, newTodo]生成一个新数组。这里注意一个细节:我没有直接用this.data.todos.push(newTodo)然后setData,而是先创建了一个新数组,再把新数组通过setData赋值。为什么要这样做?因为在小程序里,直接修改this.data里的数组,再调用setData,有时候会因为引用相同导致界面不刷新。这是一个很隐蔽的坑。使用展开运算符创建一个新数组,就能确保每次setData传入的都是一个全新的引用,界面一定能正常刷新。
toggleTodo函数的细节。 这个函数用来切换待办的完成状态。我用map方法遍历数组,匹配到id相同的项,就返回一个新的对象,把completed取反。其他项原样返回。这里也用到了展开运算符...item来复制原对象,然后再改completed字段。这样也是为了避免直接修改原数组对象,从而保证setData后视图更新。
然后你会发现一个细节,data-id="{{item.id}}"从页面传过来的是一个字符串数字,比如"1698123456789",但在数组里对比的时候,我把它转成了数字类型Number(event.currentTarget.dataset.id)。为什么要转?
直接说结论:从dataset里取出来的值,一定是个字符串。而Date.now()生成的是一个数字。如果你不转换,item.id === id这行代码就变成数字 === 字符串,结果永远是false,条件永远不会成立,删除或切换操作就会失败。这个问题,很多新手都会踩,而且报错信息不明显,特别难排查。我在这里先用Number()包一层,避免后续的坑。
deleteTodo函数的细节。 删除的逻辑比切换更简单,用filter把不匹配的项保留下来,匹配到的项直接过滤掉,得到一个新数组,再setData。这样一行代码就实现了"删除指定项"这个操作,这也是数组方法在前端开发里最常见的用法之一。
缓存同步的细节。 我在添加、切换、删除三个函数里,都调用了this.saveTodos()这个函数。这个函数做了一件事:把最新的todos数组用wx.setStorageSync存到本地缓存里。然后在onLoad生命周期里,打开页面的时候用wx.getStorageSync读取缓存,如果有数据就加载进来。这样就能做到:用户关掉小程序,下次再打开,待办事项还在。
不过有一点要说明,缓存不是实时同步的。因为我只在修改数据的时候调用saveTodos(),如果是首次加载后没有任何修改,那缓存里还是旧数据,不会有问题。但如果你在小程序里强行杀掉进程再打开,onLoad还是会执行读取缓存的操作,拿到的还是上次存的内容。这个机制本身是可靠的。
3.4 样式编写:用wxss把页面做得干净可用
样式部分我不会全部贴出来,只说几个关键的技巧。
flex布局是页面布局的核心。 输入区域的"输入框+按钮"组合、列表里"勾选标记+文字+删除按钮"的组合,都可以用flex来排列。用display: flex; align-items: center;就能让子元素在水平方向上排列,并且垂直居中。比如todo-item这一行的布局:
css复制.todo-item {
display: flex;
align-items: center;
padding: 20rpx;
background: #fff;
border-radius: 12rpx;
margin-bottom: 16rpx;
}
完成状态的样式。 加删除线、变灰色:
css复制.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
这里用的是后代选择器,当todo-item同时有completed这个类的时候,它内部的todo-text就应用删除线和灰色。这个技巧可以让完成状态的切换非常直观。
勾选圆圈的样式。 我在页面里放了一个todo-check的view,希望它看起来像一个圆圈:
css复制.todo-check {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #ccc;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
font-size: 28rpx;
color: #fff;
background: #fff;
}
当它处于完成状态时,圆圈内部会显示一个对勾,颜色变为主题色:
css复制.todo-item.completed .todo-check {
background: #07c160;
border-color: #07c160;
}
这些样式写起来不难,但做出来的效果会让整个页面有质感很多。小程序开发里,样式写得好不好,直接决定用户第一印象,所以不能完全忽略。
3.5 联调与验证:每个功能都要实测通过
代码写完之后,最重要的环节是联调测试。我的习惯是每写完一个功能就立刻在模拟器里点一遍,确认没有问题再往下写。
实际测试时,我按这个列表逐个验证:
- 首次打开页面,空列表显示"暂无待办事项"的提示。
- 输入"买牛奶",点"添加"按钮,列表出现"买牛奶"这一项,输入框清空。
- 不输入内容直接点添加,弹出"内容不能为空"的提示,不会添加空数据。
- 点击"买牛奶"左边的圆圈,文字变成灰色加删除线,统计信息的"已完成"数量从0变成1。
- 再点一次圆圈,恢复未完成状态。
- 点击"删除",这一项从列表消失。
- 反复添加、删除多项,确认所有操作都正常。
- 重新编译小程序,确认之前添加的数据还在。
这八个步骤走完,说明这个待办事项小程序的核心功能都是正常的。当然,这只是最简单的版本,大家以后可以在这个基础上继续加功能,比如编辑已添加的事项、添加截止时间、按照时间排序等等。但核心的增删改查逻辑能跑通,说明你的基础已经非常扎实了。
4. 常见问题与排查技巧:我踩过的坑,你直接绕开
实操过程中遇到的坑,比教程里讲的知识点更能让人成长。这一部分我把自己在Day11实践中遇到过的、以及身边朋友在类似练习中踩过的典型问题整理一下,每个都附上排查思路和解决方法,方便大家对照。
4.1 点击删除或切换没反应,数据也没变——dataset取值的类型陷阱
这个是我前面提到过的问题,但因为太典型了,值得单独拿出来说。
现象: 页面能正常渲染列表,但点击"删除"或者点击圆圈切换状态,界面完全没有变化。打开调试器看console,也没有明显的报错。
排查过程: 我一开始怀疑是不是事件没有绑定成功,反复检查了bindtap的写法,发现没问题。后来在deleteTodo里打印了一下id,发现打印出来的是一个字符串;再在todos数组里打印每一项的id,发现是数字。两边一对比,才意识到是类型不一致导致的条件判断失败。
解决方法: 在比较之前统一类型,用Number(event.currentTarget.dataset.id)把字符串转成数字。这个问题的隐蔽性在于,它不会直接报错,而是默默执行失败,所以特别容易让人摸不着头脑。
这个坑反映出一个编程习惯问题:不要假设数据类型相同。尤其是在小程序里,从视图层传到逻辑层的数据,大部分情况下都是字符串。你要么在写入数据的时候统一用字符串id,要么在比较的时候统一转成数字。不管选哪种,保证两边一致就好。
4.2 setData不生效——直接修改data数组导致视图不刷新
现象: 我在添加待办的代码里偷懒,写成了this.data.todos.push(newTodo),然后再this.setData({ todos: this.data.todos })。结果数据看起来是存进去了,因为console.log(this.data.todos)能看到新增项,但页面上的列表就是不更新。
排查过程: 查了文档之后确认,在小程序里,直接对this.data里的数组做push、splice这类操作,然后setData同样的引用,小程序是无法感知到变化发生的。因为它比对的是新旧数据是否不同,而这里的新数据和旧数据指向同一个数组对象,所以被认为"没有变化",视图不更新。
解决方法: 用展开运算符或者concat等方法创建新数组,然后setData新数组。比如:
javascript复制const todos = [...this.data.todos, newTodo];
this.setData({ todos });
这样每次setData的都是新数组,小程序就能检测到差异,视图自动更新。这个习惯一定要从一开始就养成,否则后面写复杂项目的时候会被折磨得不轻。
4.3 列表循环时没有设置wx:key,控制台一直报警告
现象: 渲染列表的时候没有写wx:key,页面功能正常,但控制台里出现了一条黄色警告:"Now you can provide attr wx:key for a wx:for to improve performance。"
排查过程: 这个问题其实不严重,因为它只是警告,不影响运行。但它是在提醒你,列表渲染需要提供唯一标识以优化性能。
解决方法: 给wx:for加上wx:key,并且给它一个有唯一值的字段名,直接写字段名即可,不需要{{}}包一层,写成wx:key="id"就能被正确识别。
4.4 输入框内容变了,但data里的inputValue还是空的——忘记监听bindinput
现象: 在输入框里输入了文字,点击"添加"按钮,结果添加的是一条空内容。
排查过程: 一看代码,发现我只在input标签上写了value="{{inputValue}}",没有写bindinput="handleInput"。也就是说,我虽然把inputValue绑定到了输入框的显示上,但输入框内容变化的时候,没有任何代码去更新inputValue。所以this.data.inputValue永远是初始值空字符串。
解决方法: 给input加上bindinput事件监听,在handleInput里用this.setData({ inputValue: event.detail.value })更新数据。这是小程序里"受控组件"的标准写法:你既要把数据源绑定上去,也要监听变更事件把新值拿回来。两者缺一不可。
4.5 缓存读取正常,但页面始终显示空列表——onLoad里setData失败
现象: 我在onLoad里通过wx.getStorageSync('todos')读取到了缓存数组,console.log(storedTodos)也能打印出完整数据,但页面上什么都没有。
排查过程: 我检查了console.log的日志,发现数据其实已经拿到了,问题出在setData上。我当时写的是:
javascript复制onLoad() {
const storedTodos = wx.getStorageSync('todos');
if (storedTodos) {
this.setData({
todos: storedTodos
});
}
}
理论上看起来没问题。但后来我发现,如果缓存里存的是一个空数组[],那么if (storedTodos)这个判断是成立的,因为空数组是truthy值,不会走else分支。问题出在别的地方——我拿到的storedTodos格式不对。
排查过程(续): 我打印了一下typeof storedTodos,发现是string类型,而不是对象或数组。原来在之前某次测试中,我用wx.setStorageSync('todos', JSON.stringify(this.data.todos))存了字符串格式的数据,后面读取的时候就拿不到对象,自然没法赋值给数组用。
解决方法: 在保存的时候直接存对象,不要手动JSON.stringify。小程序本身的setStorageSync是支持直接存对象或数组的,不需要你额外转换。如果之前不小心存了字符串,可以先在控制台执行一次wx.removeStorageSync('todos')清掉旧数据,再重新保存。
4.6 排列的待办项顺序不对——不确定数组操作方法的差异
现象: 添加的待办事项,想要新添加的显示在最上面,结果却在最下面。
排查过程: 我用的添加方式是[...this.data.todos, newTodo],新数据被追加到了数组末尾。而我希望新数据在最前面。
解决方法: 把数组操作改成[newTodo, ...this.data.todos],新数据就放到了数组开头。这不算bug,纯粹是需求逻辑不同。但很多新手在这里会困惑,为什么有时候用push、有时候用unshift、有时候用展开运算符,它们的区别以及适用场景,确实需要搞清楚。
我用一张表来总结这几个常见数组操作的差异:
| 操作 | 方法/写法 | 位置 | 是否修改原数组 | 适用场景 |
|---|---|---|---|---|
| 尾部追加 | arr.push(item) |
末尾 | 是 | 不推荐在小程序setData场景直接用 |
| 尾部追加(新数组) | [...arr, item] |
末尾 | 否 | 推荐,待办默认按时间先后排序 |
| 头部添加(新数组) | [item, ...arr] |
开头 | 否 | 推荐,想让新数据排在最上面时用 |
| 删除某一项 | arr.filter(fn) |
全部 | 否 | 推荐,按条件过滤 |
| 修改某一项 | arr.map(fn) |
全部 | 否 | 推荐,返回新数组 |
在实际开发中,我倾向于所有操作都用不修改原数组的方式,这样能最大程度规避setData的更新问题。
4.7 页面渲染正常,但频繁操作后页面卡顿——数据量太大时的性能优化思路
如果你在待办事项里添加了几百条数据,然后每条都绑定事件和样式,可能会觉得页面操作有一点卡。这是正常现象,因为小程序每setData一次,都会把数据从逻辑层传到视图层,传输的数据量越大,耗时越长。
优化思路: 如果列表数据特别长,可以考虑分页加载,只渲染当前需要展示的部分;或者给列表项加上唯一的key,让小程序能精确复用部分组件;再就是尽量减少不必要的setData,比如删除操作之前先检查是否存在匹配项。这些优化对新手来说可能用不上,但提前了解能让你在写大项目的时候少走弯路。
5. 今日收获与下一步学习方向
项目跑通之后,我回头做了个复盘。今天最大的收获不是"我会写待办事项了",而是把之前零散的知识点真正串成了完整的逻辑链。我在做项目之前,列表渲染和事件绑定是割裂的,数据绑定是半懂的,本地缓存是完全没概念的。做完这个项目,这四个知识点的关系一下子清晰了。数据是整个应用的中枢,视图只是数据的呈现方式,用户交互是数据变化的入口,而缓存是数据的持久化手段。整套逻辑,在多个功能开发场景里其实是通用的。
5.1 今天反复练习到的核心能力
今天一整天,我反复用到的东西其实就几样,但每一样都练得非常扎实:
data里定义数据,页面上用{{}}渲染数据。- 通过
bindinput、bindtap、bindconfirm监听用户操作。 - 通过
event.currentTarget.dataset获取当前操作项的唯一标识。 - 通过
setData更新数据,驱动视图变化。 - 用
wx.setStorageSync和wx.getStorageSync实现数据的本地持久化。 - 用
wx:for和wx:if处理列表渲染和条件渲染。
这几样东西组合起来,其实已经能写出很多基础小工具了。比如记账本、习惯打卡、购物清单,背后的逻辑大同小异。大家如果学有余力,可以先别急着学新东西,把这几个练熟,试着改一改这个待办事项,研究一下怎么加一个编辑功能——点击某一条待办,弹出一个输入框让你修改它的内容。这个功能练完,你对小程序数据流的理解会更上一层楼。
5.2 从Day11到下一步:这个项目还能怎么扩展
这里我给大家提供几个扩展方向,由易到难排列:
- 增加编辑功能:点击待办事项文字,弹出输入框或者跳转到编辑页,修改内容后再保存。
- 增加筛选功能:全部、未完成、已完成三个选项卡,点击切换列表显示范围。
- 增加清空已完成功能:一键删除所有
completed为true的数据。 - 增加数据统计:除了"共X项,已完成Y项",还可以加一个完成率的进度条。
- 增加本地离线提示:在小程序启动时检查缓存数据是否存在,如果有则显示"上次数据已恢复"的提示。
- 增加主题换肤:提供几个不同颜色的主题供用户选择,选择结果存到缓存。
每一个扩展方向,都会逼你去查文档、动手试错,这个过程本身就是最好的学习。
5.3 给同样在自学前端的朋友几句心里话
早上我打开朋友圈,看到有人发了这么一句话:"前端开发就像拼图,单独看每一块都不难,难的是怎么把它们拼成一张完整的图。"深有同感。今天做这个项目,我从上午十点做到下午三点多,中间卡在dataset类型转换上快一个小时,一度觉得自己特别笨。但解决问题的过程,恰恰是记忆最深刻的过程。踩过坑之后,你再也不会忘了要在比较之前统一数据类型。
如果你也正在自学,我想说:不要害怕卡住,卡住了才说明你在往上走。学前端也好,学小程序开发也好,最重要的不是学了多少知识点,而是能不能把学到的知识用起来。动手做一个哪怕很小的项目,真的比看十遍教程有用得多。
对了,最后再分享一个小技巧:写代码之前,先在纸上或者备忘录里把页面的功能画出来,然后标注每一个功能要改哪些数据。这个小习惯我今天刚开始做,发现写代码的速度和准确率都提升了不少。也希望大家接下来都能顺顺利利地把属于自己的第一个小程序做出来。
