ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序原生课程表开发:周视图与本地存储实战

微信小程序原生课程表开发:周视图与本地存储实战

简介:这份资源是面向微信小程序初学者与前端练手者的原生课程表项目源码,基于微信小程序原生框架实现,可帮助读者快速理解并搭建一个查看、管理每周课程安排的小工具,适合学生、教师及想熟悉小程序开发流程的开发者参考。压缩包为rar格式,共12个文件,约8KB,包含5个json配置文件、4个js逻辑脚本、2个wxss样式表与1个wxml页面结构文件,分别承担项目配置、数据交互、界面样式与视图布局等职责。目前已有811人学习下载。项目覆盖开发环境搭建、WXML条件与循环渲染、WXSS的Flex布局、data数据绑定与bindtap事件交互、wx.showModal弹窗及本地存储API、页面生命周期方法、响应式适配与调试发布等知识点,读者可据此掌握课程表从页面结构到数据持久化的完整实现思路,并借鉴其状态管理与用户体验优化做法。

1. 微信小程序(原生)课程表:从一张空课表到能用的周视图

每学期开学第一周,总有人在群里问「今天第几周、上什么课」。市面上的课程表 App 不少,但要么广告多,要么要登录,要么导入流程绕。用微信小程序(原生)自己写一个课程表,最大的好处是:不用装 App,微信里点开就用,数据存本地,没有账号体系,也没有后端成本。它适合两类人:一类是想练手原生小程序、又不想做玩具 Demo 的前端;另一类是真的需要一个干净课表、愿意花一个周末把它做出来的学生或老师。

这篇不讲「小程序是什么」,直接按能跑通的顺序走:先把周视图和数据结构定下来,再写渲染和手势,然后处理本地存储、导入导出、周次切换,最后把几个必踩的坑摊开讲。全程只用原生 WXML/WXSS/JS,不引第三方 UI 库,保证你复制过去就能改。

2. 先把数据模型和页面骨架定死:别急着写样式

2.1 课程表的数据结构为什么不能拍脑袋定

很多人一上来就写lessons: [{name, room, teacher}],写到第三周发现要支持单双周、要支持连堂、要支持调课,结构就崩了。课程表的核心不是「一节课」,而是「一个时间格子上的一段占用」。我一般会拆成三层:节次配置、课程条目、周次规则。

节次配置决定一天有几节课、每节几点开始,这是渲染左侧时间轴的依据。课程条目是用户录入的最小单位,包含课程名、地点、老师、颜色。周次规则决定这条课在第几周到第几周、单周还是双周出现。把这三层分开,后面加「临时调课」只需要在渲染层做覆盖,不动原始数据。

// config/schedule.js // 节次配置:一天 12 节,每节开始时间用于左侧时间轴 const SECTIONS = [ { index: 1, start: '08:00', end: '08:45' }, { index: 2, start: '08:55', end: '09:40' }, { index: 3, start: '10:00', end: '10:45' }, { index: 4, start: '10:55', end: '11:40' }, { index: 5, start: '14:00', end: '14:45' }, { index: 6, start: '14:55', end: '15:40' }, { index: 7, start: '16:00', end: '16:45' }, { index: 8, start: '16:55', end: '17:40' }, { index: 9, start: '19:00', end: '19:45' }, { index: 10, start: '19:55', end: '20:40' }, { index: 11, start: '20:50', end: '21:35' }, { index: 12, start: '21:45', end: '22:30' } ]; // 课程条目:一条课 = 一个格子上的占用 // weeks 用字符串描述,如 "1-16" 表示 1 到 16 周,"1-16|单" 表示单周 const SAMPLE_LESSON = { id: 'l_001', name: '高等数学', room: '教三 201', teacher: '某老师', day: 1, // 1=周一 ... 7=周日 startSection: 1, // 从第几节开始 endSection: 2, // 到第几节结束(连堂) weeks: '1-16', color: '#5B8FF9' }; module.exports = { SECTIONS, SAMPLE_LESSON };

这段代码里,day用 1 到 7 而不是 0 到 6,是为了和「周一」这种人类说法对齐,少一次心智转换。startSection和endSection分开,是为了支持连堂——两节课占一个格子,渲染时高度翻倍。weeks用字符串而不是数组,是因为导入导出时字符串更好读,解析成本也低。

提示:weeks的格式一定要在项目一开始就定死,后面所有解析、判断、导入导出都依赖它。我见过中途从数组改成字符串、结果导入功能全废的情况。

2.2 页面骨架:周视图的 DOM 怎么摆

原生小程序没有虚拟 DOM 的复杂概念,但setData的性能开销是实打实的。周视图的结构建议是:外层一个横向滚动容器,里面左侧固定时间轴、右侧七天网格。网格用绝对定位放课程块,而不是用 flex 一行行排——因为课程块要跨节次,flex 很难表达「从第 1 节到第 2 节」这种纵向跨度。

<!-- pages/schedule/schedule.wxml --> <view class="schedule"> <!-- 顶部周次切换 --> <view class="week-bar"> <view class="week-btn" bindtap="prevWeek">上一周</view> <view class="week-title">第 {{currentWeek}} 周</view> <view class="week-btn" bindtap="nextWeek">下一周</view> </view> <!-- 主体:左侧时间轴 + 右侧七天网格 --> <scroll-view class="grid-wrap" scroll-x> <view class="grid" style="width: {{gridWidth}}px;"> <!-- 时间轴 --> <view class="time-axis"> <view wx:for="{{sections}}" wx:key="index" class="time-cell"> <text>{{item.index}}</text> <text class="time-text">{{item.start}}</text> </view> </view> <!-- 七天列 --> <view class="days"> <view wx:for="{{days}}" wx:key="day" class="day-col"> <view wx:for="{{item.lessons}}" wx:key="id" class="lesson-block" style="top: {{item.top}}px; height: {{item.height}}px; background: {{item.color}};" bindtap="onLessonTap" >// utils/layout.js const SECTION_HEIGHT = 60; // 每节课块高度 const SECTION_GAP = 4; // 节间距 // 把一条课程转成带 top/height 的渲染对象 function toRenderLesson(lesson) { const top = (lesson.startSection - 1) * (SECTION_HEIGHT + SECTION_GAP); const span = lesson.endSection - lesson.startSection + 1; const height = span * SECTION_HEIGHT + (span - 1) * SECTION_GAP; return { ...lesson, top, height }; } // 判断某条课在第 week 周是否出现 function isLessonInWeek(lesson, week) { const [range, parity] = lesson.weeks.split('|'); const [start, end] = range.split('-').map(Number); if (week < start || week > end) return false; if (parity === '单' && week % 2 === 0) return false; if (parity === '双' && week % 2 === 1) return false; return true; } module.exports = { toRenderLesson, isLessonInWeek, SECTION_HEIGHT, SECTION_GAP };

isLessonInWeek是后面周次切换的核心。注意weeks里可能没有|,所以split('|')后parity是undefined,判断时用=== '单'这种严格比较,避免undefined被当成真值。这个函数每次切周都会调用,数据量大时可以先按周缓存,但一般课表几十条数据,直接算就够。

3. 手势、周次与本地存储:让课表真正能用起来

3.1 左右滑动切周:用 touch 事件而不是 scroll-view

顶部按钮切周能用,但体验一般。课程表用户习惯左右滑。原生小程序里可以用bindtouchstart和bindtouchend算横向位移,超过阈值就切周。不要用scroll-view的scroll事件去判断,因为横向滚动容器本身要用来放七天网格,两者会打架。

// pages/schedule/schedule.js Page({ data: { currentWeek: 1, days: [], // 七天,每天一个 lessons 数组 sections: [], gridWidth: 0 }, onLoad() { this.initGrid(); this.renderWeek(1); }, // 初始化网格宽度:时间轴 60 + 七天 * 100 initGrid() { const { SECTIONS } = require('../../config/schedule'); this.setData({ sections: SECTIONS, gridWidth: 60 + 7 * 100 }); }, // 渲染某一周:过滤 + 布局 renderWeek(week) { const { toRenderLesson, isLessonInWeek } = require('../../utils/layout'); const all = wx.getStorageSync('lessons') || []; const days = []; for (let d = 1; d <= 7; d++) { const lessons = all .filter(l => l.day === d && isLessonInWeek(l, week)) .map(toRenderLesson); days.push({ day: d, lessons }); } this.setData({ currentWeek: week, days }); }, prevWeek() { if (this.data.currentWeek > 1) { this.renderWeek(this.data.currentWeek - 1); } }, nextWeek() { if (this.data.currentWeek < 20) { this.renderWeek(this.data.currentWeek + 1); } }, // 手势切周 onTouchStart(e) { this.startX = e.touches[0].clientX; }, onTouchEnd(e) { const dx = e.changedTouches[0].clientX - this.startX; if (dx > 60) this.prevWeek(); if (dx < -60) this.nextWeek(); } });

renderWeek每次都从 storage 读全量数据再过滤,看起来笨,但课表数据量小,这样写最不容易出 bug。如果以后要支持几百条课,可以把全量数据缓存在this.allLessons,storage 只在增删改时写。阈值 60px 是试出来的:太小容易误触,太大滑不动。这个值跟屏幕宽度有关,严谨点可以用screenWidth * 0.15。

3.2 本地存储:为什么用 Storage 而不是云开发

课程表是纯个人数据,没有社交、没有多端同步刚需,用wx.setStorageSync就够了。云开发要开通环境、要处理权限、要防并发写,对一个小课表来说是过度设计。Storage 的坑在于:单个 key 有大小限制,且是同步 API,写大数组会卡。所以存的时候只存原始课程数组,不存渲染后的对象。

// utils/storage.js const KEY = 'lessons'; function getLessons() { return wx.getStorageSync(KEY) || []; } function saveLessons(lessons) { // 只存原始字段,去掉 top/height 这些渲染态 const raw = lessons.map(l => ({ id: l.id, name: l.name, room: l.room, teacher: l.teacher, day: l.day, startSection: l.startSection, endSection: l.endSection, weeks: l.weeks, color: l.color })); wx.setStorageSync(KEY, raw); } function addLesson(lesson) { const list = getLessons(); list.push(lesson); saveLessons(list); } module.exports = { getLessons, saveLessons, addLesson };

saveLessons里做了一次字段裁剪,这是血泪经验:如果直接把渲染对象存进去,下次读出来会带着旧的top/height,布局一变就错位。存储层只认原始数据,渲染层每次重新算,职责清晰。

3.3 导入导出:用 JSON 文本走剪贴板

课程表最烦的是每学期重新录。做一个导出成 JSON 文本、复制到剪贴板、再导入的功能,换手机或重装小程序时能救命。原生小程序用wx.setClipboardData和wx.getClipboardData,不需要文件系统权限。

// pages/schedule/schedule.js 里追加 exportData() { const { getLessons } = require('../../utils/storage'); const text = JSON.stringify(getLessons()); wx.setClipboardData({ data: text, success: () => wx.showToast({ title: '已复制到剪贴板' }) }); }, importData() { const { saveLessons } = require('../../utils/storage'); wx.getClipboardData({ success: (res) => { try { const list = JSON.parse(res.data); if (!Array.isArray(list)) throw new Error('不是数组'); // 简单校验必填字段 const valid = list.every(l => l.name && l.day && l.startSection); if (!valid) throw new Error('字段缺失'); saveLessons(list); this.renderWeek(this.data.currentWeek); wx.showToast({ title: '导入成功' }); } catch (err) { wx.showToast({ title: '格式不对', icon: 'none' }); } } }); }

导入一定要做校验,否则用户粘一段乱七八糟的文本进来,JSON.parse抛错还是小事,存进去脏数据导致整个课表渲染不出来才是大问题。校验至少看三样:是不是数组、每条有没有name、day和startSection是不是数字。宁可提示「格式不对」,也不要静默失败。

4. 避坑与排查:课程表最容易翻车的五个地方

4.1 课程块重叠:连堂和单双周同时出现时

现象:同一时间段出现两个课程块,叠在一起看不清。原因通常是数据里存在两条day和节次都相同、但周次规则不同的课,比如一条单周一条双周,渲染时没做互斥判断。解决:在renderWeek里对同一天同一节次的课做一次冲突检测,如果两条课在当前周都出现,按id排序后只保留一条,或者给第二条加偏移量。更稳妥的做法是在录入时就校验冲突,不让用户存进去。

4.2 setData 数据量过大导致卡顿

现象:切周时页面明显卡一下,低端机更明显。原因:days里塞了完整课程对象,包含teacher、color等渲染用不到的字段,setData序列化开销大。解决:toRenderLesson里只保留 WXML 用到的字段,name、room、top、height、color、id,其余在渲染前丢掉。另外days结构不要嵌套太深,七天各一个数组已经够用。

4.3 周次解析把「1-16|单」当成「1-16」

现象:单周课在双周也显示。原因:split('|')后没判断parity是否存在,或者用了if (parity)这种宽松判断,空字符串被当成假值但undefined也走同一分支。解决:严格用parity === '单'和parity === '双',其余情况一律视为全周。这个坑我在两个项目里都见过,属于典型玄学 bug,查半天。

4.4 真机上 scroll-view 横向滚动不跟手

现象:开发者工具里滑动正常,真机上七天网格滑不动。原因:scroll-view要横向滚动,必须同时设置scroll-x和子元素宽度大于容器宽度,且子元素不能是display: inline。解决:grid用display: flex或inline-block并显式设width,scroll-view加white-space: nowrap。另外真机上scroll-view的手势和页面纵向滚动会冲突,给scroll-view加enable-flex属性有时能缓解。

4.5 存储写入时机不对导致数据丢失

现象:用户添加课程后直接杀进程,再打开发现没存上。原因:用了异步wx.setStorage但没等回调,或者把保存逻辑放在onUnload里,而onUnload在杀进程时不一定触发。解决:增删改后立即同步写wx.setStorageSync,不要攒着。同步写虽然会阻塞几十毫秒,但课表数据量小,完全可接受。宁可卡一下,不要丢数据。

5. 进阶技巧:用「当前周」自动定位和颜色去重

课表做到能用之后,最值得加的一个功能是「打开自动定位到当前周」。很多课表 App 都要手动翻,其实只要在onLoad里算一下今天距离开学第几周就行。这里的关键是让用户设置一个「开学日期」,存到 storage,然后每次打开算差值。

// utils/week.js // 计算当前是第几周:开学日期所在周为第 1 周 function getCurrentWeek(startDateStr) { const start = new Date(startDateStr); const now = new Date(); // 把两个日期都归到周一,避免周中开学导致偏差 const startMonday = getMonday(start); const nowMonday = getMonday(now); const diffDays = Math.floor((nowMonday - startMonday) / 86400000); return Math.floor(diffDays / 7) + 1; } function getMonday(date) { const d = new Date(date); const day = d.getDay() || 7; // 周日算 7 d.setDate(d.getDate() - day + 1); d.setHours(0, 0, 0, 0); return d; } module.exports = { getCurrentWeek };

getMonday里d.getDay() || 7是个小技巧:getDay()周日返回 0,0 || 7得到 7,这样周一永远是 1。归到周一再算差值,能避免「周三开学、周五打开算成第 1 周还是第 0 周」这种边界问题。算出来的周数如果小于 1 或大于 20,就回退到第 1 周,防止开学日期设错导致白屏。

另一个值得做的是颜色去重。用户手动给每门课选颜色很烦,可以按课程名做哈希,自动分配一个色板里的颜色。色板不要用纯红纯绿,对比度太低,选 8 到 10 个中等饱和度的颜色,保证白字能看清。

参数建议值说明
节次高度60px低于 50px 课程名会换行
节间距4px太大浪费纵向空间
切周阈值60px约屏宽 15%
色板数量8 到 10太少容易重复,太多记不住
周次上限20一般学期 16 到 18 周

最后说个我自己的习惯:每次改完渲染逻辑,一定在真机上用「第 1 周、第 8 周、第 16 周」三个点各切一遍,因为单双周和连堂的 bug 往往只在特定周次暴露。开发者工具太干净,真机上的滚动和触摸才是照妖镜。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进