综合作业:GitHub 个人主页

从零打造你的数字名片——综合运用 DOM、异步、OOP、Canvas 等核心技能

🎯 项目目标

完成本项目,你将能够:

  • 综合运用 DOM 操作、异步编程、OOP 等 JS 核心技能构建完整应用
  • 使用 GitHub API 获取真实数据并动态渲染页面
  • 掌握 Canvas 动画与粒子系统的基本实现
  • 独立完成从开发到 GitHub Pages 部署的完整工作流

这个作业做什么

在 GitHub Pages 上亲手打造一个能代表你技术实力、创意和个性的终极个人主页

学了一肚子 JS 知识,但还没有一个能"秀"出来的作品?这个作业就是你的机会。你将从零开始搭建一个真实可部署的个人主页——它不只是静态页面,而是带动态交互、异步数据、甚至 Canvas 粒子特效的完整作品。

整个作业分三个阶段,难度逐级递进:

Part 1 — 核心功能(必修)

先搭骨架:仓库 → 日夜切换 → 打字机动画 → GitHub API 拉数据 → OOP 卡片 → 筛选。做完这部分,你就有了一个能跑的网站。

Part 2 — 个性化进阶(选修 ≥2)

让网站不再千篇一律:数据可视化、叙事时间轴、互动地图、Markdown 博客。挑你感兴趣的方向深入。

Part 3 — 创意彩蛋(选修 ≥1)

给访客一个"哇"的瞬间:Canvas 粒子背景、Konami 秘籍、动态标题。小而精,但要有质感。

先搭地基

创建仓库、配置 Pages、规划页面结构

万丈高楼平地起。在写任何特效之前,先把仓库建好、文件结构规划清楚。这页做完,你就有了一个可以在浏览器里打开的空白页面。

在 GitHub 上新建一个名为 你的用户名.github.io 的仓库,建立三个文件:

你的用户名.github.io/
├── index.html    ← 页面结构
├── style.css     ← 样式与动画
└── script.js     ← 交互逻辑
图:页面整体布局线框图

在 index.html 中规划好这些区域——后续每个任务都会往对应区域里填东西:

  • #hero — 首页英雄区(打字机动画会放在这里)
  • #about — 关于我(选修 A 的技能条/雷达图)
  • #projects — 我的项目(Part 1 的核心:动态卡片 + 筛选)
  • #hobbies — 兴趣爱好
  • <footer> — 页脚
仓库名必须是 用户名.github.io。这样 GitHub Pages 会自动部署到 https://用户名.github.io。其他命名需要在 Settings → Pages 里手动配置。

日夜模式切换

☀️ / 🌙 点击按钮,页面在浅色与深色之间平滑过渡

地基搭好了,现在给网站注入第一个"活"的效果。想象一下:晚上十一点,你打开自己的网站,白底黑字刺得眼睛疼。如果有个按钮让整站变成深色主题,是不是舒服多了?

要实现的效果:导航栏右上角放一个太阳 ☀️ 图标。点击后,背景平滑过渡到 #121212,文字变浅色,图标变成月亮 🌙。再点一次,切回浅色。整个过程不是瞬间切换,而是 0.3 秒的渐变。

图:日夜模式切换前后对比

思路拆解:

  • 用 CSS 变量定义两套颜色,body.dark-theme 覆盖变量值
  • 按钮点击时 classList.toggle('dark-theme') 切换类名
  • CSS transition 让颜色变化是渐变的,不是瞬间的
  • 图标随主题一起切换(太阳 ↔ 月亮)

这个任务覆盖的 DOM 基础——选元素、监听事件、切换类名——后面的打字机动画还会用到。

日夜模式:代码

CSS 变量 + classList.toggle 的经典方案

核心思路:CSS 变量 + body 类名切换。JS 只需要一行 classList.toggle,剩下的全是 CSS 的事。

/* style.css — 用 CSS 变量定义主题 */
:root {
    --bg-color: #ffffff;
    --text-color: #333333;
    --transition-speed: 0.3s;
}
body.dark-theme {
    --bg-color: #121212;
    --text-color: #e0e0e0;
}
body {
    background-color: var(--bg-color);
    color: var(--text-color);
    transition: background-color var(--transition-speed) ease,
                color var(--transition-speed) ease;
}
// script.js — 切换逻辑
const themeBtn = document.querySelector('#theme-toggle');
const icon = themeBtn.querySelector('span');

themeBtn.addEventListener('click', () => {
    document.body.classList.toggle('dark-theme');
    icon.textContent = document.body.classList.contains('dark-theme')
        ? '🌙' : '☀️';
});
常见问题:切换后某些元素没变色。检查那些元素是不是用了硬编码颜色(如 color: #333)而不是 var(--xxx)。只有用 CSS 变量声明的属性才会随主题变化。

打字机标题

逐字打出 → 停顿 → 逐字删除 → 循环切换

日夜模式搞定了——你已经掌握了选元素、监听事件、改 DOM 内容的基本功。接下来用同样的 DOM 基础,做一个更复杂的效果。

你一定见过这样的个人网站:页面加载后,标题一个字一个字地蹦出来,打完一句话停顿一下,然后一个字一个字地消失,换下一句。这种效果叫打字机动画,让访客觉得"这个网站是活的"。

要实现的效果:Hero 区域主标题逐字打出"你好,我是[你的名字]"。停顿 1.5 秒后逐字删除,再打出"一名前端开发爱好者"。无限循环。

思路拆解:

  • 准备一个字符串数组,存储多句预设文案
  • 用 setTimeout 递归控制节奏——不是 setInterval,因为打出和删除的速度不同
  • 打完 → 停顿 → 逐字删除 → 切换到下一句
  • slice(0, i) 截取前 i 个字符,更新 textContent

打字机:代码

setTimeout 递归 + 字符串截取

const titles = [
    "你好,我是张三",
    "一名前端开发爱好者",
    "热爱开源与创造"
];
const el = document.querySelector('#hero-title');
let titleIdx = 0, charIdx = 0, isDeleting = false;

function typeWriter() {
    const current = titles[titleIdx];

    if (!isDeleting) {
        // 逐字打出
        el.textContent = current.slice(0, charIdx + 1);
        charIdx++;
        if (charIdx === current.length) {
            isDeleting = true;      // 打完了,准备删除
            setTimeout(typeWriter, 1500); // 停顿 1.5 秒
            return;
        }
        setTimeout(typeWriter, 120);  // 打字速度:每字 120ms
    } else {
        // 逐字删除
        el.textContent = current.slice(0, charIdx - 1);
        charIdx--;
        if (charIdx === 0) {
            isDeleting = false;     // 删完了,换下一句
            titleIdx = (titleIdx + 1) % titles.length;
            setTimeout(typeWriter, 400);   // 短暂停顿后开始下一句
            return;
        }
        setTimeout(typeWriter, 60);  // 删除比打出快一倍
    }
}

typeWriter(); // 启动!

几个值得注意的细节:

  • 打字速度 120ms,删除速度 60ms——删除比打出快,这是打字机效果的"手感"秘诀
  • 用 isDeleting 标志位区分"打"和"删"两个阶段
  • (titleIdx + 1) % titles.length 让数组循环

从 API 到卡片

用 fetch 拉取 GitHub 仓库数据,用 class 动态渲染项目卡片

两个动画效果搞定了,网站"活"起来了。但有个问题:项目列表还是你手动写在 HTML 里的。每次在 GitHub 上建新仓库,还得手动去改代码。能不能让项目列表自动和 GitHub 同步?

这就是模块二要做的事——你将第一次调用真实的 Web API,把拿到的数据用 OOP 封装成组件,再渲染到页面上。整个过程分两步:

  1. 异步请求:用 async/await + try...catch 获取 GitHub API 数据
  2. 组件化渲染:创建 class ProjectCard,每个仓库一个实例,调用 .render() 生成卡片
图:从 API 到卡片的数据流

API 地址:

GET https://api.github.com/users/{username}/repos
必须使用 async/await + try...catch。不允许用 .then() 链式调用——这是培养现代异步编程习惯。

项目卡片类

用 class 封装"项目卡片"的数据与渲染逻辑

先设计好数据结构,再写请求。这样拿到数据后直接传给构造函数就行,不用到处拼字符串。

class ProjectCard {
    constructor(repo) {
        this.name = repo.name;
        this.description = repo.description || '暂无描述';
        this.url = repo.html_url;
        this.language = repo.language || '未知';
        this.stars = repo.stargazers_count;
    }

    render() {
        const card = document.createElement('div');
        card.className = 'project-card';
        card.setAttribute('data-language', this.language);
        card.innerHTML = `
            <h3>
                <a href="${this.url}" target="_blank">${this.name}</a>
            </h3>
            <p>${this.description}</p>
            <span class="lang-tag">${this.language}</span>
            <span class="stars">⭐ ${this.stars}</span>
        `;
        return card;
    }
}

每个仓库对应一个 new ProjectCard(repo) 实例,调用 .render() 就拿到一个完整的 DOM 节点。数据存在属性里,渲染逻辑封装在方法里——这就是 OOP 的核心思想。后面的筛选功能也会用到 data-language 属性。

获取仓库数据

用 fetch + async/await 拉取 GitHub API

ProjectCard 类设计好了,现在去拿数据。注意 data-language 属性——下一步的筛选系统会用到它。

async function loadProjects() {
    const container = document.querySelector('#projects-list');
    const username = '你的GitHub用户名';

    try {
        const response = await fetch(
            `https://api.github.com/users/${username}/repos`
        );

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        const repos = await response.json();

        repos.forEach(repo => {
            const card = new ProjectCard(repo);
            container.appendChild(card.render());
        });

    } catch (error) {
        container.innerHTML = '<p>加载项目失败,请稍后再试 🥲</p>';
        console.error('获取仓库数据失败:', error);
    }
}

loadProjects();
常见问题:请求返回 403。GitHub API 对未认证请求有速率限制(每小时 60 次)。频繁测试被限流时,在请求头加 Token:
const response = await fetch(url, {
    headers: { 'Authorization': 'token 你的Token' }
});
到 GitHub Settings → Developer settings → Personal access tokens 生成。Token 不要提交到公开仓库!

项目筛选系统

自动扫描语言标签,动态生成筛选按钮,平滑过滤卡片

卡片能自动生成了,但如果你的仓库有 20 个,访客可能只想看 JavaScript 的项目。你需要一个筛选系统——而且按钮本身也应该是动态生成的:仓库里有哪些语言,就自动出现哪些按钮。

还记得上一步给每个卡片设的 data-language 属性吗?现在它派上用场了。

要实现的效果:

  • 从仓库数据中提取唯一的编程语言(用 Set 去重)
  • 为每种语言动态创建一个筛选按钮,再加一个"全部"按钮
  • 点击按钮时,非匹配卡片淡出隐藏,匹配卡片淡入显示
  • 当前激活的按钮有视觉高亮
/* 提取唯一语言 */
const languages = [...new Set(repos.map(r => r.language).filter(Boolean))];
// 结果示例:["JavaScript", "HTML", "Python", "CSS"]

筛选:代码

动态生成按钮 + 事件委托 + CSS 过渡动画

function createFilterButtons(repos) {
    const languages = [...new Set(repos.map(r => r.language).filter(Boolean))];
    const bar = document.querySelector('#filter-bar');

    // "全部"按钮
    bar.innerHTML = '<button class="filter-btn active" data-lang="all">全部</button>';

    // 每种语言一个按钮
    languages.forEach(lang => {
        bar.innerHTML += `<button class="filter-btn" data-lang="${lang}">${lang}</button>`;
    });

    // 事件委托:在 bar 上统一监听,不用给每个按钮绑定
    bar.addEventListener('click', (e) => {
        if (!e.target.classList.contains('filter-btn')) return;

        bar.querySelectorAll('.filter-btn')
            .forEach(b => b.classList.remove('active'));
        e.target.classList.add('active');

        const lang = e.target.dataset.lang;
        document.querySelectorAll('.project-card').forEach(card => {
            const match = lang === 'all' || card.dataset.language === lang;
            card.classList.toggle('hidden', !match);
        });
    });
}
/* CSS 配合过渡动画 */
.project-card {
    transition: opacity 0.4s ease, transform 0.4s ease;
}
.project-card.hidden {
    opacity: 0;
    transform: scale(0.95);
    pointer-events: none;
    position: absolute;
}
常见问题:隐藏的卡片仍然占位。只设 opacity: 0 的元素依然占据空间。加上 position: absolute 让它脱离文档流。要动画用 absolute,要简洁用 display: none(但没过渡动画)。

个性化进阶

至少选修 2 项。三个方向,挑你感兴趣的开始

必修部分搞定了——你的网站现在能自动展示项目、有日夜切换、有打字机动画。接下来让它从"能用"变成"想多看两眼"。

三个方向,每个都需要你接触新的 API 或第三方库:

选修 A — 数据可视化

用 IntersectionObserver 做滚动触发的技能条动画,用 Chart.js 画技能雷达图。让"关于我"不再是枯燥的文字。

选修 B — 叙事与交互

用 CSS 伪元素画人生时间轴,用 Leaflet.js 标注旅行足迹地图。考验复杂 DOM 结构和第三方库配置。

选修 C — 内容创作

用 fetch 加载本地 Markdown 文件,用 Marked.js 解析成 HTML。无后端也能有一个博客。

动态技能条

滚动到可视区域时,技能条从 0% 平滑生长到目标百分比

大多数人写"关于我",就是一段自我介绍文字,一眼扫过就忘了。如果把技能用条形图展示出来,而且是在用户滚动到那个区域时才"长出来",效果就好得多。

要实现的效果:

  • 初始状态所有技能条宽度为 0%
  • 当技能条区域首次滚动进入视口时,动画生长到预设百分比
  • 滚动离开再回来,动画不重复
图:滚动触发技能条动画效果
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            entry.target.classList.add('animate-in');
            observer.unobserve(entry.target); // 只触发一次!
        }
    });
}, { threshold: 0.5 });

document.querySelectorAll('.skill-bar')
    .forEach(bar => observer.observe(bar));

threshold: 0.5 = 元素有 50% 进入视口才触发。unobserve 确保只触发一次,不会来回滚动时反复播放。

练习知识点:IntersectionObserver、threshold、unobserve、CSS @keyframes

技能雷达图

用 Chart.js 绘制多维度技能雷达图

技能条只能一个维度一个维度地看。雷达图能同时展示多个维度,一眼看出你的"能力六边形"。Chart.js 是最流行的前端图表库之一,引入一个 CDN 脚本就能用。

图:技能雷达图效果预览
<!-- 引入 Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<canvas id="skillRadar"></canvas>
new Chart(document.getElementById('skillRadar'), {
    type: 'radar',
    data: {
        labels: ['编程', '设计', '沟通', '算法', '团队协作', '英语'],
        datasets: [{
            label: '我的技能',
            data: [85, 70, 80, 75, 90, 65],
            backgroundColor: 'rgba(208, 101, 25, 0.2)',
            borderColor: 'rgba(208, 101, 25, 0.8)',
        }]
    },
    options: {
        scales: {
            r: { beginAtZero: true, max: 100 }
        }
    }
});

把 labels 和 data 换成你自己的维度和分值就行。配色用了和教程主题一致的橙色。

互动式时间轴

垂直时间轴 + 交错节点 + 悬停展开详情

文字介绍看多了会累。如果你用一条时间轴把人生关键节点串起来,访客会更有代入感——他们不是在读简历,而是在翻你的故事。

要实现的效果:

  • 页面中央一条垂直贯穿线(用 CSS 伪元素 ::before 绘制)
  • 轴线左右交错排列圆点节点(nth-child(odd/even))
  • 鼠标悬停:节点放大或发光 + 显示简短描述卡片
  • 点击节点:展开详细内容区域,再次点击收起
图:垂直时间轴布局效果
/* 时间轴中线 */
.timeline::before {
    content: '';
    position: absolute;
    left: 50%;
    top: 0;
    bottom: 0;
    width: 2px;
    background: var(--primary-color);
}

练习知识点:CSS 伪元素(::before、::after)、nth-child 奇偶交错、事件委托

互动足迹地图

用 Leaflet.js 在地图上标记你去过的城市

如果你喜欢旅行,这个功能会很加分。Leaflet 是一个轻量级的地图库,配合 OpenStreetMap 的免费瓦片,不需要任何 API Key 就能跑起来。和时间轴一样,它让访客通过"探索"而非"阅读"来了解你。

<!-- 引入 Leaflet.js -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<div id="map" style="height: 400px;"></div>
const map = L.map('map').setView([35.86, 104.19], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')
    .addTo(map);

const cities = [
    { name: '北京', lat: 39.90, lng: 116.40, story: '我在这里读的大学' },
    { name: '上海', lat: 31.23, lng: 121.47, story: '第一次实习的城市' },
];

cities.forEach(city => {
    L.marker([city.lat, city.lng])
        .addTo(map)
        .bindPopup(`<b>${city.name}</b><br>${city.story}`);
});

坐标在 Google Maps 上右键就能获取。把城市和故事换成你自己的。

轻量级博客

用 Marked.js 解析 Markdown,实现无后端博客系统

你有一堆 Markdown 格式的笔记或文章,但每次展示给别人都要单独开仓库或用第三方平台。能不能直接在自己的主页上做一个博客?用 fetch 加载本地 .md 文件,用 Marked.js 解析成 HTML,就是一个完整的无后端博客。

要实现的效果:

  • 主页"我的博客"区域只显示博文标题列表
  • 标题从 posts 文件夹动态读取(post1.md, post2.md ...)
  • 点击标题:页面不跳转,在当前页动态获取并解析 Markdown
  • 将格式化后的 HTML 渲染到阅读区域
async function loadPost(filename) {
    try {
        const res = await fetch(`posts/${filename}`);
        const md = await res.text();
        document.getElementById('post-content').innerHTML =
            marked.parse(md); // Marked.js 解析
    } catch (err) {
        console.error('加载文章失败:', err);
    }
}
常见问题:fetch 本地文件返回 404。GitHub Pages 的路径是大小写敏感的,posts/Post1.md 和 posts/post1.md 是两个不同的文件。另外 posts 文件夹必须和 index.html 同层,并且文件已 push 到仓库。

粒子星空背景

用 Canvas 实现星空粒子 + 鼠标引力效果

前面的选修让网站"有内容",接下来的彩蛋让网站"有灵魂"。这些小功能不需要大而全,但要有质感——给访客一个"哇"的瞬间。至少选做 1 项。

你见过那些背景是星空的网站吗?鼠标一滑,星星被"吸"过去,松开又散回来。这种效果用 <canvas> + requestAnimationFrame 就能实现。关键是理解动画循环:每帧清空画布 → 更新粒子位置 → 重新绘制。

要实现的效果:

  • 整个页面背景为 <canvas> 画布
  • 画布上随机分布闪烁的"星星"粒子
  • 鼠标移动时,周围产生更密集的"星云"
  • 附近星星受引力影响,产生穿梭感
图:粒子星空背景效果
/* 核心动画循环 */
function animate() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    particles.forEach(p => {
        const dx = mouse.x - p.x;
        const dy = mouse.y - p.y;
        const dist = Math.sqrt(dx * dx + dy * dy);

        if (dist < 150) {
            p.x += dx * 0.01; // 被鼠标吸引
            p.y += dy * 0.01;
        }

        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fillStyle = p.color;
        ctx.fill();
    });

    requestAnimationFrame(animate);
}

练习知识点:Canvas API、坐标计算、requestAnimationFrame

隐藏彩蛋

↑↑↓↓←→←→BA —— 输入秘籍触发惊喜

Konami Code 是游戏史上最有名的秘籍。你的网站上也可以藏一个:页面上没有任何提示,只有"知道的人"才能触发。这个功能的精髓在于用数组模拟滑动窗口——只保留最近的 N 次按键,和秘钥序列做比较。

const konamiCode = [
    'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
    'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
    'KeyB', 'KeyA'
];
let inputBuffer = [];

window.addEventListener('keydown', (e) => {
    inputBuffer.push(e.code);

    // 滑动窗口:只保留最近 10 次按键
    if (inputBuffer.length > konamiCode.length) {
        inputBuffer.shift();
    }

    // 比较是否匹配
    if (inputBuffer.join(',') === konamiCode.join(',')) {
        triggerEasterEgg();
        inputBuffer = [];
    }
});

function triggerEasterEgg() {
    document.body.classList.toggle('retro-theme');
    // 或者弹出一个贪吃蛇小游戏...
}

练习知识点:keydown 事件、event.code、数组 push + shift 滑动窗口

细节魔王

动态 Title + 自定义光标,打磨极致细节体验

动态 Title

同时开了十几个标签页,某个标签的标题突然变成了"别走呀,快回来!QAQ"——你忍不住又点回去了。就是这种小细节让网站有了"灵魂"。

document.addEventListener(
    'visibilitychange', () => {
    if (document.hidden) {
        document.title = '别走呀,快回来!QAQ';
    } else {
        document.title = '我的个人主页';
    }
});

自定义光标

把系统默认箭头换成你设计的小圆点。悬停到按钮、链接等可点击元素上时,圆点放大并变形——"光标会呼吸"的效果,让每次交互都有触感。

/* 隐藏默认光标 */
* { cursor: none; }
.cursor {
    width: 20px; height: 20px;
    border-radius: 50%;
    background: var(--primary-color);
    position: fixed;
    pointer-events: none;
    transition: transform 0.15s ease;
}
.cursor.hover {
    transform: scale(2);
    opacity: 0.5;
}

练习知识点:visibilitychange、mousemove、CSS cursor、event.target.matches()

知识点总览

回顾你在这个作业中用到的每一项核心技能

阶段模块核心知识点
必修日夜模式切换querySelector, addEventListener, classList.toggle, CSS transition
必修打字机标题setTimeout, 字符串 slice, 数组索引, textContent
必修项目动态渲染async/await, fetch, try...catch, class, constructor, this
必修项目筛选map, filter, Set, 事件委托, CSS transition
选修动态技能条IntersectionObserver, threshold, unobserve, CSS @keyframes
选修技能雷达图Chart.js, JSON 数据格式化
选修时间轴CSS 伪元素, nth-child, 事件委托, 复杂 DOM 结构
选修互动地图Leaflet.js, Popup 交互, 坐标系统
选修Markdown 博客Marked.js, fetch 加载文件, SPA 体验
彩蛋粒子背景Canvas API, requestAnimationFrame, 坐标计算
彩蛋Konami Codekeydown 事件, 数组 push/shift 滑动窗口
彩蛋细节魔王visibilitychange, mousemove, CSS cursor

提交要求

确保你的作品完整、可访问、有文档

1. 在线作品链接

https://你的用户名.github.io

确保 GitHub Pages 已正确部署,页面可以正常访问。

2. 代码仓库链接

提供你的 GitHub 仓库地址

代码应结构清晰、有合理的文件组织。

3. README.md 文档

内容包括:

  • 项目简介和设计理念
  • 已实现功能列表
  • 遇到的最大挑战及解决方案

动手开始吧!

完成这个综合作业后,你将拥有一个真正属于自己的线上作品。
下一章回到主线:错误处理与调试——让代码更健壮。

📝 项目总结

通过本项目你掌握了

DOM + 异步

动态渲染页面、fetch API 获取数据、事件驱动交互

OOP 设计

用 Class 组织代码,封装粒子系统、卡片组件等模块

Canvas 动画

粒子背景、动画循环、性能优化

部署上线

GitHub Pages 配置、自动化部署、域名绑定

1 / X