Scratch二次开发做了几期,从环境搭建到界面定制,再到积木扩展,每一步都有不少朋友在问。这期聊一个绕不开的话题:登录功能。
很多做少儿编程平台的团队,前期的痛点基本都一样——学生做完的作品只能存在本地,老师没法统一收集作业,家长也看不到孩子的成长记录。想解决这些问题,第一步就是把匿名用户变成实名用户,说白了就是做登录。
这篇会从后端接口到前端界面,完整拆解一遍我在Scratch里加登录功能的思路和实操过程。如果你是第一次接触Scratch二次开发,或者正准备给自己的平台加用户体系,这篇应该能帮你省不少弯路。
1. 项目概述与需求拆解
1.1 登录功能要解决的真实问题
先说说我为什么要在Scratch编辑器里做登录,而不是做一个独立的登录页。之前有个做在线编程教育的模拟项目X,学生用Scratch做完作品后,作品数据只能存在浏览器本地,换台电脑就全没了。老师在后台也看不到学生作品,评改只能靠学生手动提交截图,管理成本非常高。
后来梳理了一下需求,发现其实有三层:
- 第一层是"认出用户"——让每个学生有独立的账号,作品归属到账号下。
- 第二层是"存取作品"——学生登录后可以把作品保存到云端,下次在任何设备上都能打开继续做。
- 第三层是"权限管理"——老师能看全班作品,学生只能看自己的,管理员能看全部数据。
这三层需求,核心都是登录功能。但如果你只是做一个"用户名+密码输入框"那种极简登录,后面第二层第三层根本撑不起来。所以我在设计的时候,直接把登录做成了一套完整的用户认证体系,后面接数据库、接项目存储,一步到位。
1.2 技术方案选型:为什么选这套组合
Scratch编辑器(也就是scratch-gui)本身是用React和Redux写的,所以前端的技术栈基本被锁死了——你想在里面加界面,绕不开React组件。
后端的选择就灵活多了。我试过用Java写接口,也试过Python写后端,最后稳定用下来的是Node.js + Express。原因很简单:前后端都是JavaScript,语法统一,联调的时候沟通成本低。虽然你用Java也能做,但碰到一些数据类型的小问题,不用来回切换思维方式,明显顺手。
数据库用的是MySQL。这个选择倒不是性能上有多强,而是很多教育类项目的老系统都是MySQL,新项目适配起来省事。如果你没有历史包袱,用MongoDB或者PostgreSQL也完全没问题,反正用户表和项目表的结构都很简单。
认证方式选的是JWT(JSON Web Token),不用传统的Session。原因后面专门讲,这里先给个结论:前后端分离的架构下,JWT比Session舒服得多。
2. 环境准备与工程结构说明
2.1 开发环境搭建
做Scratch二次开发,第一步肯定要把scratch-gui跑起来。我用的方式是直接克隆源码,然后npm install装依赖。这个步骤看起来简单,但有个坑——scratch-gui的依赖很多,网络不好容易中途失败。我一般建议先配一个镜像源,再开始装,不然反复重装很浪费时间。
跑起来之后,目录结构大概是这样的:
scratch-gui/ ├── src/ │ ├── components/ # 各种React组件,包括菜单栏、编辑区 │ ├── lib/ # 核心逻辑、API封装 │ ├── redux/ # Redux store、reducer、中间件 │ ├── views/ # 页面级组件 │ └── index.js # 入口文件 └── test/ # 测试用例我后来没有直接改scratch-gui的源码,而是在外层套了一个自定义的React应用,把它当成一个子组件嵌进去。这样做的理由是:升级scratch-gui版本的时候,不需要把自定义的改动一个个重新移植,只要重新编译再嵌一次就行。这个思路参考的是"包一层壳"的做法,很多团队都是这么干的。
2.2 后端认证服务的设计
后端这边我单独建了一个服务,不跟Scratch前端混在一起。项目结构大概是这样:
user-server/ ├── routes/ # 路由定义 ├── controllers/ # 业务逻辑 ├── models/ # 数据模型 ├── middlewares/ # 认证中间件 └── app.js # 入口先看数据表设计。用户表和项目表两张表就够用了:
用户表(users)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT 自增 | 主键 |
| username | VARCHAR(50) | 用户名,唯一 |
| password | VARCHAR(255) | 密码哈希存储 |
| nickname | VARCHAR(50) | 昵称 |
| role | TINYINT | 0-学生 1-老师 2-管理员 |
| created_at | DATETIME | 创建时间 |
密码字段特别提醒:一定不要明文存,后面接口系统会讲加密方式。
项目表(projects)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT 自增 | 主键 |
| user_id | INT | 关联用户表 |
| title | VARCHAR(100) | 项目名称 |
| content | LONGTEXT | 项目数据(JSON字符串) |
| updated_at | DATETIME | 最后更新时间 |
这个设计考虑了业务场景:用户表只负责认证,项目表负责作品数据。两者通过user_id关联。后面要加"我的作品列表""老师查看全班作品"这些功能时,一条SQL就查出来了,不需要改表结构。
3. 登录功能的核心实现
3.1 后端接口开发:注册、登录与令牌校验
登录功能后端其实就三个核心接口:注册、登录、校验登录状态,再加上一个"获取用户信息"的接口。先写注册接口。
// routes/user.js const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const db = require('../db'); // 注册 router.post('/register', async (req, res) => { const { username, password, nickname } = req.body; // 1. 检查用户名是否已存在 const existed = await db.query( 'SELECT id FROM users WHERE username = ?', [username] ); if (existed.length > 0) { return res.status(400).json({ message: '用户名已存在' }); } // 2. 密码加密(加盐哈希) const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(password, salt); // 3. 写入数据库 const result = await db.query( 'INSERT INTO users (username, password, nickname, role) VALUES (?, ?, ?, 0)', [username, hashedPassword, nickname || username] ); res.json({ id: result.insertId, username, nickname: nickname || username, role: 0 }); });这里有个细节:第2步的bcrypt.genSalt(10),后面的10代表加盐的轮数。轮数越多,密码哈希计算越慢,安全性越高。10是BCrypt的默认推荐值,实测在普通服务器上注册一次大概需要50-100毫秒,完全能接受。如果调成15以上,性能会明显下降,但没有必要——10轮对付常见攻击已经足够了。
登录接口和注册类似,但逻辑反过来了:先查用户,再比对密码。
// 登录 router.post('/login', async (req, res) => { const { username, password } = req.body; // 1. 查询用户 const users = await db.query( 'SELECT * FROM users WHERE username = ?', [username] ); if (users.length === 0) { return res.status(400).json({ message: '用户不存在' }); } const user = users[0]; // 2. 比对密码 const valid = await bcrypt.compare(password, user.password); if (!valid) { return res.status(400).json({ message: '密码错误' }); } // 3. 签发JWT const token = jwt.sign( { id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: '3d' } ); res.json({ token, user: { id: user.id, username: user.username, nickname: user.nickname, role: user.role } }); });JWT的核心原理,用一张图说清楚:它是一串经过签名的JSON字符串,客户端登录成功后拿到token,之后每次请求都在Header里带上它。服务器收到token后,用同样的密钥验证签名是否有效,有效就直接信任token里写的用户ID、角色等信息,不需要去数据库查Session表。
token有效期我设的是3天,理由是:少儿编程项目一般是一周一节课,学生3天内登录状态能保留,下一节课重新打开网站还是登录状态,体验好。如果你做的是成人学习平台,可以设短一点,比如1天,再加刷新机制。
校验登录状态的中间件也很关键:
// middlewares/auth.js module.exports = function auth(req, res, next) { const token = req.headers['authorization']; if (!token) { return res.status(401).json({ message: '未登录' }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; next(); } catch (err) { return res.status(401).json({ message: '登录已过期' }); } };这个中间件的逻辑非常朴素:拿请求头里的Authorization字段,用JWT密钥验证,验证通过就把解码出来的用户信息挂到req上,后面的接口就能直接用req.user.id了。如果验证失败,就直接返回401,前端收到这个数字就跳转登录界面。
提示:JWT密钥(JWT_SECRET)一定要存环境变量,不要硬编码在代码里。密钥一旦泄露,攻击者可以伪造任意用户的token。
3.2 前端登录界面与状态管理
后端接口准备好之后,前端这边重点有两块:登录界面的UI,以及登录状态的Redux管理。
UI我用了最简单的弹窗方式。在scratch-gui里,顶部菜单栏右侧有一个位置可以放用户头像和用户名(匿名状态就显示"登录")。我在这里放了一个按钮,点击弹出登录弹窗。
创建登录弹窗组件的伪代码如下:
// src/components/login-modal.jsx import React, { useState } from 'react'; import { connect } from 'react-redux'; import { login } from '../redux/login-slice'; function LoginModal({ open, onClose, onLogin, loading }) { const [mode, setMode] = useState('login'); // 'login' or 'register' const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); if (!open) return null; const handleSubmit = async (e) => { e.preventDefault(); await onLogin({ username, password }); onClose(); }; return ( <div className="login-modal-overlay"> <div className="login-modal"> <button className="close-btn" onClick={onClose}>×</button> <h3>{mode === 'login' ? '账号登录' : '注册新账号'}</h3> <form onSubmit={handleSubmit}> <input type="text" placeholder="用户名" value={username} onChange={(e) => setUsername(e.target.value)} /> <input type="password" placeholder="密码" value={password} onChange={(e) => setPassword(e.target.value)} /> <button type="submit" disabled={loading}> {loading ? '加载中...' : mode === 'login' ? '登录' : '注册'} </button> </form> <p> {mode === 'login' ? ( <a onClick={() => setMode('register')}>没有账号?去注册</a> ) : ( <a onClick={() => setMode('login')}>已有账号?去登录</a> )} </p> </div> </div> ); } const mapDispatchToProps = (dispatch) => ({ onLogin: (payload) => dispatch(login(payload)), }); export default connect(null, mapDispatchToProps)(LoginModal);这里的逻辑很简单:mode字段切换登录和注册模式,提交时调用Redux里的login异步action,等请求成功后关闭弹窗,刷新界面上的用户状态。
Redux的状态管理部分,我用了一个独立的slice来管理用户信息:
// src/redux/login-slice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; export const login = createAsyncThunk( 'user/login', async ({ username, password }, { rejectWithValue }) => { try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const data = await response.json(); if (!response.ok) { return rejectWithValue(data.message); } // 保存token到localStorage,刷新页面后还能保持登录状态 localStorage.setItem('scratch_token', data.token); return data.user; } catch (err) { return rejectWithValue('网络错误,请稍后重试'); } } ); const loginSlice = createSlice({ name: 'user', initialState: { userInfo: null, token: localStorage.getItem('scratch_token') || null, loading: false, error: null, }, reducers: { logout: (state) => { state.userInfo = null; state.token = null; localStorage.removeItem('scratch_token'); }, }, extraReducers: (builder) => { builder .addCase(login.pending, (state) => { state.loading = true; state.error = null; }) .addCase(login.fulfilled, (state, action) => { state.loading = false; state.userInfo = action.payload; state.token = localStorage.getItem('scratch_token'); }) .addCase(login.rejected, (state, action) => { state.loading = false; state.error = action.payload; }); }, }); export const { logout } = loginSlice.actions; export default loginSlice.reducer;这里有个细节值得专门说:token存localStorage的原因。很多开发者习惯存cookie,但前后端分离场景下cookie涉及跨域、CSRF等一堆问题。localStorage只要设置好token,请求时手动加到Header里就行,逻辑非常清晰。缺点是有XSS攻击风险,但对少儿编程平台这种内部工具来说,风险可以接受。
前端还有一个关键点:应用启动时,如果发现localStorage里已经有token,需要再调一个接口验证token是否有效,并获取用户信息。这一步保证了用户刷新页面后仍然是登录状态。
// 应用启动时调用 async function fetchUserInfo(token) { const response = await fetch('/api/me', { headers: { 'Authorization': token }, }); if (response.ok) { const user = await response.json(); // 更新Redux状态 } else { // 401,说明token过期了,清理掉 localStorage.removeItem('scratch_token'); } }4. 实操过程:从零到能用的完整链路
4.1 后端部署与联调
后端代码写完,第一步不是马上接前端,而是先用curl测接口。我习惯这样测:
# 注册 curl -X POST http://localhost:3000/api/register \ -H "Content-Type: application/json" \ -d '{"username":"testuser","password":"123456","nickname":"测试同学"}' # 登录 curl -X POST http://localhost:3000/api/login \ -H "Content-Type: application/json" \ -d '{"username":"testuser","password":"123456"}' # 带token访问用户信息 curl http://localhost:3000/api/me \ -H "Authorization: 这里粘贴登录返回的token"这三个命令跑通,后端逻辑基本就没问题了。然后再接前端。
联调阶段遇到最多的坑就是跨域。Scratch前端跑在开发服务器(比如localhost:8080),后端跑在3000端口,浏览器会做跨域校验。解决方法是给后端加上CORS支持:
const cors = require('cors'); app.use(cors({ origin: 'http://localhost:8080', credentials: true }));如果你把前端部署到线上,origin那里要换成线上地址。另外,如果前后端用同一个域名(例如都挂在nginx下),跨域问题就不存在了,配置可以更简单。
注意:跨域配置里credentials: true要谨慎使用。如果你用JWT方式,前端每次请求手动带Authorization头,其实不需要credentials。开了credentials有时候反而会引起Preflight请求,给排查增加难度。
4.2 将登录状态接入Scratch的核心操作
后端通了,前端登录弹窗也有了,接下来最关键的一步:让登录功能真正跟Scratch作品的保存、加载联动起来。这一步如果只是做个登录界面但保存还是走本地,那登录就没有实际意义。
我的做法分三步:
第一步,在Scratch的Redux store里注册用户模块。scratch-gui自己有一套Redux store,你可以通过configureStore或combineReducers把自定义的reducer挂进去。这个操作需要找到scratch-gui的store配置文件,把loginReducer加进reducers列表。
第二步,拦截Scratch的保存动作。scratch-gui的保存通常在菜单栏点击"保存到电脑"或执行导出逻辑。为了做"保存到服务器",我把这个动作拦截下来,判断当前用户是否已登录。未登录就弹出登录弹窗;已登录就调用后端项目保存接口。
// 保存项目的封装 async function saveProjectToServer(user, projectData) { const response = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': localStorage.getItem('scratch_token'), }, body: JSON.stringify({ title: '我的项目', content: JSON.stringify(projectData), }), }); const result = await response.json(); return result; }第三步,加载作品时同理。用户登录后,"我的作品"列表应该从后端拉,而不是只显示本地文件。这里要注意性能:每次拉列表不要拉全部作品内容,可以先只拉标题和更新时间。用户点击某个作品后,再单独调接口拿详情。
另外还有一个体验细节:如果用户项目保存到一半突然断网,或者token过期了,前端要有合理的错误提示,而不是白屏或静默失败。我一般会在保存接口的catch里弹个提示框,告诉用户"保存失败,请检查网络"。如果错误是401,就直接跳到登录弹窗。
5. 常见问题与排查记录
5.1 跨域和各种报错
我在这个项目里踩过的跨域坑,简单说三种:
第一种是开发环境跨域。通常前端跑8080,后端跑3000,浏览器会拦截请求。解决方法是加CORS中间件,或者在前端package.json里配置proxy。
// package.json "proxy": "http://localhost:3000"第二种是请求头里的Authorization没带上。很多朋友写了前端代码,但忘了在axios的默认配置里设置Authorization头,结果后端一直返回401。解决方法是封装一个"带token的请求工具函数":
async function authedFetch(url, options = {}) { const token = localStorage.getItem('scratch_token'); return fetch(url, { ...options, headers: { ...(options.headers || {}), 'Authorization': token, }, }); }第三种是预检请求(OPTIONS)问题。如果你在请求里设置了非简单请求头,浏览器会先发一个OPTIONS请求确认服务器允许。后端如果没处理OPTIONS请求,会导致请求失败。
5.2 密码加密与安全问题
登录功能里绝对不能犯的错就是明文存密码。哪怕你的项目只是个内部Demo也一样。用bcrypt加盐哈希,既不会增加太复杂的代码,又能避免最要命的安全事故。
还有一个容易被忽略的点:JWT密钥(JWT_SECRET)里不要用弱密码。之前看到有项目直接把"123456"当密钥,这种token基本上等于没加密。最起码要生成一个32位以上的随机字符串。比如用Node的crypto模块生成:
const crypto = require('crypto'); const secret = crypto.randomBytes(32).toString('hex'); console.log(secret);生成的字符串放到环境变量.env文件里,别提交到代码仓库。如果用的是Git管理代码,记得把.env加进.gitignore。
5.3 登录状态同步与页面刷新
登录之后,界面上的用户名怎么刷新?用Redux时,登录成功的回调里dispatch一个action,更新store里的userInfo。React组件通过选择器connect到userInfo字段上,store变更后界面自动刷新。
但有一个坑:如果你在登录弹窗里登录成功,但弹窗本身没有关闭,或者弹窗关闭后界面没刷新,用户会以为没登进去。这个问题我当时排查了很久,最后发现是Redux的connect范围太小,只有弹窗组件本身订阅了userInfo状态,菜单栏组件没有订阅。解决方案是调整connect的使用范围,让顶部菜单栏也能读到userInfo。
还有一个细节是页面刷新后如何恢复登录状态。我的方案是:应用启动时从localStorage读token,然后调/api/me接口验证用户信息。如果返回成功,就说明token有效,重新把userInfo设进store;如果返回401,就直接清掉token,让用户重新登录。这个"静默恢复登录"的逻辑写好后,刷新页面的体验就很顺了。
5.4 多角色与权限控制的展开
做完基础登录后,你会发现学生、老师、管理员这些角色天然给你留好了扩展点。我的users表里role字段存了0、1、2,分别对应学生、老师、管理员。
有了这个字段,老师角色的用户登录后,"保存作品"可能会变成"查看全班作品"。管理员登录后,界面多了一个"数据管理"按钮。这些都可以根据role字段做条件渲染。
后端这边的权限控制也很简单。写个中间件判断当前用户的role是否够权限:
// middlewares/role.js module.exports = function requireRole(roles) { return (req, res, next) => { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: '权不足' }); } next(); }; }; // 使用示例:只有老师(role=1)和管理员(role=2)能访问 app.get('/api/admin/students', requireRole([1, 2]), handler);这套体系做完,基本上从"登录功能"扩展成了"用户体系和权限管理",后面的功能叠加就变得很舒服了。
6. 回顾与个人经验总结
登录功能从设计到落地,前前后后改了三版。最开始就是写个简单的表单加后端验证,能用但用户体验很差;后来加了JWT认证,登录状态可以持久化了;再后来加了角色权限和项目存储,才算真正把这个功能嵌进Scratch的日常使用链路里。
讲到这儿,给你一个实用建议:如果做Scratch二次开发,登录功能千万不要等所有UI做完才开始。先用最小的后端接口把注册、登录、token这一条链路跑通,马上接上Scratch的保存动作做个demo,让用户能真实地把作品存到服务器、能打开。有了这个最小的闭环,后面再优化界面、加权限控制,都是顺手的事。
另外一个小技巧:给登录弹窗做表单校验时,一定要做前端预校验。比如用户名长度、密码长度、两次密码是否一致这些,在前端就能拦住,别等到提交给后端才报错。这样用户体验和接口安全性都提升不少。
最后说一句:登录功能看着不起眼,但它往往是整个平台从"体验Demo"走向"实际可用"的分水岭。把这一步走扎实,后面做作品分享、班上排行榜、老师点评这些功能,都是在同一套用户体系上搭积木而已。