几周前帮一个朋友做数字电路课程的辅助课件,他说学生看课本上的逻辑门符号和真值表容易犯困,问我能不能做一个能“点着玩”的网页——点一下开关,输入信号变化,输出端跟着亮灭,最好还能把每个门的真值表同步显示出来。于是就有了这个小项目:一个纯HTML+CSS+JavaScript实现的基本逻辑门交互演示页。没有后端,没有框架,一个单文件就能跑,双击打开就能用。
这个项目解决的核心问题很实在:把抽象的逻辑运算变成看得见、摸得着的交互体验。适合三类人看——正在学数字电路的初学者(用网页验证自己对门电路的理解)、准备备课的教学人员(课堂上当教具演示)、以及刚接触前端想找个练手项目的开发者(用这个小需求把HTML、CSS、JS串起来实践一遍)。
今天把这套东西从设计思路到代码实现完整拆一遍,包含所有核心代码、参数计算逻辑、以及我在调试过程中踩过的坑。项目本身不难,但它覆盖了交互逻辑设计的完整思考链路,看完你不仅能复现,还能根据自己的需求改出花样来。
1. 项目定位与整体设计思路
1.1 逻辑门演示到底在解决什么问题
先聊聊逻辑门本身。数字电路里最基本的几个逻辑门:与门(AND)、或门(OR)、非门(NOT)、与非门(NAND)、或非门(NOR)、异或门(XOR)、同或门(XNOR)。每个门做的事情都是把输入信号(0或1)经过某种规则变换成输出信号(0或1)。
教科书上通常用真值表来描述这种规则,两输入与门的真值表长这样:
| 输入A | 输入B | 输出Y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
真值表很严谨,但对初学者来说有一个天然的门槛:你得先在脑子里把“输入信号的变化”和“输出结果的变化”对应起来。这个对应过程纯粹靠脑补,很容易走神。交互演示页解决的就是这个“脑补”问题——把输入信号做成可点击的开关,输出端用明亮的颜色直接反馈,点击开关的瞬间,输出随之一变,规则就被记住了。
设计目标很简单:每个逻辑门一张卡片,卡面上有输入开关、门符号示意、输出指示。点击输入开关,输出实时刷新,同一个门的所有输入组合都能手动测出来。再加一个自动展示全部组合的模式,把真值表动态滚动一遍,看一眼全懂了。
1.2 为什么选择纯HTML/CSS/JS实现
这个项目完全可以用Canvas画电路图,也可以引入Vue、React之类的框架来管理状态。但最终我选择纯HTML+CSS+JavaScript单文件实现,原因有三条。
第一,零依赖零构建。一个.html文件双击就在浏览器里跑,不需要装Node.js,不需要npm install,不需要启动开发服务器。对非程序员身份的电子课程学生来说,这是最低的使用门槛。你把这个文件发到微信群,对方下载后双击就能玩,没有任何环境问题。
第二,这个项目的状态管理足够简单。逻辑门总共就两路输入,每个输入只有0和1两种状态,全部状态组合不超过4种。用Vue管理这种级别的小页面属于杀鸡用牛刀,反而增加学习成本。原生JS的事件监听加一个更新函数,几十行就搞定,逻辑链路短,适合作为入门范例。
第三,高度可定制。单文件意味着想看代码的人可以直接“查看网页源代码”,教学场景下这反而是优势——学生能直接看到逻辑与、或、非的运算在代码里长什么样,把硬件逻辑和软件逻辑联系起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 页面布局与核心功能拆解
2.1 功能面板怎么规划
页面布局我用了大栅格卡片式设计。七个逻辑门排成三行:第一行AND、OR、NOT,第二行NAND、NOR,第三行XOR、XNOR。每张卡片内部再分三个区域:顶部是门名称和符号图,中部是两个输入按钮和输出指示灯,底部是对应的真值表预览。
输入按钮的设计上,为了让“0”和“1”的切换有足够强的视觉反馈,我用了一个伪开关的样式——不是普通的复选框,而是一个矩形块,左半边灰底表示0,右半边绿底表示1,中间一个可以滑动的圆形滑块。点击整个按钮区域,开关状态翻转,滑块左右移动,同时颜色渐变切换。
输出指示区是整个演示的核心反馈区域,用一个圆形灯表示。输出为0时圆灯是暗红色,周围一圈灰色光晕;输出为1时圆灯变成亮绿色,并且发出一个用CSS box-shadow模拟的发光效果——两层投影叠加,一层是近距离的亮光,一层是大范围的漫射光,视觉上真的很像LED被点亮。
真值表预览放在卡片底部每一行对应一种输入组合,当前正在演示的组合会用高亮底色标出来。这样一来,学生点开关的时候能同时看到“哦,我现在处于A=1、B=0这一行,所以输出是1”,文字图像和操作三者同步,学习效率比纯看表高得多。
2.2 交互方式的设计取舍
交互设计上我做了几个关键取舍,值得展开说说。
第一,两路输入用独立的开关点击,而不是“下一状态”按钮。早期版本我尝试过一个“循环演示”按钮,点一下自动从00切到01再切到10最后到11,这种设计看起来自动化程度高,但实际上削弱了学习效果——学生没有主动去思考“如果我把A拨到1会怎样”,只是被动看灯亮灭。改成手动拨动开关后,每次变化都带着学生的意图,记忆深度完全不同。我额外保留了一个“自动演示”按钮,但放在页面底部,作为辅助工具而不是核心入口。
第二,NAND和NOR没有单独做三输入版本。实际数字电路里三输入的门很常见,但教学场景下,两输入的门已经足够覆盖真值表的核心规律:从两输入推演多输入是顺理成章的事。而且三输入会让每个卡片至少多一行真值表,页面信息密度增加,界面变拥挤。以后要扩展的话,给每个门加一个“输入数量”选项并不困难,但现阶段保持界面清爽更重要。
第三,真值表的显示方式用了完整表格,而不是“只显示当前状态”。有人可能会说,交互演示嘛,显示当前输入输出就够,真值表反而占据空间。但我坚持保留完整真值表,因为多数初学者在看到当前状态时并不能马上意识到“这已经是全部四种组合中的第三种”,他们需要一个全局视角来理解“输出端的所有可能取值”。表格行高亮就是给全局视角和实时状态之间搭一座桥。
2.3 数据结构:怎么用代码表达逻辑门
代码里最核心的数据结构是门定义数组。每个门是一个对象,包含名称、符号、输入数量、计算函数、真值表。我用一个数组把七个门全部描述出来,然后用一个渲染函数把所有卡片生成到DOM里。这样做的好处,后加新门只需往数组里推一个对象,不需要碰页面结构代码。
门的计算函数全部走纯函数风格,输入两个数字(0或1),返回一个数字(0或1)。所有函数都简单到令人发指,但把它们的定义列出来,本身就有教学价值——初学者可以逐行对照逻辑门真值表来理解“代码是如何描述逻辑的”。
为了统一处理,非门也走了两输入函数的格式,但忽略第二个输入。测试阶段我发现很多人对“为什么非门卡片上只有一个开关但有两个输入占位”感到困惑,所以渲染非门卡片的时候特殊处理了一下,只显示A输入开关,隐藏B输入区域,但计算函数仍然接收一个默认的0作为第二参数。这样接口统一,界面又清爽。
3. 核心代码实现过程
3.1 HTML骨架:按语义化标签搭台子
整个页面只有一个大的容器div,内部结构分三个部分:顶部的说明区域、中部的门卡片网格、底部的自动演示按钮。上代码:
html复制<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基本逻辑门交互演示</title>
<style>
/* 样式稍后详细展开 */
</style>
</head>
<body>
<header class="page-header">
<h1>基本逻辑门交互演示</h1>
<p>点击输入开关,观察输出变化;真值表中当前状态会自动高亮。</p>
</header>
<main id="gate-container" class="gate-grid">
<!-- 门卡片由 JavaScript 动态生成 -->
</main>
<footer class="page-footer">
<button id="auto-demo-btn" class="demo-btn">开始自动演示</button>
<span id="auto-demo-status"></span>
</footer>
<script>
// JavaScript 逻辑稍后详细展开
</script>
</body>
</html>
这里注意一点,门卡片的内容全部由JavaScript动态生成,而不是在HTML里手写七份静态卡片。有人可能会觉得手写更直观、加载更快,但这个项目选定JS渲染有两个原因:一是代码量会大幅减少——七个门每个卡片结构都相同,只是名称和逻辑不同,用数据驱动一次搞定;二是后维护方便,改一个门的逻辑定义,所有相关区域自动同步更新,不会出现改错了卡片底部真值表忘改的情况。
3.2 CSS样式:让电路看起来像电路
样式这块是视觉体验的重头戏。我将配色方案定为深色背景加亮色元素,模拟一块深色的电路实验板。输入开关显示灰/绿两态,输出指示灯用红/绿两色区分负逻辑和正逻辑。
开关的CSS用了一个checkbox配合伪元素实现。原理是隐藏原始checkbox,用一个span模拟滑块:
css复制.switch {
position: relative;
display: inline-block;
width: 56px;
height: 28px;
background-color: #3a3f4b;
border-radius: 14px;
transition: background-color 0.2s ease;
cursor: pointer;
user-select: none;
}
.switch .knob {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
background-color: #ffffff;
border-radius: 50%;
transition: transform 0.2s ease;
}
.switch.active {
background-color: #2e8b57;
}
.switch.active .knob {
transform: translateX(28px);
}
.switch.active类在JS里通过切换class来改变状态,好处是只管状态标识,不管具体样式,视觉细节全部由CSS控制。滑动动画用了transition,手感很顺滑,不会觉得僵硬。
输出指示灯的发光效果值得单独说。我用box-shadow做了两层光晕:
css复制.lamp {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #8b2e2e;
box-shadow: 0 0 6px rgba(139, 46, 46, 0.6);
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.lamp.on {
background-color: #2eff70;
box-shadow:
0 0 8px #2eff70,
0 0 24px rgba(46, 255, 112, 0.5);
}
第一层投影是近距离的亮光,模拟LED本身的辉光,第二层是大范围的漫射光,让点亮效果显得更通透。实测在深色背景下这个效果非常明显,站在教室后排也能看清输出状态。
3.3 JavaScript逻辑:把真值表变成交互
门定义数组是核心。我用一个数组定义七个门的元信息,每个对象包含id、名称、符号、计算函数、说明。计算函数全部接受两个参数a和b,返回一个结果:
javascript复制const gates = [
{
id: 'and',
name: '与门 AND',
symbol: 'Y = A · B',
calc: (a, b) => a & b
},
{
id: 'or',
name: '或门 OR',
symbol: 'Y = A + B',
calc: (a, b) => a | b
},
{
id: 'not',
name: '非门 NOT',
symbol: 'Y = ¬A',
inputs: 1,
calc: (a, b) => a ^ 1
},
{
id: 'nand',
name: '与非门 NAND',
symbol: 'Y = ¬(A · B)',
calc: (a, b) => (a & b) ^ 1
},
{
id: 'nor',
name: '或非门 NOR',
symbol: 'Y = ¬(A + B)',
calc: (a, b) => (a | b) ^ 1
},
{
id: 'xor',
name: '异或门 XOR',
symbol: 'Y = A ⊕ B',
calc: (a, b) => a ^ b
},
{
id: 'xnor',
name: '同或门 XNOR',
symbol: 'Y = A ⊙ B',
calc: (a, b) => (a ^ b) ^ 1
}
];
用位运算符实现逻辑运算是个小技巧,但背后有一个教学价值:按位与、按位或、按位异或本来就是JavaScript语言内置的位运算能力,用它来模拟数字电路里的逻辑门,语义上完全吻合。非门和与非门里那个 ^ 1 就是取反的意思——异或1能把0变1、1变0,比用 ! 然后再转数字更干净。
计算函数归一化成两输入以后,卡片渲染、真值表生成、输出更新就全都统一了,不再需要为非门单独写一套逻辑。这算是整个代码设计里我最满意的一个决策。
3.4 真值表与输入状态同步
状态管理用一个对象数组。每个门卡片维护自己的状态:{ a: 0, b: 0 },点击某个开关时只更新对应的值,然后调用更新函数重算输出和真值表高亮。
卡片DOM结构是动态生成的,生成函数长这样:
javascript复制function createGateCard(gate, index) {
const card = document.createElement('div');
card.className = 'gate-card';
card.dataset.gateId = gate.id;
const inputNum = gate.inputs || 2;
let inputsHtml = '';
// 标记名称和符号
let controlsHtml = `
<div class="gate-symbol">${gate.symbol}</div>
<div class="input-area">
`;
// A 输入开关
controlsHtml += `
<div class="input-group">
<span class="input-label">A</span>
<div class="switch" data-gate="${gate.id}" data-input="a">
<span class="knob"></span>
</div>
<span class="input-value" id="val-${gate.id}-a">0</span>
</div>
`;
// B 输入开关(非门不显示,但保留隐藏字段以便统一计算)
if (inputNum > 1) {
controlsHtml += `
<div class="input-group">
<span class="input-label">B</span>
<div class="switch" data-gate="${gate.id}" data-input="b">
<span class="knob"></span>
</div>
<span class="input-value" id="val-${gate.id}-b">0</span>
</div>
`;
} else {
controlsHtml += `<input type="hidden" id="val-${gate.id}-b" value="0">`;
}
// 输出指示灯
controlsHtml += `
</div>
<div class="output-area">
<span class="output-label">Y</span>
<div class="lamp" id="lamp-${gate.id}"></div>
<span class="output-value" id="out-${gate.id}">0</span>
</div>
`;
// 真值表区域(后面单独生成)
card.innerHTML = `
<div class="card-header">
<h3>${gate.name}</h3>
${controlsHtml}
</div>
<div class="truth-table-wrap">
<table class="truth-table">
<thead><tr><th>A</th><th>B</th><th>Y</th></tr></thead>
<tbody id="tt-body-${gate.id}"></tbody>
</table>
</div>
`;
// 填充真值表
const tbody = card.querySelector(`#tt-body-${gate.id}`);
for (let a = 0; a < 2; a++) {
for (let b = 0; b < 2; b++) {
if (inputNum === 1 && b === 1) continue; // 非门只测 A=0 和 A=1
const row = document.createElement('tr');
row.dataset.a = a;
row.dataset.b = b;
row.innerHTML = `<td>${a}</td><td>${b}</td><td>${gate.calc(a, b)}</td>`;
tbody.appendChild(row);
}
}
return card;
}
这里有个小细节值得注意:真值表的计算直接调用 gate.calc(a, b),不需要额外维护一份数据,这样可以保证展示的逻辑和实际运行的计算逻辑是同一套代码,不会出现“界面上写着Y=1,但灯是灭的”这种错位。代码和数据源统一,是这类教学工具最容易忽略但最重要的一点。
事件处理是委托式的,事件绑定在容器上:
javascript复制document.getElementById('gate-container').addEventListener('click', (e) => {
const switchEl = e.target.closest('.switch');
if (!switchEl) return;
const gateId = switchEl.dataset.gate;
const inputKey = switchEl.dataset.input;
const gate = gates.find(g => g.id === gateId);
const state = states[gateId];
state[inputKey] = state[inputKey] ^ 1;
const output = gate.calc(state.a, state.b);
updateCard(gate, state, output);
});
事件委托的好处是,即使后添加新的门卡片进来,也不需要单独绑定事件,所有 .switch 元素的点击都会被容器捕捉到。用 closest('.switch') 过滤出真正点击到开关上的情况,避免其他元素干扰。
更新函数负责刷新三处UI:开关滑块位置(通过class切换)、输出指示灯状态、真值表行高亮:
javascript复制function updateCard(gate, state, output) {
const card = document.querySelector(`[data-gate-id="${gate.id}"]`);
const lamp = card.querySelector('.lamp');
const outSpan = card.querySelector(`#out-${gate.id}`);
const aSwitch = card.querySelector(`[data-input="a"]`);
const bSwitch = card.querySelector(`[data-input="b"]`);
// 更新开关 UI
aSwitch.classList.toggle('active', state.a === 1);
card.querySelector(`#val-${gate.id}-a`).textContent = state.a;
if (bSwitch) {
bSwitch.classList.toggle('active', state.b === 1);
card.querySelector(`#val-${gate.id}-b`).textContent = state.b;
}
// 更新输出灯
lamp.classList.toggle('on', output === 1);
outSpan.textContent = output;
// 更新真值表行高亮
card.querySelectorAll('.truth-table tbody tr').forEach(row => {
const rowA = parseInt(row.dataset.a, 10);
const rowB = parseInt(row.dataset.b, 10);
const inputs = gate.inputs || 2;
const isCurrent = inputs === 1
? (rowA === state.a && rowB === 0)
: (rowA === state.a && rowB === state.b);
row.classList.toggle('highlight', isCurrent);
});
}
自动演示模式是另一个可选功能。点击“开始自动演示”后,启动一个定时器,每隔800毫秒把一个门的状态自动切换到下一个组合。这个功能在课堂教学时用来展示非常方便,教师不需要逐个去点开关。代码如下:
javascript复制let autoTimer = null;
let currentGateIndex = 0;
let comboIndex = 0;
const combos = [
{ a: 0, b: 0 },
{ a: 0, b: 1 },
{ a: 1, b: 0 },
{ a: 1, b: 1 }
];
document.getElementById('auto-demo-btn').addEventListener('click', function() {
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
this.textContent = '开始自动演示';
document.getElementById('auto-demo-status').textContent = '';
return;
}
this.textContent = '停止自动演示';
const gateId = gates[currentGateIndex].id;
let gate = gates[currentGateIndex];
autoTimer = setInterval(() => {
comboIndex = (comboIndex + 1) % combos.length;
const state = combos[comboIndex];
const output = gate.calc(state.a, state.b);
states[gateId] = { ...state };
updateCard(gateById(gateId), state, output);
}, 800);
});
这里我简化了自动演示的循环逻辑——固定遍历门数组,每个门停留4个组合时间(大约3.2秒)再跳到下一个门。教学展示时最好全程每0.8秒变一种状态,两个循环配合起来刚好把全部七个门过一遍,节奏合适。
3.5 布局细节和响应式处理
布局上卡片用了CSS Grid,最小宽度240px,在大屏幕上每一行放3-4张卡片,窄屏幕自动降为1列。实现方式:
css复制.gate-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
auto-fill 配合 minmax(240px, 1fr) 是响应式布局的老搭档。当视口足够宽时,网格自动填充多列;当视口缩小时,卡片自动换行成单列。不需要写媒体查询就能实现基础的响应式效果。
卡片内部用flex布局分配空间,标题在上,输入区左,输出区右,真值表在下。真值表的信息密度不高,两列输入加一列输出,总共三列,所以就算窄屏也不会横向溢出。
4. 常见问题与排查技巧实录
4.1 点击开关没反应,检查事件绑定是否挂在正确节点上
这个是最常见的问题。很多人会习惯性把监听器绑在 .switch 元素上,然后发现动态生成的卡片根本绑不上——因为生成卡片时事件绑定代码还没执行,或者卡片后生成在监听器绑定之后。解决方式是用事件委托,绑定到父容器上,原理前面已经说了。如果你是静态手写卡片,那直接绑没问题;但只要用了动态渲染,事件委托是更稳妥的选择。
如果点击确实没反应,先用浏览器开发者工具检查:右键点击开关,选择“检查”,确认元素是否存在;然后在Console里手动执行 document.querySelector('.switch') 看能不能选中元素。不要急着怀疑代码逻辑,先确认DOM结构对不对。
4.2 输出灯不亮或颜色不对
症状:点击开关,真值表数字变了,但灯一直是暗红色。这个问题多数出在CSS类名控制上。我用的是 .lamp.on 控制点亮状态,如果JS里写成了 .lamp-active 或者 style.background 直接覆盖,样式就会错乱。
排查时要分清“状态变了但样式没变”和“状态根本没变”。在Console里跑一下 document.querySelector('.lamp').classList.contains('on'),如果返回true但灯颜色没变,那就是CSS选择器或优先级问题。我遇到过一种坑:某个CSS框架(或者你自己写的其他样式)有更高的优先级覆盖了 .lamp.on 的背景色,需要加 !important 或提高选择器特异性来修复。
4.3 页面在手机上显示错乱
Grid的 auto-fill 方案在手机上没问题,但卡片内部的flex布局可能会因为内容挤压而错位。主要原因是真值表的最小宽度和两列输入开关的宽度加起来超过了卡片宽度。解决方式是在真值表容器上加 overflow-x: auto,然后给卡片设一个合理的 min-width。
如果只是演示用途,可以把卡片最小宽度调到200px,输入区改为纵向排列(A开关在上,B开关在下),这样即使320px宽的老手机也能正常显示。
4.4 自动演示停不下来
自动演示的bug几乎都出在定时器管理上。我用的方案是全局变量保存 setInterval 的返回值,点击按钮时判断“如果当前有定时器就先清除再启动新的”。很多人会漏掉清除这一步,导致多个定时器叠加,每次点击后演示速度越来越快。
另一个坑是定时器回调里读取 comboIndex 的方式——如果用了 let comboIndex = 0 定义在函数内部,每次进入回调都重新初始化为0,演示就会一直停在00组合不动。正确做法是把组合索引放在外层作用域,让定时器回调每次都能拿到递增后的值。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 点击开关无反应 | 事件未绑定/未委托 | 用事件委托绑定到父容器 |
| 真值表数字变但灯不变 | CSS类名不匹配 | 检查类名是否一致,Console里验证classList |
| 灯亮但颜色偏暗 | 光晕投影被截断 | 检查父元素是否有 overflow: hidden |
| 自动演示加速 | 定时器未清除 | 每次开启前先 clearInterval |
| 手机显示横向滚动 | 卡片最小宽度过大 | 降低 min-width,加 overflow-x: auto |
| 开关滑块不滑动 | transition 未生效或transform冲突 |
检查是否被其他transform属性覆盖 |
4.6 一个值得踩的坑:getElementById 与 dataset 混用
写代码时我有一瞬间想用 getElementById 拿开关,但后来全部改成 querySelector 配合 data-* 属性,为的是保持逻辑统一。如果混用,会很容在调试时把ID拼错,比如 val-and-a 拼成 val-and-A(大小写不同),代码不报错但页面不更新,排查起来很浪费时间。建议所有动态生成的元素都只用 data-* 属性来标识,统一走 querySelector 获取,避免ID拼接的拼写错误。
另外一个真实教训:早期版本里我给输入开关直接绑了 onclick 事件,然后又在容器上绑了事件委托,结果一次点击触发了两次状态翻转——代码看起来“挺正常”,但实际效果是开关点了没变化(因为连续翻转了两次回到原状态)。这就是一股脑把所有交互逻辑都堆上去的结果。后来统一成只保留事件委托,问题一次解决。写交互逻辑时,同一个行为只有一条触发路径,这是我从这个项目里收获最深的一条经验。
5. 进一步扩展的思路
这个项目做完了基础版本,其实还有很多可以碾的扩展方向。你可以加上德摩根定律的演示模块,把 NAND 和“或非门+非门”的组合实时对比,用交互证明两者等价。也可以给每个门卡片加一个“波形图”区域,用Canvas画一个简单的时序图,展示输入信号随时间变化的波形——这对数电课上的时序分析帮助巨大。
如果你想让页面更精致,可以引入动画效果:输入开关切换时输出灯的发光有一个从小到大的过渡过程,就像真的LED通电一样;真值表当前行高亮时加一个淡入淡出的背景色过渡,视觉感受会缓和很多。
技术上还有一条可以优化的路:增加URL参数支持,比如在地址栏传参 ?gate=xor 让页面只展示异或门,方便直接跳转到某个特定门的讲解页面。这个在课件系统里集成的时候非常实用。
最后,如果你想把这个项目分享给别人,建议打包成单个HTML文件后压缩发送,或者部署到任意的静态网站托管服务上。整个项目零依赖,部署就是放一个文件的事,没有什么构建流程要跑,省心。
