一直有个现象,我觉得特别值得说:很多朋友学完JavaScript基础语法,数组也会用,函数也会写,ES6的箭头函数、模板字符串也见过。结果一旦说“自己做个项目”,立刻就懵了。语法知识点零散地摆在脑子里,真到要把数据管理、DOM渲染、事件交互、状态刷新串成一条线的时候,不知道第一步该干什么。这种“看得懂但写不出”的落差,是学习曲线里最难受的一段。今天这篇综合实战,就是把待办清单和无缝轮播图这两个项目放在一起做,用纯JavaScript从头到尾实现,帮大家把零散的语法点拧成一股能用的力。
这两个项目一个偏逻辑、一个偏交互,加起来基本覆盖了前端基础里最高频的场景:列表数据的增删改查、表单处理、本地存储、定时器、DOM操作、CSS过渡动画。做完这两个,你再看框架的文档,很多概念会变得特别好理解,因为底层原理你已经亲手碰过了。
1. 两个项目放一起练,到底在练什么
1.1 一个管逻辑,一个管交互,组合起来刚好是一套完整技能
很多人会有个误区:觉得练项目就要练大的,最好是一上来就做一个仿某某网站。实际上从学习角度说,这种大项目反而难以下手,因为功能太多、代码太长,最后很容易变成照着眼教程敲一遍,敲完还是不会独立写。
待办清单的核心是“业务逻辑”:任务数据怎么存、怎么改、怎么渲染到页面上,用户的某个操作对应的数据变化是什么。你做它的时候,脑子里要时刻有一张数据变化表——点删除按钮,数组里哪一条要移除,页面怎么跟着变。这是数据驱动视图的基础思维。
无缝轮播图的核心是“界面交互”:布局怎么排、位移怎么算、动画怎么过渡、自动播放和手动点击怎么协作。做它的时候,你在处理的是浏览器的显示机制、过渡事件、定时器调度。这是一套完全不同的实战技能。
把这两个项目放在同一个综合实战里练,是因为它们正好互补:如果你只做待办清单,可能整天在和数组、条件判断打交道,对界面的动态切换缺乏感觉;如果你只做轮播图,又会陷入只调样式、不怎么碰数据的局面。两个都做完,你才算把“数据逻辑”和“界面表现”这两条腿都迈出去了。
1.2 适合什么基础的人,做到什么程度算完成
这个项目不适合完全零基础的人。你需要先知道JavaScript的变量、函数、数组、对象、循环、条件判断,至少见过箭头函数和模板字符串。对这些语法不需要背得多熟,但要有印象,因为在项目里你会反复用到它们。
如果你刚学完基础语法还处于“半懂不懂”的阶段,做这个项目的过程中,那些模糊的语法会慢慢变得清晰。比如模板字符串这个特性,当你写${todo.text}时,会真正体会到它比字符串拼接省了多少事;再比如map、filter、join这些数组方法,不是“好像见过”而是“原来要这样用”。
做到什么程度算完成?我的标准很简单:不看教程,能独立从空白HTML文件开始,把待办清单的增删改查、筛选、刷新页面数据不丢这些功能实现出来;轮播图的自动播放、手动切换、循环不空白、快速点击不混乱这些行为都能正常工作。能达到这个水平,再学习其他框架或工具,基础就会扎实很多。
1.3 动手前的最低知识清单
在正式写代码前,先确认下面这些概念你都有基本认知:
- 数组的
push、splice、filter、map方法 - 对象属性访问和修改
- DOM的
querySelector、addEventListener、创建/修改元素 JSON.stringify与JSON.parse- 简单的CSS
flex布局和transition过渡 setInterval与clearInterval
这些不需要你精通,在项目里我会逐个讲到它们怎么配合。遇到不熟的,在浏览器控制台里敲一遍看返回结果,比死记文档高效多了。
2. 开工前的环境与目录安排,运行边界提前厘清
2.1 HBuilderX里创建项目并配置HTML/CSS/JavaScript三种文件
工具有很多,VS Code、WebStorm、HBuilderX都可以。如果你正好用HBuilderX,它的配置路径很简单:新建一个普通Web项目,会自动生成index.html和css目录。我习惯手动补齐目录结构:
todo-slider/ ├── index.html ├── css/ │ └── style.css └── js/ ├── todo.js └── slider.jsindex.html里通过<link>引入CSS,通过两个<script>标签分别引入两个JS文件。这里有个关键顺序问题:todo.js和slider.js没有相互依赖,谁在前谁在后都行,但都必须放在</body>之前。
对应到HBuilderX,就是在index.html里写好基本骨架后,把CSS和JS文件入口引用填上。注意一点:HTML文件的编码推荐用UTF-8,并在<head>里加上<meta charset="UTF-8">,否则中文注释和页面里的中文内容很容易变成乱码。
2.2 script标签位置与模块划分的坑
很多新手第一次写综合项目时,习惯把所有代码堆在一个JS文件里,或者写在HTML的<script>标签中。待办清单和轮播图代码量虽然不算大,但混在一起调试时还是会头晕。按功能拆成todo.js和slider.js两个文件,各管各的,排查问题时定位很快。
script标签位置这个坑,我见得很频繁。如果把<script>放在<head>里,JS代码执行时DOM还没解析完,document.getElementById('app')拿到的就是null,后面绑定事件或者修改属性会直接报错。比如:
// 放在 head 里大概率报错:Cannot read properties of null document.getElementById('todoForm').addEventListener('submit', handler);解决办法有两个:一是把script标签放到</body>之前,让DOM先解析完再执行JS;二是给script标签加defer属性。对于这两个项目,最简单的就是前者,不用引入额外概念。
还有个小建议:不要在HTML里直接用onclick属性绑定事件。把事件监听写在JS文件里,逻辑更集中,也不容易在代码量上去之后出现“事件绑定散落各处”的混乱。
2.3 调试面板怎么配合项目使用
做综合项目时,浏览器开发者工具是你的最佳拍档,尤其是Console和Sources两个面板。
Console面板最常用的是console.log,很多人觉得它太初级,实际上在数据变化追踪上极为高效。比如在待办清单里,每次点击删除按钮后,打印一下todos数组,就能马上确认数据是否正确更新。如果数据变了但页面没变,问题就出在渲染层;如果数据压根没变,那问题在交互层的事件绑定或数据处理上。这种“数据对不对、视图对不对”的分步排查思路,能帮你快速缩小问题范围。
Sources面板的断点调试也很重要,尤其遇到逻辑复杂的轮播图。在JS文件里点一下行号加上断点,刷新页面,代码执行到那里会暂停,右侧可以看到当前所有变量的值。这比自己猜测代码走到哪一步了要精确得多。把断点调试学会后,排查逻辑错误的速度提升不止一倍。
3. 待办清单实战:增删改查怎么设计才不乱
3.1 数据层先想清楚:数组结构、id生成、localStorage封装
待办清单的第一个决定,就是数据用什么结构存。很多人上来就搞一个字符串数组['买牛奶', '写作业'],写起来倒是简单,但后面加“完成状态”、“创建时间”、“编辑内容”时就很难受。正确做法是用对象数组,每个任务是一个对象:
let todos = [ { id: 1, text: '学习JavaScript数组方法', completed: false, createdAt: 1735200000000 } ];id字段必须要。删除任务、切换完成状态时,你不能靠任务序号来定位,因为删除中间一项后序号全变了。用稳定唯一的id是最可靠的。
id怎么生成?新手阶段别引入数据库自增那套,直接用Date.now()加一个自增计数器组合:
let idCounter = 0; function createId() { idCounter += 1; return Date.now() + '-' + idCounter; }这个方案在单机本地场景下不会重复,而且实现简单。你不用纠结它是不是绝对唯一,够用就行。
localStorage的封装要单独抽出来,不要散落在各个操作里:
const STORAGE_KEY = 'todo-app-data'; function loadTodos() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; try { return JSON.parse(raw); } catch (e) { return []; } } function saveTodos() { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); }try...catch很重要。如果用户曾经往localStorage里写坏数据,或者手动改了数据格式,JSON.parse会抛异常,没有异常处理的直接会让整个页面崩溃。
localStorage的机制很简单:只能存字符串。所以对象数组必须先JSON.stringify序列化才能存,读取时再JSON.parse反序列化成数组。就这么一层转换,很多新手漏掉,结果存进去的是[object Object]。
3.2 渲染层用一个render函数统管
待办清单的页面渲染,我强烈建议只有一个render函数,任何数据变化后都调它一次。不要在每个操作里手动去创建DOM节点、追加DOM节点,那样代码很快变成一团乱麻。
function render() { const listEl = document.getElementById('todoList'); const filteredTodos = getFilteredTodos(); // 根据筛选条件返回数组 const html = filteredTodos.map(todo => ` <li class="${todo.completed ? 'done' : ''}">function escapeHtml(str) { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }这个函数你要习惯性地用,做前端开发久了,凡是把用户输入拼进HTML的地方,都要有转义意识。
提到getFilteredTodos(),它是后面筛选功能的核心,我这里先把代码贴出来,它根据当前筛选条件过滤数组:
let currentFilter = 'all'; // 可选值:all / active / completed function getFilteredTodos() { if (currentFilter === 'active') { return todos.filter(todo => !todo.completed); } if (currentFilter === 'completed') { return todos.filter(todo => todo.completed); } return todos; }3.3 交互层:表单提交、事件委托、增删改切换状态的实现
待办清单的交互入口是新增任务。HTML里用一个表单:
<form id="todoForm"> <input id="todoInput" type="text" required> <button type="submit">添加</button> </form>JS里监听表单的submit事件:
const form = document.getElementById('todoForm'); form.addEventListener('submit', function(e) { e.preventDefault(); const input = document.getElementById('todoInput'); const text = input.value.trim(); if (!text) return; todos.push({ id: createId(), text: text, completed: false, createdAt: Date.now() }); saveTodos(); render(); input.value = ''; });这里就要聊到热搜词里提到的“表单提交和H5的区别”。传统表单提交是浏览器原生行为:用户点击提交按钮,浏览器会向action指定的地址发请求,然后整页刷新或跳转。而我们在单页应用里要的是“不刷新页面,用JavaScript处理数据”,所以关键就是e.preventDefault(),这个方法把浏览器默认的提交行为拦下来,然后交给JS处理。
HTML5带来的required属性和type校验,是在浏览器层面做基础校验。比如required会让空表单无法提交,并显示浏览器提示。但注意,这并不能取代JS校验,因为你需要对数据做进一步处理,比如去掉首尾空格。去除空格这步很关键,不然用户输入“ 买东西 ”,存进去还带着空格,任务列表看着就难受。
删除和切换状态这两个操作,我用的是事件委托,而不是给每个按钮单独绑定事件。原因很简单:任务列表的项是动态增删的。如果你在创建每项时单独绑事件,新加的项必须重新绑一次,删掉的项如果不解绑还可能有内存隐患。事件委托的思路是:只给它们共同的父容器绑定一次监听,通过判断实际点击的目标元素来决定执行什么操作。
const listEl = document.getElementById('todoList'); listEl.addEventListener('click', function(e) { const li = e.target.closest('li'); if (!li) return; const id = Number(li.dataset.id); const action = e.target.dataset.action; if (action === 'delete') { todos = todos.filter(todo => todo.id !== id); saveTodos(); render(); } if (action === 'toggle') { const todo = todos.find(todo => todo.id === id); if (todo) { todo.completed = !todo.completed; saveTodos(); render(); } } });e.target.closest('li')是从实际点击的元素沿着DOM树往上找最近的<li>。这样不管你点到的是<span>还是<button>,都能拿到对应的任务项。>document.querySelectorAll('.filter-btn').forEach(btn => { btn.addEventListener('click', function() { currentFilter = this.dataset.filter; render(); }); });
注意这里用了querySelectorAll,返回的是一个类数组,用forEach遍历并分别绑定事件。所有筛选按钮放在同一个父容器上,用><div class="slider-wrapper" id="sliderWrapper"> <ul class="slider-list" id="sliderList"> <li class="slider-item"><img src="images/1.jpg" alt=""></li> <li class="slider-item"><img src="images/2.jpg" alt=""></li> <li class="slider-item"><img src="images/3.jpg" alt=""></li> <li class="slider-item"><img src="images/4.jpg" alt=""></li> </ul> </div>
CSS核心部分:
.slider-wrapper { overflow: hidden; } .slider-list { display: flex; transition: transform 0.4s ease; } .slider-item { flex: 0 0 100%; }这里flex: 0 0 100%的意思很关键,flex-basis设为100%,每个列表项宽度等于外层容器宽度。flex-shrink: 0(简写在flex属性的第二个值)保证列表项不会被压缩。这样4张图横向排成一行,总宽度是容器宽度的4倍,通过修改transform的translateX来左右移动。
4.2 无缝衔接的核心:首尾克隆与瞬间切换
无缝轮播的难点在于:当你从左向右播完最后一张图后,下一张应该回到第一张,但不能出现“唰”地一下倒退回第一张的动画。这个问题的标准解法是:列表首尾各克隆一张对应图片。
具体来说,如果你的原始图片是[1, 2, 3, 4],克隆后列表变成[4克隆, 1, 2, 3, 4, 1克隆]。初始显示的是第2个位置,也就是原始第1张图。
我直接用代码展示完整逻辑:
const wrapper = document.getElementById('sliderWrapper'); const list = document.getElementById('sliderList'); const originalItems = Array.from(list.children); const itemCount = originalItems.length; // 克隆首尾节点 const firstClone = originalItems[0].cloneNode(true); const lastClone = originalItems[itemCount - 1].cloneNode(true); list.appendChild(firstClone); list.insertBefore(lastClone, list.firstChild); // 当前显示的位置:1 表示第一张原始图 let currentIndex = 1; const itemWidth = wrapper.clientWidth; const isAnimating = false; function render(animate = true) { list.style.transition = animate ? 'transform 0.4s ease' : 'none'; list.style.transform = `translateX(${-currentIndex * itemWidth}px)`; } function goNext() { if (isAnimating) return; if (currentIndex >= itemCount) return; // 已经在最后一张克隆图 currentIndex++; render(); // 如果展示到了末尾的克隆图,这次动画结束后要瞬间切回原始第一张 if (currentIndex === itemCount + 1) { handleTransitionEnd(() => { currentIndex = 1; render(false); }); } }可能有人要问:为什么currentIndex从1开始,而不是0?因为列表经过克隆后,下标0是我放在最前面的4克隆,下标1才是原始第1张。初始translateX是-1 * itemWidth,也就是把列表向左移动一个视口宽度,这样视口里正好显示原始第1张。
handleTransitionEnd是监听transitionend事件封装的:
function handleTransitionEnd(callback) { const handler = function() { list.removeEventListener('transitionend', handler); callback(); }; list.addEventListener('transitionend', handler); }为什么要等transitionend而不是直接改?因为必须等位移动画播放完,视觉上停在最后一张克隆图时,才能瞬间切回原始第一张。切换时设置transition: none,让位移变化不产生动画,然后再恢复过渡。因为克隆图内容和原始图完全一样,且宽度一致,所以这个瞬间切换用户是察觉不到的。
这里要特别说明一个容易写错的地方:切回之后translateX要从-(itemCount + 1) * itemWidth变成-itemWidth。看起来数值跨度很大,但由于transition: none,浏览器不会补间动画,是硬切。用户在视觉上看到的画面没有变化,仍然是一张完全相同的图,这就无缝了。
4.3 手动切换和自动播放怎么协作
手动切换支持上一张、下一张两个按钮。下一张的逻辑上面已经写了,上一张是对称的:
function goPrev() { if (isAnimating) return; if (currentIndex <= 0) return; currentIndex--; render(); if (currentIndex === 0) { handleTransitionEnd(() => { currentIndex = itemCount; render(false); }); } }走到下标0(最前面放的4克隆)后,动画结束瞬间切到currentIndex = 4(原始第4张),translateX也是瞬间切换,视觉依旧无缝。
自动播放用setInterval:
let timer = null; function startAutoPlay() { stopAutoPlay(); timer = setInterval(goNext, 3000); } function stopAutoPlay() { if (timer) { clearInterval(timer); timer = null; } } wrapper.addEventListener('mouseenter', stopAutoPlay); wrapper.addEventListener('mouseleave', startAutoPlay);这里有个细节:startAutoPlay里先调用stopAutoPlay清掉旧定时器再设新定时器,防止重复调用导致多个定时器叠加。很多新手直接写timer = setInterval(...),没有清旧定时器,鼠标进出几次之后,轮播速度变得飞快,就是定时器叠加了。
如果你要在startAutoPlay中用箭头函数,注意clearInterval要拿到同一个变量值。把timer定义在外面作为模块级变量,这样在多个函数里都能访问和修改,这就是闭包和模块作用域的典型配合。
手动点击按钮后,如果你还想让自动播放继续,可以在点击处理里重新调用startAutoPlay(),更新定时器,避免用户点了之后还要等很久才走下一张。如果希望点了之后暂停,那就别重启定时器。这个根据产品需求来,无所谓对错,但要在代码里明确。
4.4 尺寸变化、快速点击、图片未加载的边界处理
轮播图最容易出问题的不是正常逻辑,而是各种边界情况。这里把常见问题集中说一遍:
1. 窗口宽度变化。itemWidth = wrapper.clientWidth只在初始化时算了一次。用户拖动窗口大小后,每个列表项宽度变了,但translateX还是按旧宽度算的,位移就会错位。解决办法是监听resize事件,重新计算宽度并基于当前currentIndex重新渲染:
let resizeTimer = null; window.addEventListener('resize', function() { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(function() { itemWidth = wrapper.clientWidth; render(false); }, 200); });这里用了简单的防抖:200毫秒内连续的resize事件只处理最后一次,避免频繁重算。
2. 快速点击按钮。多次点击下一张,第一个动画还没结束,第二次点击又改了translateX,浏览器会直接跳转到新位置,中间的过渡动画被截断,看起来就像是卡了一下。更严重的是,如果当前已经处于末尾克隆图,你还在点击“下一张”,此时逻辑上currentIndex已经到itemCount + 1,不能再加了。代码里已有的if (currentIndex >= itemCount) return就是这个保护。
但如果你还想要更强的防重复点击效果,可以加一个isAnimating锁:
let isAnimating = false;在动画开始时设为true,在handleTransitionEnd回调里设为false。goNext和goPrev开头都判断if (isAnimating) return。这样每次动画执行期间,用户再点击任何方向按钮都不会生效,彻底杜绝了连点导致的状态错乱。
3. 图片还没加载完就初始化。如果图片比较慢,wrapper.clientWidth其实已经有值(容器宽度由CSS决定),但图片没加载时li的高度可能塌陷,导致列表项高度不一致。简单办法是给.slider-item设置固定宽高比,或者给img设置width: 100%; height: 100%; object-fit: cover,让图片填满列表项。
如果轮播图初始化时依赖图片加载完成后才能算准位置,可以监听window的load事件:
window.addEventListener('load', function() { itemWidth = wrapper.clientWidth; render(false); startAutoPlay(); });load事件会等页面上所有资源加载完才触发,比DOMContentLoaded更晚。如果图片资源没加载成功,你也应该在img的onerror里给一个占位背景色,避免显示破图影响观感。
5. 调试实录:这两个项目里最容易翻车的四个地方
5.1 a标签的href="#"与javascript:void(0)在实战中的区别
轮播图的手动按钮,很多新手喜欢用<a>标签,于是出现这两种写法:<a href="#">和<a href="javascript:void(0)">。
href="#"的问题是,点击后浏览器会在地址栏后面加一个#,并且页面会跳到顶部。如果你页面内容较长,用户正在看轮播图,点一下按钮页面滚回顶部,体验很糟。href="javascript:void(0)"是让点击后执行一段返回undefined的JavaScript,地址栏不变,也不会跳转。
但在综合实战项目里,我的建议是:非链接性质的操作按钮,直接用<button>标签,根本不用纠结href。按钮语义更清晰,样式也好控制,默认没有跳转行为。只有在需要URL跳转的场景才用<a>。如果你已经用了<a>且不想改结构,那也千万别用href="#",至少也要在JS里e.preventDefault()拦掉默认行为。
5.2 最常见的两个运行时报错,以及什么时候会出现
第一个是Cannot read properties of null (reading 'addEventListener')。十有八九是script标签放在了head里,或者JS代码在DOM元素之前执行了。前面说过,把script放到</body>之前,或者用defer。如果你在HBuilderX里预览时遇到这个错,先检查script标签的位置,再检查id拼写。
第二个是todos.filter is not a function。这个通常是初始化时用了const todos = { ... }或者从localStorage里读出来的是字符串没有JSON.parse。记住一点:filter、map是数组方法,对象上没这些方法。遇到这个报错,先在Console里打印todos看是什么类型,十有八九是对象或字符串,转成数组就好。
排查这类问题的通用套路是:读报错信息 → 定位到出错的JS文件和行号 → 打印相关变量的类型和值 → 修复。别一上来就改代码,先搞清楚数据到底是什么。
5.3 一个定时器相关的闭包陷阱:for循环里的var问题
轮播图如果有多个切换按钮,或者需要给一组元素绑定点击事件,新手很容易写出这段代码:
// 错误示范 var btns = document.querySelectorAll('.btn'); for (var i = 0; i < btns.length; i++) { btns[i].addEventListener('click', function() { console.log(i); }); }结果无论点击哪个按钮,console.log(i)输出的都是btns.length。原因很典型:用var声明的i是函数作用域,整个for循环共享同一个i变量。循环结束之后,i的值是btns.length,所有闭包访问到的都是这个最终值。
解决方法有两个。一是把var改成let:
for (let i = 0; i < btns.length; i++) { btns[i].addEventListener('click', function() { console.log(i); }); }let是块级作用域,每次迭代都会创建一个独立的绑定,闭包捕获的是当次迭代的值,所以点击第几个按钮就打印几。
另一个方法是包一层立即执行函数:
for (var i = 0; i < btns.length; i++) { (function(index) { btns[index].addEventListener('click', function() { console.log(index); }); })(i); }这个方案的原理是:立即执行函数每次执行时,参数index被赋值为当前的i,因为函数参数是按值传递的,所以每个闭包捕获到的是不同的index。现在有了let,第二种方案基本不需要了,但理解它有助于你彻底弄懂闭包和作用域。
在轮播图项目里,如果你要给多个指示器圆点绑定点击事件,请务必用let。这个坑一旦踩进去,排查起来特别费时间,因为代码逻辑看起来完全正确,只有点到哪都跳同一张图这一种诡异表现。
5.4 对象还没new完,就能用prototype上的方法?
最后聊一个不少人都困惑过的点:在构造函数里调用原型方法,为什么可行?
比如轮播图如果用面向对象的方式写:
function Slider(el, options) { this.el = el; this.options = options; this.init(); // init 定义在 Slider.prototype 上 } Slider.prototype.init = function() { console.log('初始化轮播图'); }; const slider = new Slider(wrapper, { autoplay: true });从字面上看,new Slider()还没执行完,init方法却已经在用了。这合理吗?
合理。因为new的执行过程是有顺序的:
- 创建一个空对象
- 把该对象的原型(
__proto__)指向Slider.prototype - 把构造函数内部的
this绑定到这个新对象 - 执行构造函数体里的代码
- 如果构造函数没有显式返回对象,就返回这个新对象
关键在于第2步发生在第4步之前。当构造函数体执行到this.init()时,新对象的原型链已经建立,this能够顺着__proto__找到Slider.prototype上的init方法。所以构造函数内部可以放心调用原型方法。
这个机制其实在你写的每个函数里都在起作用。JavaScript的函数对象在定义完成后,就已经自动拥有了prototype属性,并生成了一个与之关联的原型对象。new只是负责把新对象的原型链和这个原型对象挂上钩。理解了这一点,你以后再看“构造函数和原型方法”相关的代码就不会觉得神秘了。
写完这两个项目之后,我的一点实际感受
说真的,每次带人做这两个项目,最明显的分水岭是:做完待办清单,你会对“数据驱动页面”产生直觉;做完轮播图,你会对“浏览器如何渲染、动画如何播放”产生直觉。这两种直觉叠加在一起,基本就是前端开发的核心体感了。
我个人在实操时还有一个习惯:每完成一个功能,就随手在浏览器里刷新一下、点几下,故意制造一些边界情况,比如快速连点、把窗口拉窄、输入特殊字符。让代码先在真实场景里“摔打”一遍,把问题暴露出来,再逐个修掉。这个过程虽然费时间,但积累的调试经验,恰恰是以后工作中最值钱的部分。
这两个项目做完后,你可以尝试把待办清单的localStorage换成后端接口,把轮播图加上触摸滑动,或者用类的方式重写一遍。每加一个新功能,都会用到今天这些底层逻辑。基础打牢之后,再去接触框架和工具链,你会发现很多抽象概念有了具体的落脚点,学习速度会快很多。