文章背景图

Moments 极简朋友圈 + iPhone 16 Pro 网页模拟完整实现

2026-06-19
4
-
- 分钟
|

一、项目介绍

1.1 📌 Moments 项目简介

/

Moments 是一款极简朋友圈应用,采用 Golang 重写服务端后包体更小、性能更强。支持多用户、标签管理、图片上传(本地/S3)、Markdown 语法,可嵌入网易云音乐、B 站视频及豆瓣内容,适配移动端与暗黑模式,数据存储在 SQLite 中便于备份。它为用户精心打造了一个网页端简洁却功能丰富的社交展示平台,支持多种内容形式的记录与展示。

演示地址:https://pyq.zxiaolin.com/

1.2 🎯 功能亮点

  • 📝 Memo 记录:这里就像一个多功能的笔记本,支持标签管理,让你的内容条理清晰;支持图片上传,可存储至本地或 S3,满足不同的存储需求;自动生成缩略图(本地上传),为你节省时间;支持 Markdown 语法,让你可以用简洁的方式表达丰富的内容;点赞与评论功能,让你的分享与他人产生互动;还能嵌入网易云音乐、B站视频、外部链接,引用豆瓣读书和电影信息,让你的分享更加丰富多彩。

  • 🛠️ 其他功能:它就像一个贴心的伙伴,完美适配移动端,无论你是在路上还是在休息,都能随时随地使用;支持暗黑模式,为你在夜晚使用提供舒适的视觉体验;提供回到顶部按钮,让你在浏览长页面时更加便捷;数据库采用 SQLite,便于备份,让你的数据更加安全;支持自定义头图、头像和网站标题,让你的朋友圈独具个性。

二、页面展示增强

2.1 💅 CSS 优化

通过 display: none 隐藏原页面中不需要的链接和 UI 元素,让视觉焦点集中在内容本身。

a[href="<https://github.com/kingwrcy/moments>"] {
    display: none;
}

div.sm\\:absolute.sm\\:flex-col.sm\\:-right-10.sm\\:rounded.sm\\:p-2.sm\\:w-fit.dark\\:bg-neutral-800.bg-white.shadow {
    display: none !important;
}

2.2 灵动岛歌词容器及动画优化

灵动岛是 iPhone 14 及以上机型的标志性 UI 元素,我们用一个 div 来模拟它。

/* 灵动岛歌词容器 */
/* 歌词容器与滚动动画(优化滚动速度) */
.lyric-container {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    overflow: hidden;
    pointer-events: none;
}

.lyric-text {
    color: #ffcc99;
    font-size: 12px;
    white-space: nowrap;
    transform: translateX(100%);
    opacity: 0;
    font-weight: 500;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
    transition: transform 0.3s ease-out, opacity 0.3s ease-out;
}

.lyric-text.visible {
    opacity: 1;
    transform: translateX(0);
}

/* 优化1:歌词滚动速度减慢,延长动画周期至25秒 */
.lyric-text.scrolling {
    animation: scrollLyric 25s linear infinite;
}

@keyframes scrollLyric {
    0% { transform: translateX(100%); }
    10% { transform: translateX(0); } /* 10%时间完成进入 */
    85% { transform: translateX(0); } /* 75%时间保持静止 */
    100% { transform: translateX(-100%); } /* 15%时间完成退出 */
}

/* 优化3:灵动岛炫光动画 */
.dynamic-island {
    /* 基础样式保持不变... */
    position: relative;
    overflow: hidden;
}

.dynamic-island::before {
    content: '';
    position: absolute;
    top: -50%;
    left: -50%;
    width: 200%;
    height: 200%;
    background: radial-gradient(circle, rgba(255, 204, 153, 0.2) 0%, transparent 70%);
    transform: rotate(0deg);
    opacity: 0;
    transition: opacity 0.5s ease;
}

.dynamic-island.playing::before {
    opacity: 1;
    animation: glowRotate 6s linear infinite;
}

@keyframes glowRotate {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

2.2.1 🎵 歌词容器与滚动动画

  • 📦 容器布局:.lyric-container 采用绝对定位,如同一个隐形的框架,覆盖整个父元素,通过 flex 布局将歌词文本垂直和水平居中。overflow: hidden 确保超出容器的歌词部分不会显示,pointer-events: none 使歌词容器不会干扰用户与其他元素的交互,就像一个安静的守护者,默默守护着歌词的展示。

  • 📝 歌词文本样式:.lyric-text 初始状态下透明度为 0 且位于容器右侧,当添加 visible 类时,通过过渡效果使其透明度变为 1 并移动到容器中心,就像一颗星星从黑暗中逐渐闪耀出来。

  • 🎬 滚动动画优化:通过 animation: scrollLyric 25s linear infinite 将歌词滚动动画周期延长至 25 秒,在 @keyframes scrollLyric 中详细定义了歌词的滚动过程,使歌词在进入、静止和退出阶段有更合理的时间分配,就像一场精心编排的舞蹈,提升用户的视觉体验。

2.2.2 🌟 灵动岛炫光动画

  • 伪元素创建:通过 ::before 伪元素在 .dynamic-island 元素内部创建一个径向渐变的背景,初始状态下透明度为 0,就像一颗隐藏的宝石,等待被点亮。

  • 🎬 动画触发:当 .dynamic-island 元素添加 playing 类时,伪元素的透明度变为 1,并开始执行 glowRotate 动画,使炫光以 6 秒为周期旋转,增强了灵动岛的视觉效果,就像给灵动岛披上了一层绚丽的光环。


三、iPhone 16 Pro 模拟完整实现

3.1 🤔 模拟思路概述

在页面中模拟 iPhone 16 Pro 的效果,就像一场奇妙的魔法之旅。我们主要是通过创建一个具有 iPhone 外观的容器,并将原页面内容嵌入其中,同时添加灵动岛元素来模拟 iPhone 的交互效果。在模拟过程中,需要像一位严谨的建筑师一样,考虑屏幕尺寸的适配、元素的布局和动画效果的实现。

3.2 💻 代码实现步骤

var simulatorWidth;
function initiPhoneSimulator() {
    // 路径验证
    if (window.location.pathname !== '/') {
        console.log('非根路径,不执行模拟');
        return;
    }
    console.log('根路径验证通过,开始初始化');

    // 核心参数:新比例395:830,最小宽度395px
    const baseWidth = 395;
    const baseHeight = 830;
    const minWidth = baseWidth; // 强制最小宽度不小于395px
    const ratio = baseWidth / baseHeight; // 宽高比≈0.4759
    const screenWidth = window.innerWidth;
    const screenHeight = window.innerHeight;

    // 1. 计算可用空间限制
    const maxAvailableWidth = screenWidth * 0.85; // 最大可用宽度为屏幕85%
    const maxAvailableHeight = screenHeight * 0.95; // 最大可用高度为屏幕95%

    // 2. 优先保证宽度不小于395px,计算最优宽高
    let optimalWidth, optimalHeight;

    // 基础宽度:取最小宽度和最大可用宽度的较大值(确保不小于395)
    const baseCandidateWidth = Math.max(minWidth, maxAvailableWidth);

    // 按基础宽度计算对应高度(原始比例)
    const heightByWidth = baseCandidateWidth / ratio;

    if (heightByWidth <= maxAvailableHeight) {
        // 高度在可用范围内:使用基础宽度和对应高度(保持比例)
        optimalWidth = baseCandidateWidth;
        optimalHeight = heightByWidth;
    } else {
        // 高度超出范围:限制高度为最大可用高度,宽度按比例计算(可能大于基础宽度)
        optimalHeight = maxAvailableHeight;
        optimalWidth = optimalHeight * ratio;

        // 二次检查:确保宽度不小于最小宽度(允许比例偏差)
        if (optimalWidth < minWidth) {
            optimalWidth = minWidth;
            optimalHeight = maxAvailableHeight; // 此时比例有偏差,优先保证宽度和高度限制
            console.log(`宽度已达最小值${minWidth}px,高度限制为${optimalHeight.toFixed(0)}px(比例偏差)`);
        }
    }
    simulatorWidth = optimalWidth;
    console.log(`最优尺寸:宽${optimalWidth.toFixed(0)}px,高${optimalHeight.toFixed(0)}px(比例≈${(optimalWidth/optimalHeight).toFixed(3)})`);

    // 3. 创建iPhone容器
    const iphone = document.createElement('div');
    iphone.style.width = `${optimalWidth}px`;
    iphone.style.height = `${optimalHeight}px`;
    iphone.style.border = '12px solid #1a1a1a';
    iphone.style.borderRadius = `${optimalWidth * (56 / baseWidth)}px`; // 按基础宽度比例计算圆角
    iphone.style.position = 'relative';
    iphone.style.boxShadow = '0 15px 40px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.1)';
    iphone.style.background = '#000';
    iphone.style.transform = 'scale(0.95)';
    iphone.style.transition = 'transform 0.3s ease';
    iphone.onmouseover = () => { iphone.style.transform = 'scale(1)'; };
    iphone.onmouseout = () => { iphone.style.transform = 'scale(0.95)'; };
    console.log('iPhone容器创建完成');

    // 4. 创建屏幕容器
    const screenBorder = 2;
    const screen = document.createElement('div');
    screen.style.width = `calc(100% - ${2 * screenBorder}px)`;
    screen.style.height = `calc(100% - ${2 * screenBorder}px)`;
    screen.style.background = '#fff';
    screen.style.borderRadius = `${optimalWidth * (48 / baseWidth)}px`;
    screen.style.overflow = 'hidden';
    screen.style.position = 'relative';
    screen.style.margin = `${screenBorder}px`;
    console.log('屏幕容器创建完成');

    // 创建灵动岛(新增歌词容器)
    const dynamicIsland = document.createElement('div');
    dynamicIsland.style.position = 'absolute';
    dynamicIsland.style.top = `${optimalWidth * (8 / baseWidth)}px`;
    dynamicIsland.style.left = '50%';
    dynamicIsland.style.transform = 'translateX(-50%)';
    dynamicIsland.style.width = `${optimalWidth * (120 / baseWidth)}px`;
    dynamicIsland.style.height = `${optimalWidth * (32 / baseWidth)}px`;
    dynamicIsland.style.background = 'linear-gradient(180deg, #1a1a1a 0%, #000 100%)';
    dynamicIsland.style.borderRadius = `${optimalWidth * (20 / baseWidth)}px`;
    dynamicIsland.style.zIndex = '9999';
    dynamicIsland.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.05)';
    dynamicIsland.style.transition = 'all 0.3s ease';

    // 新增:歌词容器
    const lyricContainer = document.createElement('div');
    lyricContainer.className = 'lyric-container';
    const lyricText = document.createElement('div');
    lyricText.className = 'lyric-text';
    lyricContainer.appendChild(lyricText);
    dynamicIsland.appendChild(lyricContainer);

    // 灵动岛交互
    dynamicIsland.onclick = () => {
        const isExpanded = dynamicIsland.style.width === `${optimalWidth * (160 / baseWidth)}px`;
        dynamicIsland.style.width = isExpanded ? `${optimalWidth * (120 / baseWidth)}px` : `${optimalWidth * (160 / baseWidth)}px`;
        dynamicIsland.style.height = isExpanded ? `${optimalWidth * (32 / baseWidth)}px` : `${optimalWidth * (38 / baseWidth)}px`;
    };
    console.log('灵动岛创建完成(支持歌词显示)');

    // 6. 创建内容容器
    const webContent = document.createElement('div');
    webContent.style.position = 'absolute';
    webContent.style.top = '0';
    webContent.style.left = '0';
    webContent.style.width = '100%';
    webContent.style.height = '100%';
    webContent.style.overflowY = 'auto';
    //webContent.style.paddingTop = `${optimalWidth * (40 / baseWidth)}px`; // 避开灵动岛
    webContent.style.boxSizing = 'border-box';
    console.log('内容容器创建完成');

    // 7. 移除固定宽度class
    const targetClass = 'md:w-[567px]';
    const escapedClass = targetClass.replace(/:/g, '\\\\:').replace(/\\[/g, '\\\\[').replace(/\\]/g, '\\\\]');
    const elementsWithClass = document.querySelectorAll(`.${escapedClass}`);
    // 处理原项目的最低宽度
    elementsWithClass.forEach(el => {
        el.classList.remove(targetClass);
    });
    // 处理特殊样式的宽度
    document.querySelectorAll('.h-full').forEach(he => {
        he.classList.remove('h-full');
    })

    // 8. 移动原DOM并适配
    const originalContent = document.getElementById('__nuxt');
    if (originalContent) {
        console.log('找到__nuxt容器,开始移动');
        originalContent.style.width = '100%';
        originalContent.style.maxWidth = '100%';
        originalContent.style.height = '100%';
        originalContent.style.boxSizing = 'border-box';
        webContent.appendChild(originalContent);
    } else {
        console.warn('未找到__nuxt容器');
        const fallback = document.createElement('div');
        fallback.style.padding = '20px';
        fallback.style.textAlign = 'center';
        fallback.textContent = '内容加载失败';
        webContent.appendChild(fallback);
    }

    // 9. 组装结构
    screen.appendChild(dynamicIsland);
    screen.appendChild(webContent);
    iphone.appendChild(screen);
    document.body.appendChild(iphone);
    console.log('元素组装完成');

    // 10. 页面样式
    document.body.style.margin = '0';
    document.body.style.padding = '20px';
    document.body.style.backgroundColor = '#f0f2f5';
    document.body.style.minHeight = '100vh';
    document.body.style.display = 'flex';
    document.body.style.justifyContent = 'center';
    document.body.style.alignItems = 'center';
    document.body.style.overflow = 'hidden';

    // 11. 加载动画
    webContent.style.opacity = '0';
    setTimeout(() => {
        webContent.style.transition = 'opacity 0.5s ease';
        webContent.style.opacity = '1';
    }, 300);

    // 新增:监听meting-js播放事件
    setupAdvancedPlayerListener(dynamicIsland, lyricText, optimalWidth, baseWidth);
}

// 高级播放器监听系统(兼容无限滚动)
function setupAdvancedPlayerListener(dynamicIsland, lyricText, optimalWidth, baseWidth) {
    const originalWidth = dynamicIsland.style.width;
    const originalHeight = dynamicIsland.style.height;
    const expandedWidth = `${optimalWidth * (220 / baseWidth)}px`;
    const expandedHeight = `${optimalWidth * (38 / baseWidth)}px`;

    let activePlayer = null;
    let playerObservers = new Map(); // 存储每个播放器的observer
    let lyricUpdateTimer = null;

    // 处理播放器状态变化
    const handlePlayerStateChange = (player) => {
        const playButton = player.querySelector('.aplayer-button');
        const isPlaying = playButton?.classList.contains('aplayer-pause');

        if (isPlaying) {
            // 激活当前播放器(不暂停其他)
            activePlayer = player;
            expandIsland();
            startLyricUpdates();
        } else if (activePlayer === player) {
            // 当前播放器暂停
            activePlayer = null;
            resetIsland();
            stopLyricUpdates();
        }
    };

    // 展开灵动岛
    const expandIsland = () => {
        dynamicIsland.style.width = expandedWidth;
        dynamicIsland.style.height = expandedHeight;
        dynamicIsland.classList.add('playing');
        lyricText.classList.add('visible');
    };

    // 重置灵动岛
    const resetIsland = () => {
        dynamicIsland.style.width = originalWidth;
        dynamicIsland.style.height = originalHeight;
        dynamicIsland.classList.remove('playing');
        lyricText.classList.remove('visible', 'scrolling');
        lyricText.textContent = '';
    };

    // 更新歌词
    const updateLyric = () => {
        if (!activePlayer) return;

        const currentLine = activePlayer.querySelector('.aplayer-lrc-current');
        if (currentLine && currentLine.textContent.trim()) {
            const lyric = currentLine.textContent;
            if (lyric !== lyricText.textContent) {
                lyricText.textContent = lyric;
                // 重置滚动动画
                lyricText.classList.remove('scrolling');
                void lyricText.offsetWidth;
                lyricText.classList.add('scrolling');
            }
        }
    };

    // 开始歌词更新
    const startLyricUpdates = () => {
        if (!lyricUpdateTimer) {
            lyricUpdateTimer = setInterval(updateLyric, 1500);
        }
    };

    // 停止歌词更新
    const stopLyricUpdates = () => {
        if (lyricUpdateTimer) {
            clearInterval(lyricUpdateTimer);
            lyricUpdateTimer = null;
        }
    };

    // 初始化单个播放器监听
    const initPlayer = (player) => {
        // 避免重复初始化
        if (playerObservers.has(player)) return;

        // 创建状态变化监听器
        const observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
                    handlePlayerStateChange(player);
                }
            });
        });

        // 监听播放器状态变化
        observer.observe(player, { attributes: true, attributeFilter: ['class'] });

        // 监听播放按钮状态变化
        const playButton = player.querySelector('.aplayer-button');
        if (playButton) {
            observer.observe(playButton, { attributes: true, attributeFilter: ['class'] });
        }

        // 存储observer引用
        playerObservers.set(player, observer);

        // 检查初始状态
        if (playButton?.classList.contains('aplayer-pause')) {
            handlePlayerStateChange(player);
        }
        if (simulatorWidth && simulatorWidth < 600) {
            player.style.width = '95%';
            player.style.maxWidth = `${simulatorWidth - 120}px`;
            player.style.boxSizing = 'border-box';
            console.log(`[播放器宽度适配] ${simulatorWidth - 120}px`)
        }
        console.log('[播放器监听] 初始化新播放器:', player);
    };

    // 清理播放器监听
    const cleanupPlayer = (player) => {
        const observer = playerObservers.get(player);
        if (observer) {
            observer.disconnect();
            playerObservers.delete(player);
        }

        // 如果清理的是当前活跃播放器
        if (activePlayer === player) {
            activePlayer = null;
            resetIsland();
            stopLyricUpdates();
        }
    };

    // 创建Intersection Observer监听播放器元素进入视口
    const playerIntersectionObserver = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                // 播放器进入视口,初始化监听
                initPlayer(entry.target);
            } else {
                // 播放器离开视口,可选择清理监听(这里保留监听以支持后台播放)
                // cleanupPlayer(entry.target);
            }
        });
    }, {
        rootMargin: '500px 0px', // 提前500px触发观察,确保播放器即将进入视口时就初始化
        threshold: 0.1
    });

    // 创建Mutation Observer监听DOM变化,捕获新添加的播放器
    const domObserver = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            if (mutation.type === 'childList') {
                // 检查新增节点中是否有播放器
                mutation.addedNodes.forEach(node => {
                    if (node.classList && node.classList.contains('aplayer')) {
                        // 直接找到播放器
                        playerIntersectionObserver.observe(node);
                    } else if (node.nodeType === 1) {
                        // 检查子节点中是否有播放器
                        node.querySelectorAll('.aplayer').forEach(player => {
                            playerIntersectionObserver.observe(player);
                        });
                    }
                });
            }
        });
    });

    // 开始监听DOM变化
    domObserver.observe(document.body, {
        childList: true,
        subtree: true
    });

    // 初始化现有播放器
    document.querySelectorAll('.aplayer').forEach(player => {
        playerIntersectionObserver.observe(player);
    });

    // 页面卸载时清理所有资源
    window.addEventListener('beforeunload', () => {
        domObserver.disconnect();
        playerIntersectionObserver.disconnect();
        playerObservers.forEach(observer => observer.disconnect());
        playerObservers.clear();
        stopLyricUpdates();
    });
}

// 新增:判断屏幕宽度,小于500px则不模拟
const screenWidth = window.innerWidth;
if (screenWidth < 500) {
    console.log(`[模拟判断] 屏幕宽度${screenWidth}px < 500px,不执行模拟`);
} else {
    initiPhoneSimulator();
    window.addEventListener('load', function() {
        // 处理音乐播放器宽地
        if (optimalWidth < 520) {
            document.querySelectorAll('.aplayer').forEach(ae => {
                ae.style.width = '100%';
                ae.style.maxWidth = `${optimalWidth - 30}px`;
                ae.style.boxSizing = 'border-box';
            })
        }
        console.log('处理音乐播放器宽度');
    });
}

3.2.1 🛂 路径验证

  • 在 initiPhoneSimulator 函数中,首先进行路径验证,就像一个严格的门卫,只有当页面路径为根路径时才执行模拟操作,避免在其他页面不必要的模拟,确保模拟过程的准确性和高效性。

3.2.2 📏 尺寸计算与适配

  • 📌 核心参数设定:定义了 baseWidth 和 baseHeight 作为 iPhone 模拟的基础尺寸,计算出宽高比 ratio。同时,设置了最小宽度 minWidth 为 395px,确保模拟的 iPhone 宽度不会过小,就像为模拟过程设定了一个坚实的基础框架。

  • 📐 可用空间计算:根据屏幕宽度和高度,计算出最大可用宽度和高度,分别为屏幕宽度的 85% 和屏幕高度的 95%,就像为模拟过程规划了一个合理的活动范围。

  • 🌟 最优宽高计算:通过比较按基础宽度计算的高度和最大可用高度,确定最优的宽高值。如果按基础宽度计算的高度超出最大可用高度,则限制高度为最大可用高度,并重新计算宽度。同时,进行二次检查,确保宽度不小于最小宽度,就像一位精打细算的设计师,为模拟过程找到最完美的尺寸。

3.2.3 🎨 元素创建与布局

  • 📱 iPhone 容器创建:使用 document.createElement 创建一个 div 元素作为 iPhone 容器,设置其宽度、高度、边框、圆角、背景等样式,并添加鼠标悬停动画效果,就像为模拟的 iPhone 穿上了一件时尚的外衣,让它更加生动逼真。

  • 📺 屏幕容器创建:在 iPhone 容器内部创建屏幕容器,设置其宽度、高度、背景、圆角等样式,并将其添加到 iPhone 容器中,就像为 iPhone 安装了一块清晰的屏幕,让内容展示更加清晰。

  • 💫 灵动岛创建:在屏幕容器内部创建灵动岛元素,设置其位置、大小、背景、圆角等样式,并添加歌词容器和交互效果。当点击灵动岛时,通过改变其宽度和高度实现展开和收缩的效果,就像为 iPhone 赋予了一个灵动的灵魂,让交互更加有趣。

  • 📄 内容容器创建:创建内容容器,将原页面的 __nuxt 容器移动到内容容器中,并设置其宽度、高度和滚动属性。同时,移除原页面中固定宽度的类,以适配模拟的 iPhone 尺寸,就像为原页面内容找到了一个合适的新家,让它在模拟的 iPhone 中完美展示。

3.2.4 🎵 播放器监听与歌词更新

  • 🎧 高级播放器监听系统:setupAdvancedPlayerListener 函数实现了对多个音乐播放器的监听和歌词更新功能。通过 MutationObserver 监听播放器和播放按钮的状态变化,当播放器开始播放时,展开灵动岛并开始更新歌词;当播放器暂停时,重置灵动岛并停止更新歌词,就像一个智能的音乐管家,时刻关注着播放器的状态。

  • 👀 Intersection Observer:使用 Intersection Observer 监听播放器元素进入视口的事件,当播放器进入视口时,初始化对该播放器的监听;当播放器离开视口时,保留监听以支持后台播放,就像一个默默守护的卫士,确保播放器在任何情况下都能正常工作。

  • 👀 Mutation Observer:使用 Mutation Observer 监听 DOM 变化,当有新的播放器元素添加到页面时,自动初始化对该播放器的监听,就像一个敏锐的侦探,及时发现新的播放器并进行处理。

3.2.5 🧹 资源清理

  • 在页面卸载时,通过 window.addEventListener('beforeunload') 事件清理所有的监听器和定时器,避免内存泄漏,就像一个细心的清洁工,在离开前将一切清理干净,确保页面的整洁和高效。

四、播放器监听系统完整实现

4.1 🤔 问题分析

在实现动态多播放器的过程中,主要面临以下几个问题:

  • 🎵 播放器状态同步:当页面中存在多个音乐播放器时,需要确保灵动岛能够准确显示当前正在播放的播放器的信息,并在播放器状态变化时及时更新,就像一个精准的指挥家,确保所有播放器的节奏一致。

  • 📝 歌词更新与滚动:要实现歌词的实时更新和滚动效果,需要监听播放器的歌词变化,并在合适的时机更新灵动岛中的歌词显示,就像一个勤奋的记录员,及时记录下每一句歌词。

  • ⚙️ 性能优化:在处理多个播放器的监听和更新时,需要考虑性能问题,避免过多的 DOM 操作和定时器导致页面卡顿,就像一个精明的工程师,优化每一个细节,确保系统的高效运行。

  • 📜 无限滚动适配:当页面支持无限滚动时,需要确保新添加的播放器能够自动被监听,并且在播放器离开视口时不会影响后台播放,就像一个灵活的舞者,能够适应各种舞台环境。

4.2 💡 解决方法

4.2.1 🎵 播放器状态同步

👀 使用 MutationObserver:通过 MutationObserver 监听播放器和播放按钮的 class 属性变化,当播放器开始播放时,将其标记为活跃播放器,并展开灵动岛;当播放器暂停时,重置灵动岛,就像一个敏锐的观察者,及时发现播放器状态的变化并做出相应的调整。

const observer = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
            handlePlayerStateChange(player);
        }
    });
});
observer.observe(player, { attributes: true, attributeFilter: ['class'] });
observer.observe(playButton, { attributes: true, attributeFilter: ['class'] });

4.2.2 📝 歌词更新与滚动

⏱️ 定时器更新歌词:使用 setInterval 定时器每隔 1.5 秒检查当前活跃播放器的歌词变化,当歌词发生变化时,更新灵动岛中的歌词显示,并重置滚动动画,就像一个准时的闹钟,按时提醒我们更新歌词。

const updateLyric = () => {
    if (!activePlayer) return;

    const currentLine = activePlayer.querySelector('.aplayer-lrc-current');
    if (currentLine && currentLine.textContent.trim()) {
        const lyric = currentLine.textContent;
        if (lyric !== lyricText.textContent) {
            lyricText.textContent = lyric;
            // 重置滚动动画
            lyricText.classList.remove('scrolling');
            void lyricText.offsetWidth;
            lyricText.classList.add('scrolling');
        }
    }
};
lyricUpdateTimer = setInterval(updateLyric, 1500);

4.2.3 ⚙️ 性能优化

🗄️ 使用 Map 存储监听器:使用 Map 对象存储每个播放器的 MutationObserver 引用,避免重复初始化监听器,提高性能,就像一个高效的仓库管理员,合理管理每一个监听器。

let playerObservers = new Map();
playerObservers.set(player, observer);
  • 👀 Intersection Observer 优化:通过设置 rootMarginthreshold 参数,提前触发对播放器元素的观察,确保播放器即将进入视口时就初始化监听,减少不必要的 DOM 操作,就像一个聪明的预判者,提前做好准备,避免不必要的麻烦。

const playerIntersectionObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            initPlayer(entry.target);
        }
    });
}, {
    rootMargin: '500px 0px',
    threshold: 0.1
});

4.2.4 📜 无限滚动适配

👀 Mutation Observer 监听 DOM 变化:使用 MutationObserver 监听页面的 DOM 变化,当有新的播放器元素添加到页面时,自动初始化对该播放器的监听,就像一个警觉的哨兵,时刻关注着页面的变化,及时发现新的播放器并进行处理。

const domObserver = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
        if (mutation.type === 'childList') {
            mutation.addedNodes.forEach(node => {
                if (node.classList && node.classList.contains('aplayer')) {
                    playerIntersectionObserver.observe(node);
                } else if (node.nodeType === 1) {
                    node.querySelectorAll('.aplayer').forEach(player => {
                        playerIntersectionObserver.observe(player);
                    });
                }
            });
        }
    });
});
domObserver.observe(document.body, {
    childList: true,
    subtree: true
});

五、歌词系统:从定时轮询到 MutationObserver

在原有功能基础上,进一步增强了灵动岛的视觉效果与交互体验,并通过技术重构实现了性能的显著提升,使歌词展示更流畅、资源占用更合理。

5.1 CSS样式增强:专辑封面与歌词容器优化

通过新增专辑封面容器和细化歌词样式,让灵动岛的视觉层次更丰富,交互反馈更直观。

/* 灵动岛基础样式增强 */
.dynamic-island {
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 更自然的过渡曲线 */
}

/* 专辑封面容器(默认隐藏,播放时显示) */
.album-cover {
  width: 0;
  height: 0;
  border-radius: 4px;
  background-color: #333;
  margin-right: 0;
  margin-left: 8px;
  flex-shrink: 0;
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 与灵动岛过渡保持一致 */
}

.dynamic-island.playing .album-cover {
  width: 24px;
  height: 24px;
  margin-right: 8px; /* 播放时展开并显示间距 */
}

.album-cover img {
  width: 100%;
  height: 100%;
  object-fit: cover; /* 确保封面图片比例正确 */
}

/* 歌词容器与文本优化 */
.lyric-container {
  position: relative;
  flex-grow: 1;
  height: 100%;
  display: flex;
  align-items: center;
  overflow: hidden;
  padding: 0 4px; /* 避免歌词贴边 */
}

.lyric-text {
  color: #ffcc99;
  font-size: 14px; /* 增大字体提升可读性 */
  white-space: nowrap;
  transform: translateX(100%);
  opacity: 0;
  font-weight: 500;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
  transition: transform 0.3s ease-out, opacity 0.3s ease-out;
  max-width: calc(100% - 8px); /* 预留边距,避免内容溢出 */
}

核心优化点:

  • 专辑封面动态显示:通过 .dynamic-island.playing 类触发,播放时自动展开封面容器并显示专辑图片,增强视觉关联性;

  • 歌词样式细化:增大字体尺寸、添加边距限制,提升可读性;

  • 过渡曲线统一:采用 cubic-bezier(0.4, 0, 0.2, 1) 曲线,使灵动岛展开、封面显示等动画更协调自然。

5.2 JavaScript实现:从“主动轮询”到“被动监听”的重构

通过引入 MutationObserver 替代定时轮询,实现歌词变化的精准监听,同时优化滚动逻辑,提升性能与流畅度。

5.2.1 歌词滚动控制器(JS驱动)

// 歌词滚动状态管理
let scrollAnimationId = null; // 动画ID,用于中断
let isScrolling = false; // 滚动状态标记
let startTimestamp = null; // 动画开始时间戳

// 停止当前滚动动画
const stopScroll = () => {
  if (scrollAnimationId) {
    cancelAnimationFrame(scrollAnimationId);
    scrollAnimationId = null;
  }
  isScrolling = false;
  startTimestamp = null;
};

// 平滑滚动函数(优化版)
const smoothScroll = (element, totalDistance, duration) => {
  if (totalDistance <= 0) return; // 无需滚动

  // 缓动函数:模拟自然物理运动
  const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

  // 重置状态
  element.style.transform = 'translateX(0)';
  startTimestamp = performance.now();

  const animate = (currentTime) => {
    const elapsedTime = currentTime - startTimestamp;
    const progress = Math.min(elapsedTime / duration, 1);
    const easedProgress = easeInOutCubic(progress);

    // 计算当前位置(从0滚动到-totalDistance)
    const currentPosition = -totalDistance * easedProgress;
    element.style.transform = `translateX(${currentPosition}px)`;

    if (progress < 1) {
      scrollAnimationId = requestAnimationFrame(animate);
    } else {
      stopScroll(); // 动画完成,清理状态
    }
  };

  isScrolling = true;
  scrollAnimationId = requestAnimationFrame(animate);
};

5.2.2 歌词变化监听系统(核心优化)

// 监听歌词容器中p元素的class变化
const setupLyricChangeListener = (player) => {
  // 找到歌词容器
  const lrcContainer = player.querySelector('.aplayer-lrc-contents');
  if (!lrcContainer) return;

  // 创建监听p元素class变化的Observer
  const lyricObserver = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      // 只处理class变化的p元素
      if (mutation.type === 'attributes' &&
          mutation.attributeName === 'class' &&
          mutation.target.tagName === 'P') {

        const pElement = mutation.target;
        // 检查是否是当前歌词(class包含aplayer-lrc-current)
        if (pElement.classList.contains('aplayer-lrc-current')) {
          handleNewLyric(pElement.textContent.trim()); // 处理新歌词
        }
      }
    });
  });

  // 开始监听所有p元素的class变化
  lyricObserver.observe(lrcContainer, {
    attributes: true,          // 监听属性变化
    attributeFilter: ['class'], // 只监听class属性
    subtree: true              // 监听子树(所有p元素)
  });

  // 存储Observer,用于后续清理
  player.lyricObserver = lyricObserver;
};

// 处理新歌词
const handleNewLyric = (rawLyric) => {
  // 去除翻译内容,保留纯歌词
  const cleanLyric = rawLyric.replace(/\\([^)]*\\)|\\[.*?\\]/g, '').trim();
  if (!cleanLyric) return;

  // 停止当前滚动
  stopScroll();

  // 更新歌词显示
  lyricText.textContent = cleanLyric;
  lyricText.classList.add('visible');
  lyricText.style.transform = 'translateX(0)';

  // 计算滚动参数(歌词宽度 > 可见宽度时才滚动)
  const lyricWidth = getTextWidth(cleanLyric);
  const visibleWidth = getVisibleWidth();

  if (lyricWidth > visibleWidth) {
    const scrollDistance = lyricWidth - visibleWidth;
    const scrollDuration = Math.max(5, scrollDistance / 60) * 1000; // 60px/秒的滚动速度
    setTimeout(() => {
      smoothScroll(lyricText, scrollDistance, scrollDuration);
    }, 200);
  }
};

核心优化点:

  • 从“定时轮询”到“被动监听”:使用 MutationObserver 监听歌词元素(p 标签)的 class 变化,仅在歌词实际切换时触发更新,替代原有的 setInterval 定时查询,减少无效计算;

  • 精准歌词处理:通过正则清理歌词中的翻译内容,确保显示纯歌词,并仅在歌词变化时执行滚动计算,避免重复操作;

  • 平滑滚动优化:采用 requestAnimationFrame 结合缓动函数,使歌词滚动更符合物理规律,减少视觉卡顿。

5.3 性能优化对比:从“低效轮询”到“按需执行”

通过技术重构,灵动岛的性能在多个维度实现显著提升,核心差异如下:

维度

原有方案(定时轮询)

优化方案(MutationObserver)

触发时机

固定间隔(如500ms/次),无论歌词是否变化

仅歌词实际切换时触发(p元素class变化)

DOM访问频率

高频重复查询(每500ms一次)

仅歌词变化时查询一次

主线程占用

持续占用(即使歌词不变)

仅歌词切换时短暂占用

无效计算

多(重复处理相同歌词)

无(每句歌词仅处理一次)

资源释放

依赖定时器清理,易遗漏

通过 observer.disconnect() 精准释放

核心优势:优化方案彻底消除了“无歌词变化时的无效计算”,将DOM操作频率与歌词变化频率绑定(通常远低于500ms/次),显著降低主线程负担,尤其在多播放器或长时间播放场景下,性能优势更明显。

5.4 专辑封面同步逻辑

新增专辑封面提取与显示功能,实现歌词与封面的联动:

// 更新专辑封面
const updateAlbumCover = () => {
  if (!activePlayer) return;

  // 从播放器获取封面图片
  const coverElement = activePlayer.querySelector('.aplayer-pic');
  if (coverElement) {
    // 支持从background-image提取封面
    const bgImage = coverElement.style.backgroundImage;
    if (bgImage) {
      const imgUrlMatch = bgImage.match(/url\\("?(.+?)"?\\)/);
      if (imgUrlMatch && imgUrlMatch[1]) {
        albumCover.innerHTML = `<img src="${imgUrlMatch[1]}" alt="专辑封面">`;
        return;
      }
    }

    // 支持从img标签提取封面
    const imgElement = coverElement.querySelector('img');
    if (imgElement && imgElement.src) {
      albumCover.innerHTML = `<img src="${imgElement.src}" alt="专辑封面">`;
    }
  }
};

通过解析播放器中的封面元素(aplayer-pic),自动提取封面图片并同步到灵动岛的专辑封面容器,增强视觉关联性。

优化通过CSS样式增强、JavaScript逻辑重构(从轮询到监听)、性能精细化管理,使灵动岛在视觉效果更丰富的同时,实现了“按需执行”的高效运行模式。专辑封面的动态显示与歌词的精准同步,进一步提升了用户体验,而性能的优化则为多场景下的稳定运行提供了保障。

Moments原作者项目地址:https://github.com/kingwrcy/moments

IPhone16 Pro模拟地址:https://pyq.zxiaolin.com/

原创

Moments 极简朋友圈 + iPhone 16 Pro 网页模拟完整实现

本文链接: Moments 极简朋友圈 + iPhone 16 Pro 网页模拟完整实现

本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

本文章为原创,请勿滥用,转载请注明出处

评论交流

文章目录