前端新人别慌:HTML和JS到底怎么"搭伙过日子"?

前端新人别慌:HTML和JS到底怎么"搭伙过日子"?

你以为写个onclick就叫交互了?

说实话,我刚入行那会儿,觉得前端不就是写几个标签,然后加个onclick="alert('hello')"就算会交互了。直到有次面试,面试官问我:"你说说事件委托的原理?"我当场表演了一个瞳孔地震。

现在回头看,HTML和JavaScript的关系,根本不是那种"你写你的结构,我写我的逻辑"的塑料同事情。它们俩是实打实的两口子——HTML是家里的装修,JS是住进去的人。装修得再好,没人住就是毛坯房;人再勤快,房子漏雨也住不舒服。

这篇文章咱们就聊聊这对"夫妻"是怎么过日子的,顺便把我当年踩过的坑、摔过的跤,都给你标成地图上的红色警示牌。


从script标签塞进HTML那一刻起,它们就绑一块儿了

还记得你第一次写HTML的时候吗?是不是也干过这种事:

<!DOCTYPE html>
<html>
<head>
    <title>我的第一个页面</title>
    <!-- 方式一:直接内嵌,简单粗暴 -->
    <script>
        alert('页面加载中...');
    </script>
</head>
<body>
    <h1>Hello World</h1>
    
    <!-- 方式二:外部引入,假装很专业 -->
    <script src="app.js"></script>
</body>
</html>

当时我就觉得,这script标签往那一摆,JS就能操作HTML了,魔法!但后来才知道,这里面的水可深了。

浏览器是怎么撮合这俩的?

想象一下,浏览器拿到你的HTML文件后,其实不是直接"显示"出来的。它得先干一件很枯燥但很重要的事——解析。这个过程就像装修队看图纸:

  1. 从上到下读HTML,遇到标签就生成对应的DOM节点
  2. 碰到<script>标签,如果是内联代码直接执行,如果是外部文件就停下来去下载(除非加了asyncdefer
  3. 等JS执行完了,再继续往下解析HTML

这就是为什么很多人会遇到"JS找不到元素"的经典bug:

<body>
    <script>
        // 完蛋!这时候浏览器还没读到下面的div呢
        const box = document.getElementById('myBox');
        console.log(box); // null,当场去世
    </script>
    
    <div id="myBox">我在这里啊!</div>
</body>

解决方式?要么把script</body>前面,要么用DOMContentLoaded事件:

// 等DOM树建好了再动手,别急
document.addEventListener('DOMContentLoaded', function() {
    const box = document.getElementById('myBox');
    console.log(box); // 这回有了,<div id="myBox">...
});

或者更现代一点的写法:

// 箭头函数版,显得你很潮
document.addEventListener('DOMContentLoaded', () => {
    const box = document.getElementById('myBox');
    box.style.color = 'red'; // 可以愉快地操作了
});

async和defer,到底选哪个?

这俩属性简直是面试高频题,但很多人背完就忘。我给你讲个故事你就记住了:

  • 没有属性(默认):浏览器看到<script>就停下来,下载、执行,完了再继续解析HTML。就像你正在做饭,突然快递来了,你得关火去开门,拿完快递再回来重新开火。效率低下,页面加载慢。

  • async(异步):浏览器边解析HTML边下载JS,下载完了立即执行,执行的时候还是会暂停HTML解析。就像你点了外卖,外卖到了你马上去吃,不管锅里炒到一半的菜。适合独立的脚本,比如统计代码、广告SDK。

<!--  async  -->
<script async src="analytics.js"></script>
  • defer(推迟):浏览器边解析HTML边下载JS,但等到HTML解析完再执行,而且是按顺序执行。就像你点了外卖,但坚持要把手里的菜炒完再吃外卖。适合依赖DOM或者需要按顺序加载的脚本。
<!--  defer  -->
<script defer src="app.js"></script>

所以现代前端的最佳实践通常是:

<head>
    <!-- 关键CSS放这里 -->
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <!-- 页面内容 -->
    
    <!-- 所有JS放这里,加defer -->
    <script defer src="main.js"></script>
    <script defer src="utils.js"></script>
</body>

这样保证HTML先渲染出来,用户不会对着白屏发呆,而且JS执行时DOM已经准备好了。


DOM:那个让HTML和JS能唠嗑的中间人

DOM这词听起来很学术,其实就是Document Object Model的缩写。但你可以把它理解为HTML的"活体镜像"——你在HTML文件里写的每一个标签,浏览器都会在内存里创建一个对应的对象,JS操作这些对象,页面就会跟着变。

节点、元素、属性,都是啥?

别被术语吓到,咱们用微信通讯录来类比:

  • 文档节点(Document):整个通讯录,根老大
  • 元素节点(Element):一个个联系人,比如<div><p><span>
  • 文本节点(Text):联系人名字下面的备注信息
  • 属性节点(Attr):联系人的标签,比如"同事"、“家人”
<!-- 这段HTML在DOM里长这样 -->
<div id="user" class="card" data-age="25">
    <h2>张三</h2>
    <p>前端工程师</p>
</div>

对应的DOM树大概是这样:

Document
  └── html
       └── body
            └── div (id="user", class="card")
                 ├── h2
                 │    └── "张三" (文本节点)
                 └── p
                      └── "前端工程师" (文本节点)

找元素:getElementById vs querySelector

我当年拼getElementById拼错三次不是开玩笑的,这单词也太长了。后来querySelector出来,我直接真香。

// 老派写法,性能其实最好,但写起来手酸
const box = document.getElementById('myBox');

// 现代写法,支持CSS选择器,灵活的一批
const box = document.querySelector('#myBox');
const boxes = document.querySelectorAll('.box'); // 找所有class为box的元素
const firstInput = document.querySelector('input[type="text"]'); // 属性选择器也能用

但这里有个坑:querySelectorAll返回的是NodeList,不是数组!虽然它可以用forEach,但有些数组方法用不了,得先转换:

const divs = document.querySelectorAll('div');

// 这样不行:divs.map is not a function
// const ids = divs.map(d => d.id);

// 得这样
const ids = Array.from(divs).map(d => d.id);

// 或者用展开运算符,看起来更骚
const ids = [...divs].map(d => d.id);

操作元素,别只会innerHTML

新手最容易犯的错就是滥用innerHTML,虽然它确实好用:

const list = document.getElementById('list');

// 危险但爽快的写法
list.innerHTML = `
    <li>苹果</li>
    <li>香蕉</li>
    <li>橘子</li>
`;

// 安全但啰嗦的写法
const fruits = ['苹果', '香蕉', '橘子'];
fruits.forEach(fruit => {
    const li = document.createElement('li');
    li.textContent = fruit; // 用textContent不会解析HTML,防XSS
    list.appendChild(li);
});

为什么说innerHTML危险?假设你在做一个评论区,用户输入的内容直接塞进innerHTML

// 用户输入:<img src=x onerror="alert('你的cookie被我偷了')">
comment.innerHTML = userInput; // 完蛋,XSS攻击!

正确的做法是永远不要信任用户输入,要么用textContent,要么做转义:

function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML; // 浏览器自动转义了特殊字符
}

// 这样即使输入恶意代码,也只会显示为普通文本
comment.innerHTML = escapeHtml(userInput);

classList才是操作样式的正确姿势

以前我改样式是这样写的:

element.className = 'active'; // 直接覆盖,之前的class全没了!
element.className += ' active'; // 拼接字符串,容易多打空格

直到发现了classList,简直是宝藏API:

const btn = document.querySelector('button');

// 添加class,重复添加不会报错,很智能
btn.classList.add('active');
btn.classList.add('large', 'primary'); // 可以一次加多个

// 移除class
btn.classList.remove('disabled');

// 切换class(有就删,没有就加),做tab切换神器
btn.classList.toggle('active');

// 检查有没有某个class,返回true/false
if (btn.classList.contains('active')) {
    console.log('按钮是激活状态');
}

// 还能带条件切换,第二个参数为true就加,false就删
btn.classList.toggle('hidden', isLoading); // isLoading为true就加上hidden

比直接操作className优雅一百倍,而且不用担心空格问题。


事件驱动:JS怎么知道用户点了哪?

前端最本质的特征就是交互,而交互的核心是事件。用户点了一下、输入了几个字、按了个回车,JS怎么知道的?靠事件监听。

别再用onclick了,真的

我知道很多教程还在教onclick,包括我开头说的那个alert例子。但实际项目中,千万别这么写:

<!-- HTML里混JS,维护起来想死 -->
<button onclick="handleClick()">点我</button>

<script>
    function handleClick() {
        alert('点了');
    }
</script>

问题在哪?

  1. HTML和JS耦合在一起,改个函数名要改两个地方
  2. 只能绑定一个事件处理函数,后面写的会覆盖前面的
  3. 不符合"行为与结构分离"的原则

现代写法是用addEventListener

const btn = document.querySelector('button');

// 可以绑定多个监听器,不会互相覆盖
btn.addEventListener('click', () => {
    console.log('第一个监听');
});

btn.addEventListener('click', () => {
    console.log('第二个监听');
});

// 还能指定只触发一次
btn.addEventListener('click', () => {
    console.log('我只说一次');
}, { once: true });

// 移除监听(注意:得用命名函数才能移除)
function handleClick() {
    console.log('我可以被移除');
}
btn.addEventListener('click', handleClick);
btn.removeEventListener('click', handleClick);

事件对象里藏着什么宝贝?

每次事件触发,浏览器都会传一个事件对象给你,里面信息量巨大:

document.addEventListener('click', (event) => {
    console.log(event.type); // 'click'
    console.log(event.target); // 实际被点击的元素
    console.log(event.currentTarget); // 绑定事件的元素(可能是祖先)
    console.log(event.clientX, event.clientY); // 鼠标在视口的坐标
    console.log(event.pageX, event.pageY); // 鼠标在页面的坐标(含滚动)
    console.log(event.timestamp); // 事件发生的时间戳
});

键盘事件更有用:

input.addEventListener('keydown', (e) => {
    console.log(e.key); // 'Enter'
    console.log(e.code); // 'Enter'(物理按键)
    console.log(e.ctrlKey, e.shiftKey, e.altKey); // 修饰键是否按下
    
    // 阻止默认行为,比如阻止表单回车提交
    if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        console.log('阻止了默认提交');
    }
});

事件冒泡和捕获,到底啥意思?

这是面试必考题,但很多人背完就忘。我给你打个比方:

想象你在一个俄罗斯套娃里点击了最里面那个小娃娃。这个点击事件,首先会从最外层的大娃娃(document)开始往下传,经过一层层套娃,直到找到你点的那个小娃娃——这叫捕获阶段。然后事件再从小娃娃往外冒,经过每一层套娃,直到最外层——这叫冒泡阶段

<div id="grandpa">
    <div id="dad">
        <button id="son">点我</button>
    </div>
</div>

<script>
    // 第三个参数true表示在捕获阶段监听,false或不写表示冒泡阶段
    grandpa.addEventListener('click', () => console.log('爷爷捕获'), true);
    dad.addEventListener('click', () => console.log('爸爸捕获'), true);
    son.addEventListener('click', () => console.log('儿子捕获'), true);
    
    grandpa.addEventListener('click', () => console.log('爷爷冒泡'), false);
    dad.addEventListener('click', () => console.log('爸爸冒泡'), false);
    son.addEventListener('click', () => console.log('儿子冒泡'), false);
</script>

点击按钮后,输出顺序是:

爷爷捕获 -> 爸爸捕获 -> 儿子捕获 -> 儿子冒泡 -> 爸爸冒泡 -> 爷爷冒泡

就像你在微信群@全体成员,消息一层层往下传(捕获),然后大家一层层回复(冒泡)。

阻止传播和默认行为,什么时候用?

  • stopPropagation():阻止事件继续传播(冒泡或捕获)
  • preventDefault():阻止元素的默认行为
// 场景:点击按钮提交表单,但想先校验
form.addEventListener('submit', (e) => {
    if (!isValid) {
        e.preventDefault(); // 表单不提交
        alert('请填写完整');
    }
});

// 场景:点击按钮,但不想触发父元素的点击
btn.addEventListener('click', (e) => {
    e.stopPropagation(); // 事件不往上冒泡了
    console.log('只有按钮知道被点了');
});

但要注意,别滥用stopPropagation()!有时候你阻止了冒泡,其他依赖冒泡的逻辑就失效了,比如事件委托。


动态操作HTML:JS怎么"动手动脚"改页面

前端最爽的地方就是能实时改页面,不用刷新。但这里面的性能陷阱也不少。

innerHTML vs textContent vs innerText

这三个属性经常搞混,我帮你理清楚:

const div = document.createElement('div');

// innerHTML:解析HTML标签,危险但强大
div.innerHTML = '<strong>加粗</strong>'; 
console.log(div.innerHTML); // '<strong>加粗</strong>'

// textContent:纯文本,不解析HTML,安全
div.textContent = '<strong>加粗</strong>';
console.log(div.textContent); // '<strong>加粗</strong>'(原样显示)
console.log(div.innerHTML); // '&lt;strong&gt;加粗&lt;/strong&gt;'(自动转义了)

// innerText:考虑CSS样式,比如display:none的元素不会包含
div.innerHTML = '<span style="display:none">隐藏</span><span>显示</span>';
console.log(div.innerText); // '显示'(忽略了隐藏的元素)
console.log(div.textContent); // '隐藏显示'(包含所有文本)

总结:显示用户输入用textContent,自己拼接HTML用innerHTML(但要确保数据安全),需要兼容CSS样式时用innerText(但性能稍差)。

createElement真的老派吗?

很多人说直接操作DOM太慢,都用框架了。但你要知道,框架最后也是操作DOM,只是帮你做了优化。理解底层API很重要:

// 创建一个复杂的列表项
function createListItem(data) {
    const li = document.createElement('li');
    li.className = 'list-item';
    li.dataset.id = data.id; // 自定义数据属性
    
    const img = document.createElement('img');
    img.src = data.avatar;
    img.alt = data.name;
    img.className = 'avatar';
    
    const div = document.createElement('div');
    div.className = 'content';
    
    const h3 = document.createElement('h3');
    h3.textContent = data.name;
    
    const p = document.createElement('p');
    p.textContent = data.description;
    p.className = 'desc';
    
    // 组装起来,像拼积木
    div.appendChild(h3);
    div.appendChild(p);
    li.appendChild(img);
    li.appendChild(div);
    
    return li;
}

// 批量创建,但这样性能差,会触发多次重排
const list = document.getElementById('list');
data.forEach(item => {
    list.appendChild(createListItem(item)); // 每次append都会重排!
});

优化方案是用DocumentFragment

// 就像先把菜都切好,再一次性下锅
const fragment = document.createDocumentFragment();

data.forEach(item => {
    fragment.appendChild(createListItem(item)); // 在内存中操作,不触发重排
});

list.appendChild(fragment); // 一次性插入,只触发一次重排

或者更现代的写法,用insertAdjacentHTML

// 比innerHTML快,不会破坏已有元素的事件监听
list.insertAdjacentHTML('beforeend', `
    <li class="list-item" data-id="${data.id}">
        <img src="${data.avatar}" alt="${data.name}" class="avatar">
        <div class="content">
            <h3>${escapeHtml(data.name)}</h3>
            <p class="desc">${escapeHtml(data.description)}</p>
        </div>
    </li>
`);

修改样式,别直接改style

虽然可以直接改element.style,但维护起来很乱:

// 这样写,过几天你自己都看不懂
element.style.width = '100px';
element.style.height = '100px';
element.style.backgroundColor = 'red'; // 注意是驼峰命名!
element.style.marginTop = '10px';

更好的做法是通过切换class来改变样式:

/* CSS里定义好状态 */
.card {
    width: 100px;
    height: 100px;
    background: blue;
    transition: all 0.3s;
}

.card.active {
    background: red;
    transform: scale(1.1);
}
// JS只负责切换状态
element.classList.add('active');

这样样式和逻辑分离,改样式不用改JS,而且可以利用CSS的过渡动画。


表单交互:用户填的东西,JS怎么拿到手?

表单是前端最常见的交互场景,但里面的坑也不少。

各种输入类型的取值方式

不同类型的表单元素,取值方式不一样:

// 文本输入:用value
const input = document.querySelector('input[type="text"]');
console.log(input.value); // 用户输入的内容

// 复选框:用checked判断是否选中
const checkbox = document.querySelector('input[type="checkbox"]');
console.log(checkbox.checked); // true或false

// 单选框:需要找到被选中的那个
const radios = document.querySelectorAll('input[name="gender"]');
const selected = Array.from(radios).find(r => r.checked);
console.log(selected?.value);

// 下拉选择:用value或selectedIndex
const select = document.querySelector('select');
console.log(select.value); // 选中项的value
console.log(select.selectedIndex); // 选中项的索引
console.log(select.options[select.selectedIndex].text); // 显示的文本

// 多选下拉:需要遍历所有option
const selectedValues = Array.from(select.options)
    .filter(option => option.selected)
    .map(option => option.value);

实时监听输入:input vs change vs keyup

这三个事件很容易混淆:

const input = document.querySelector('input');

// input事件:每次输入都触发,包括粘贴、拖拽
input.addEventListener('input', (e) => {
    console.log('当前值:', e.target.value); // 实时更新
});

// change事件:失去焦点且值改变时才触发
input.addEventListener('change', (e) => {
    console.log('最终值:', e.target.value); // 用户输完离开后才触发
});

// keyup事件:键盘抬起时触发,但无法监听到鼠标粘贴
input.addEventListener('keyup', (e) => {
    console.log('按键:', e.key);
});

实时搜索建议用input,因为它能捕获所有输入方式;最终校验用change,避免中间状态干扰用户。

表单验证实战

const form = document.querySelector('form');
const emailInput = document.querySelector('#email');
const errorTip = document.querySelector('.error-tip');

// 实时校验邮箱格式
emailInput.addEventListener('input', (e) => {
    const email = e.target.value;
    const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    
    if (isValid) {
        emailInput.classList.remove('invalid');
        emailInput.classList.add('valid');
        errorTip.textContent = '';
    } else {
        emailInput.classList.remove('valid');
        emailInput.classList.add('invalid');
        errorTip.textContent = '邮箱格式不正确';
    }
});

// 提交时整体校验
form.addEventListener('submit', (e) => {
    e.preventDefault(); // 先阻止默认提交
    
    const formData = new FormData(form);
    const data = Object.fromEntries(formData); // 转成普通对象
    
    // 校验必填项
    let hasError = false;
    if (!data.username.trim()) {
        showError('username', '用户名不能为空');
        hasError = true;
    }
    
    if (!data.password || data.password.length < 6) {
        showError('password', '密码至少6位');
        hasError = true;
    }
    
    if (hasError) return;
    
    // 提交按钮防重复
    const submitBtn = form.querySelector('button[type="submit"]');
    submitBtn.disabled = true;
    submitBtn.textContent = '提交中...';
    
    // 发送请求
    fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
    .then(res => res.json())
    .then(result => {
        if (result.success) {
            alert('注册成功!');
            form.reset();
        } else {
            alert(result.message);
        }
    })
    .finally(() => {
        // 无论成功失败,恢复按钮
        submitBtn.disabled = false;
        submitBtn.textContent = '注册';
    });
});

防重复提交的小技巧

除了上面代码里的disabled方式,还有更优雅的:

// 利用form的submit事件只触发一次的特性
form.addEventListener('submit', async (e) => {
    e.preventDefault();
    
    // 检查是否正在提交
    if (form.dataset.submitting === 'true') return;
    
    form.dataset.submitting = 'true'; // 标记提交中
    
    try {
        await submitForm();
    } finally {
        form.dataset.submitting = 'false'; // 无论成败,重置状态
    }
});

或者用一个Set来管理正在请求的接口:

const pendingRequests = new Set();

async function submitWithDebounce(url, data) {
    const key = `${url}-${JSON.stringify(data)}`;
    
    if (pendingRequests.has(key)) {
        console.log('相同的请求正在处理中...');
        return;
    }
    
    pendingRequests.add(key);
    
    try {
        const response = await fetch(url, {
            method: 'POST',
            body: JSON.stringify(data)
        });
        return await response.json();
    } finally {
        pendingRequests.delete(key);
    }
}

性能陷阱:别让JS把页面拖成PPT

DOM操作是昂贵的,因为每次修改都可能触发重排(Reflow)重绘(Repaint)

为什么频繁操作DOM会卡?

浏览器渲染页面的步骤大概是:

  1. 解析HTML生成DOM树
  2. 解析CSS生成CSSOM树
  3. 两者结合成渲染树
  4. 布局(Layout/Reflow):计算每个元素的位置和大小
  5. 绘制(Paint):把元素画出来
  6. 合成(Composite):把各层合并成最终页面

当你修改元素的样式或结构时,浏览器可能需要重新执行布局、绘制或合成。最昂贵的是重排,因为它会影响后续所有元素的位置。

// 糟糕的做法:触发多次重排
const list = document.getElementById('list');

for (let i = 0; i < 1000; i++) {
    const li = document.createElement('li');
    li.textContent = `Item ${i}`;
    list.appendChild(li); // 每次append都触发重排!
}

// 优化:先脱离文档流,操作完再插回去
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
    const li = document.createElement('li');
    li.textContent = `Item ${i}`;
    fragment.appendChild(li); // 在内存中操作,不触发重排
}
list.appendChild(fragment); // 只触发一次重排

// 或者更绝的:用display:none隐藏,操作完再显示
list.style.display = 'none'; // 触发一次重排
for (let i = 0; i < 1000; i++) {
    // 疯狂操作,但用户看不见,也不触发重排
}
list.style.display = 'block'; // 再触发一次重排

事件委托:一个监听器管全家

如果你有100个按钮,每个都要绑定点击事件,别傻乎乎地绑100次:

// 笨办法:绑100个监听器
document.querySelectorAll('button').forEach(btn => {
    btn.addEventListener('click', handleClick); // 内存占用高,性能差
});

事件委托利用冒泡机制,只在父元素上绑一个监听器:

// 聪明办法:一个监听器管所有
document.getElementById('button-container').addEventListener('click', (e) => {
    // 检查实际点击的是不是按钮
    if (e.target.matches('button')) {
        console.log('按钮被点了:', e.target.textContent);
        
        // 获取按钮的自定义数据
        const action = e.target.dataset.action;
        handleAction(action);
    }
});

这样无论里面有多少个按钮,甚至动态新增的按钮,都能响应点击。而且内存占用极低。

防抖和节流:控制事件触发频率

搜索框实时请求、窗口resize、滚动监听,这些场景都需要控制频率:

// 防抖(debounce):等用户停下来了再执行
function debounce(fn, delay) {
    let timer = null;
    return function(...args) {
        clearTimeout(timer);
        timer = setTimeout(() => fn.apply(this, args), delay);
    };
}

// 节流(throttle):每隔一段时间执行一次
function throttle(fn, interval) {
    let lastTime = 0;
    return function(...args) {
        const now = Date.now();
        if (now - lastTime >= interval) {
            lastTime = now;
            fn.apply(this, args);
        }
    };
}

// 实际应用
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', debounce((e) => {
    console.log('搜索:', e.target.value);
    fetchSearchResults(e.target.value);
}, 500)); // 停止输入500ms后才搜索

window.addEventListener('scroll', throttle(() => {
    console.log('滚动位置:', window.scrollY);
    checkIfNeedLoadMore();
}, 200)); // 每200ms最多检查一次是否需要加载更多

常见翻车现场 & 急救包

“为什么我的JS找不到那个div?”

症状document.getElementById返回null

病因

  1. 脚本执行时DOM还没解析到那个元素
  2. ID拼写错了(大小写敏感!)
  3. 元素是动态生成的,还没生出来

急救

// 方案1:把script放body底部
<body>
    <div id="app"></div>
    <script src="app.js"></script> <!-- 这时候DOM肯定有了 -->
</body>

// 方案2:等DOMContentLoaded
document.addEventListener('DOMContentLoaded', init);

// 方案3:检查是否真的存在
const el = document.getElementById('app');
if (!el) {
    console.error('找不到#app,检查HTML里有没有这个id');
    return;
}

“点击没反应?”

症状:绑了点击事件,但怎么点都没效果

病因排查清单

// 1. 检查元素是否真的被点击了
btn.addEventListener('click', (e) => {
    console.log('点击事件触发了', e.target); // 看看输出的是什么
});

// 2. 检查是不是被其他元素盖住了
// 在控制台执行,看看有没有半透明遮罩层
document.querySelectorAll('*').forEach(el => {
    const rect = el.getBoundingClientRect();
    if (rect.width > 0 && rect.height > 0) {
        const style = window.getComputedStyle(el);
        if (style.pointerEvents !== 'none' && parseFloat(style.opacity) > 0) {
            console.log('可能遮挡的元素:', el);
        }
    }
});

// 3. 检查事件是否被阻止传播了
document.addEventListener('click', (e) => {
    console.log('全局点击:', e.target); // 如果这里没输出,说明被阻止了
}, true); // 捕获阶段监听,能抓到被阻止冒泡的事件

“innerHTML注入XSS?”

症状:用户输入的内容变成了可执行的代码

预防代码

function sanitizeHtml(html) {
    const temp = document.createElement('div');
    temp.textContent = html; // 先当纯文本处理
    return temp.innerHTML; // 浏览器自动转义特殊字符
}

// 或者更严格的DOMPurify库(生产环境推荐)
// import DOMPurify from 'dompurify';
// const clean = DOMPurify.sanitize(dirtyHtml);

“内存泄漏:页面越用越卡”

常见原因

  1. 移除了DOM元素,但事件监听没移除
  2. 闭包引用了大对象,导致无法回收
  3. 定时器没清理
// 错误示范:元素删了,但事件还在
function badPractice() {
    const btn = document.createElement('button');
    btn.addEventListener('click', () => {
        console.log('这个函数引用了外部的大对象');
        console.log(bigObject); // 即使btn被删,bigObject也不会被回收
    });
    document.body.appendChild(btn);
    // 后来btn被删除了,但事件监听还在内存里!
}

// 正确做法:记得移除监听,或者用弱引用
function goodPractice() {
    const btn = document.createElement('button');
    const handler = () => console.log('clicked');
    
    btn.addEventListener('click', handler);
    document.body.appendChild(btn);
    
    // 清理时
    btn.removeEventListener('click', handler);
    btn.remove();
}

// 更好的做法:用AbortController(现代浏览器支持)
const controller = new AbortController();
btn.addEventListener('click', handler, { signal: controller.signal });

// 批量移除
controller.abort(); // 所有用这个signal的监听器都被移除了

开发骚操作合集

dataset:比getAttribute更友好

HTML5的data-*属性,用dataset读取超方便:

<button 
    id="btn"
    data-user-id="123" 
    data-role="admin"
    data-last-login="2024-01-15"
>
    用户信息
</button>
const btn = document.querySelector('#btn');

// 老办法
console.log(btn.getAttribute('data-user-id')); // '123'

// 新办法:自动驼峰命名转换
console.log(btn.dataset.userId); // '123'(注意是驼峰!)
console.log(btn.dataset.role); // 'admin'
console.log(btn.dataset.lastLogin); // '2024-01-15'

// 还能直接修改
btn.dataset.status = 'online'; // 自动同步到HTML属性
console.log(btn.outerHTML); // data-status="online"出现了

模板字符串快速渲染

小项目不用引入Vue/React,用模板字符串也能快速生成HTML:

const users = [
    { name: '张三', age: 25, avatar: 'zhangsan.jpg' },
    { name: '李四', age: 30, avatar: 'lisi.jpg' }
];

function renderUserList(users) {
    return users.map(user => `
        <div class="user-card" data-name="${escapeHtml(user.name)}">
            <img src="${user.avatar}" alt="${escapeHtml(user.name)}" loading="lazy">
            <div class="info">
                <h3>${escapeHtml(user.name)}</h3>
                <span class="age">${user.age}岁</span>
            </div>
        </div>
    `).join('');
}

document.getElementById('user-list').innerHTML = renderUserList(users);

注意loading="lazy"是原生懒加载,现代浏览器都支持。

MutationObserver:监控DOM变化

有时候你需要知道某个元素什么时候被修改了(比如测试、或者集成第三方库):

const targetNode = document.getElementById('app');

const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        console.log('变化类型:', mutation.type); // 'childList'或'attributes'
        console.log('变化的节点:', mutation.target);
        console.log('添加的节点:', mutation.addedNodes);
        console.log('删除的节点:', mutation.removedNodes);
    });
});

// 开始监控
observer.observe(targetNode, {
    childList: true, // 监控子节点增删
    attributes: true, // 监控属性变化
    subtree: true, // 监控所有后代节点
    characterData: true // 监控文本内容变化
});

// 用完记得断开,避免内存泄漏
// observer.disconnect();

警告:别在MutationObserver的回调里修改被监控的DOM,容易陷入无限循环!


最后碎碎念:别把HTML当木头,JS当锤子

写这篇文章的时候,我想起了自己刚入行时的糗事。有一次为了做一个手风琴效果,我用JS疯狂操作style.height,计算来计算去,bug修了一整天。后来才发现,CSS的max-height配合transition就能搞定,代码量少了90%。

那时候我才明白,HTML、CSS、JS不是谁命令谁的关系,它们是搭档。HTML能自己搞定的事(比如details/summary标签的原生手风琴),就别麻烦JS;CSS能处理的动画,就别用requestAnimationFrame硬写。

好的前端交互,是渐进增强的:

  1. 没有JS,HTML也能正常显示内容
  2. 有了JS,体验更流畅、功能更丰富
  3. JS挂了,页面不至于全白

下次写代码前,先问问自己:“这活儿非得JS干吗?HTML5有没有原生支持?CSS能不能搞定?” 省下的代码都是将来要维护的债,少写一行,就少一个bug的藏身之处。

而且啊,别觉得原生DOM操作过时了。确实,现在大家都用React、Vue,但你看它们的源码,最后不也是调createElementappendChild?理解底层,你写框架代码时才知道为什么要有key,为什么要避免直接修改state。

希望这篇文章能帮你在前端路上少走点弯路。至少,别再拼错getElementById了,真的,我当年面试因为这个被笑了整整五分钟。

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐