前端核心
前端核心
这是一份自测题集。学完HTML基础、CSS基础、JavaScript基础三篇,再动手做完前端完整示例之后,我用这些题检查自己哪些是真懂了、哪些只是眼熟。
建议每题先自己答(编码题动手写),再看解析,答不上来就回对应的基础篇补课。
HTML 部分
1. HTML5 新增了哪些语义化标签?
代码块收起展开
<header> <!-- 头部 -->
<nav> <!-- 导航 -->
<main> <!-- 主内容 -->
<article> <!-- 独立成篇的内容,如一篇文章 -->
<section> <!-- 章节 -->
<aside> <!-- 侧边栏 -->
<footer> <!-- 页脚 -->
<figure> <!-- 图片等媒体的容器 -->
<figcaption> <!-- 媒体的说明文字 -->
<time> <!-- 时间 -->
<mark> <!-- 高亮文本 -->为什么要用它们:<div> 什么都能装,但从代码上看不出每一块是干什么的;换成语义化标签,页面结构自己就能把自己讲清楚。受益方是三类“读代码的人”:搜索引擎(更容易理解页面结构,搜索排名会更好,也就是常说的 SEO)、屏幕阅读器(视障用户靠它导航页面)、以及几个月后回来维护的自己。
2. <script> 的 defer 和 async 有什么区别?
先回忆背景:浏览器解析 HTML 是从上往下的,碰到普通 <script> 会停下来,先下载并执行完脚本再继续解析。脚本一大,页面就白屏等着。
代码块收起展开
<!-- 普通:阻塞解析,下载完立即执行 -->
<script src="script.js"></script>
<!-- async:后台下载不阻塞,下载完立刻执行,多个脚本谁先下完谁先跑,顺序不保证 -->
<script src="script.js" async></script>
<!-- defer:后台下载不阻塞,等 HTML 全部解析完再按书写顺序执行 -->
<script src="script.js" defer></script>日常默认用 defer:不挡渲染,执行时页面元素都已解析完(不会出现调试篇里那种“选不到元素”的报错),而且多个脚本之间顺序有保证。async 适合和页面其他脚本没有依赖关系的独立脚本,比如统计代码。
3. 行内元素和块级元素有什么区别?
块级元素独占一行,宽高、边距都能设,适合当结构骨架,比如 div、p、h1、ul、section。
行内元素不换行,跟文字排在一起,宽高由内容撑开、设了也不生效,比如 span、a、strong。
还有一类行内块,排列上像行内(不换行),但又能设宽高,比如 img、input、button。
三者可以用 display 互相转换:
代码块收起展开
display: block; /* 转块级 */
display: inline; /* 转行内 */
display: inline-block; /* 转行内块 */CSS 部分
4. 盒模型是什么?
页面上每个元素都是一个矩形的盒子,由内到外四层:content(内容)、padding(内边距,内容和边框之间的空隙)、border(边框)、margin(外边距,盒子和别的盒子之间的距离)。
容易翻车的点是 width 到底量的是哪一层,这由 box-sizing 决定:
代码块收起展开
.box {
width: 200px;
padding: 10px;
border: 5px solid black;
box-sizing: content-box; /* 默认值:width 只算 content,盒子实际占 230px 宽 */
}
.box {
width: 200px;
padding: 10px;
border: 5px solid black;
box-sizing: border-box; /* width 包含 padding 和 border,盒子就是 200px 宽 */
}border-box 更符合直觉(说 200 就是整体 200),所以很多项目开头都有一句 * { box-sizing: border-box; }。细节在 CSS 篇讲过,忘了回去翻。
5. 如何实现水平垂直居中?
方法一,Flexbox,我平时的默认选择:
代码块收起展开
.container {
display: flex;
justify-content: center; /* 主轴(水平)居中 */
align-items: center; /* 交叉轴(垂直)居中 */
height: 100vh; /* vh 是视口高度的百分之一,100vh 就是满屏高 */
}方法二,Grid,写起来最短:
代码块收起展开
.container {
display: grid;
place-items: center;
height: 100vh;
}方法三,绝对定位加 transform,适合不知道元素自身尺寸的场景:
代码块收起展开
.container { position: relative; height: 100vh; }
.item {
position: absolute;
top: 50%;
left: 50%;
/* 先把左上角挪到正中,再往回挪自身宽高的一半 */
transform: translate(-50%, -50%);
}方法四,绝对定位加 margin: auto,要求元素有明确宽高:
代码块收起展开
.item {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0; /* 四边都贴住 */
margin: auto; /* 剩余空间四边均分,就居中了 */
width: 200px;
height: 100px;
}6. Flexbox 和 Grid 的区别?
Flexbox 是一维布局,一次只管一行或一列,元素沿主轴排队,适合导航栏、按钮组、卡片横排这类“一串东西”。Grid 是二维布局,行和列同时规划,适合整个页面的宏观骨架,比如“左侧边栏 + 右内容区 + 底部页脚”。
选择的直觉:先有内容、让内容自己排开,用 Flex;先画好格子、再往格子里填内容,用 Grid。两者也经常套着用,Grid 搭大骨架,格子内部用 Flex。
7. CSS 选择器优先级怎么算?
从高到低:!important > 内联样式(计 1000)> ID 选择器(计 100)> 类/伪类/属性选择器(计 10)> 标签/伪元素选择器(计 1)。多个选择器组合时把分数加起来比大小:
代码块收起展开
div { color: red; } /* 1 */
.box { color: blue; } /* 10 */
#header { color: green; } /* 100 */
div.box { color: purple; } /* 11 = 1 + 10 */
#header .box { color: orange; } /* 110 = 100 + 10 */
div#header .box a { color: pink; } /* 112 = 1 + 100 + 10 + 1 */分数相同时,写在后面的赢。!important 能压过一切,但它会让样式覆盖关系变得没法推理,尽量别用。实际排查“样式为什么没生效”时,直接开 Elements 面板看谁覆盖了谁最快,方法在前端调试里。
8. BFC 是什么?
BFC(Block Formatting Context,块级格式化上下文),人话版:一个隔离的布局小房间,房间里的浮动元素、外边距都关在里面,不影响外面,外面的也进不来。
这题需要先补一个背景:float 是老式布局属性,能让元素脱离正常文档流靠左或靠右“浮”起来,文字环绕在旁边。
现代布局基本用 Flex 了,但老代码和这道经典题里还常见。浮动元素有个副作用:父容器算高度时不把它算进去,导致父容器“塌陷”成零高度。
让父容器变成 BFC 就能解决,因为 BFC 计算高度时必须把内部浮动包进来。
常见的触发方式:
display: flow-root(专为触发 BFC 设计,无副作用,首选)overflow: hidden/auto(老项目里最常见的写法,副作用是可能裁掉溢出内容)display: inline-block、position: absolute、float本身(都能触发,但各有布局副作用)
BFC 的三个作用对应记:包住内部浮动(清除浮动)、阻止相邻元素的外边距折叠、不被旁边的浮动元素覆盖。
JavaScript 部分
9. var、let、const 的区别?
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块作用域 | 块作用域 |
| 变量提升 | 是 | 否 | 否 |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 重新赋值 | 允许 | 允许 | 不允许 |
“变量提升”的意思是 var 声明会被偷偷挪到函数开头,导致声明之前访问它不会报错,只会拿到 undefined,这是很多老代码诡异 bug 的来源。
代码块收起展开
// var 只认函数边界,if 块拦不住它
if (true) {
var x = 1
}
console.log(x) // 1,泄漏出来了
// let 是块作用域,出了大括号就不存在
if (true) {
let y = 2
}
console.log(y) // ReferenceError
// const 不能重新赋值
const z = 3
z = 4 // TypeError
// 但对象内容可以改
const obj = { a: 1 }
obj.a = 2 // 允许
obj = {} // TypeError写惯 Java 的人看 const 第一反应是常量,其实它和 Java 的 final 一个脾气:锁的是引用本身,引用指向的对象内容照样能改。日常规则很简单:默认 const,需要重新赋值再改成 let,var 永远不用。
10. 箭头函数和普通函数的区别?
最重要的区别是 this:箭头函数没有自己的 this,直接沿用定义时外层的 this;普通函数的 this 由调用方式决定,谁调用指向谁。
代码块收起展开
const obj = {
name: '张三',
greet: function () {
console.log(this.name) // '张三',obj 调用的,this 就是 obj
},
greetArrow: () => {
console.log(this.name) // undefined,箭头函数用的是外层(这里是全局)的 this
}
}所以经验法则是:对象的方法用普通函数(需要 this 指向对象本身),回调用箭头函数(不想让 this 被调用方式改变,调试篇的 setTimeout 例子就是这个场景)。
另外三点:箭头函数不能当构造函数 new;没有 arguments 对象(要收集参数用 ...args);单表达式可以省略大括号和 return,写起来短很多:
代码块收起展开
const add = function (a, b) { return a + b } // 普通函数
const add2 = (a, b) => a + b // 箭头函数,一行11. 什么是闭包?
人话版:函数记住了自己“出生地”的变量,哪怕出生地(外层函数)已经执行完退场了,这些变量依然活着,只有它能访问。
代码块收起展开
function outer() {
let count = 0 // 外层的局部变量
return function inner() {
count++ // inner 一直记着 count
console.log(count)
}
}
const counter = outer() // outer 已经执行完了
counter() // 1
counter() // 2
counter() // 3,count 一直活着,且外界摸不到它典型用途就是像上面这样制造“私有变量”:外界拿不到 count,只能通过 counter 间接操作。防抖节流(见编码题)也全靠闭包保存内部状态。代价是被闭包引用的变量不会被垃圾回收,用不好会内存泄漏,调试篇的 Memory 小节讲过怎么查。
12. Promise 是什么?
Promise 是一个代表“未来才有结果的操作”的对象,主要为了解决回调函数层层嵌套的问题。它有三种状态:pending(等待中)、fulfilled(成功)、rejected(失败),状态一旦变定就不再改变。
代码块收起展开
const promise = new Promise((resolve, reject) => {
// 这里放异步操作,成功时调 resolve,失败时调 reject
setTimeout(() => {
const success = true
if (success) {
resolve('成功的结果')
} else {
reject('失败原因')
}
}, 1000)
})
promise
.then(result => console.log(result)) // 成功走这里
.catch(error => console.log(error)) // 失败走这里
.finally(() => console.log('无论成败都执行'))then 返回的还是 Promise,所以能链式串起来,这正是 fetch 的标准用法:
代码块收起展开
fetch('/api/user')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.log(error))async/await 是它的语法糖,把异步代码写成同步的样子,日常首选:
代码块收起展开
async function getUser() {
try {
const response = await fetch('/api/user')
const data = await response.json()
console.log(data)
} catch (error) {
console.log(error)
}
}从回调到 Promise 再到 async/await 的演进过程,JS 篇的异步小节讲得更完整。
13. 事件冒泡和事件捕获是什么?
点击一个按钮时,这个事件其实在 DOM 树上走了一个来回:先从最外层一路向内传到按钮(捕获阶段),到达按钮本身(目标阶段),再从按钮一路向外传回去(冒泡阶段)。addEventListener 默认在冒泡阶段触发。
<div id="outer">
<div id="inner">
<button id="btn">点击</button>
</div>
</div>btn.addEventListener('click', () => console.log('button'))
inner.addEventListener('click', () => console.log('inner'))
outer.addEventListener('click', () => console.log('outer'))
// 点击按钮,冒泡从内向外:button → inner → outer
// 第三个参数传 true,改为捕获阶段触发(很少用)
outer.addEventListener('click', () => console.log('outer'), true)
// 不想让事件继续往外传,就地拦住
btn.addEventListener('click', (e) => {
e.stopPropagation()
})冒泡最有用的应用是事件委托:列表有一千个 <li>,与其绑一千个监听器,反正点击都会冒泡到 <ul>,在它身上绑一个就够:
代码块收起展开
ul.addEventListener('click', (e) => {
// e.target 是实际被点中的元素
if (e.target.tagName === 'LI') {
console.log('点击了', e.target.textContent)
}
})14. 数组常用方法有哪些?
先分清会不会改原数组,这决定了用的时候要不要小心:
代码块收起展开
// 会改变原数组
arr.push(item) // 末尾添加
arr.pop() // 末尾删除
arr.unshift(item) // 开头添加
arr.shift() // 开头删除
arr.splice(index, n) // 从 index 起删 n 个(还能顺便插入)
arr.reverse() // 反转
arr.sort() // 排序
// 返回新数组/新值,原数组不动
arr.map(fn) // 每个元素变换一遍
arr.filter(fn) // 留下满足条件的
arr.find(fn) // 找第一个满足条件的元素
arr.findIndex(fn) // 找它的下标
arr.some(fn) // 有没有满足条件的
arr.every(fn) // 是否全部满足
arr.reduce(fn, init) // 把整个数组归约成一个值
arr.slice(start, end) // 截取一段
arr.includes(item) // 是否包含高频的几个过一遍手感:
代码块收起展开
const nums = [1, 2, 3, 4, 5]
nums.map(x => x * 2) // [2, 4, 6, 8, 10]
nums.filter(x => x % 2 === 0) // [2, 4]
nums.reduce((sum, x) => sum + x, 0) // 15,从 0 开始累加
nums.find(x => x > 3) // 4,第一个大于 3 的
nums.some(x => x > 3) // true
nums.every(x => x > 0) // truemap、filter、reduce 是 Vue 模板和各种数据处理里出场率最高的三个,必须闭着眼能写。
15. 深拷贝和浅拷贝的区别?
浅拷贝只复制第一层,嵌套的对象复制的是引用,两边还是同一个:
代码块收起展开
const obj = { a: 1, b: { c: 2 } }
const copy1 = { ...obj } // 展开运算符,浅拷贝
const copy2 = Object.assign({}, obj) // 效果相同
copy1.b.c = 3
console.log(obj.b.c) // 3,原对象跟着变了,因为 b 是同一个对象深拷贝是完全独立的副本,改哪边都不影响另一边:
代码块收起展开
// 方法一:JSON 一转一还原,简单场景够用
const deepCopy1 = JSON.parse(JSON.stringify(obj))
// 局限:函数、undefined 会丢,循环引用直接报错
// 方法二:手写递归
function deepClone(obj) {
// 基本类型和 null 直接返回
if (obj === null || typeof obj !== 'object') return obj
const copy = Array.isArray(obj) ? [] : {}
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
copy[key] = deepClone(obj[key]) // 每一层都递归复制
}
}
return copy
}实际项目里一般不手写,直接用现成工具库 lodash 的 cloneDeep,等学到 npm 和工具链之后就能用了,见前端常用工具库。
浏览器与网络
16. 从输入 URL 到页面显示,中间发生了什么?
前半段对写过后端的人很熟悉:浏览器先做 DNS 解析把域名换成 IP,然后和服务器三次握手建立 TCP 连接,发出 HTTP 请求;服务器处理后返回 HTTP 响应,正文里就是 HTML 文本。到这一步为止,我在后端日志里全都见过,真正的新知识是后半段浏览器内部的事。
浏览器拿到 HTML 后的渲染流程:
- 从上往下解析 HTML,构建 DOM 树
- 解析 CSS,构建 CSSOM 树(样式规则树)
- 两棵树合成渲染树,只包含实际要显示的节点
- 布局(Layout):算出每个节点的位置和尺寸
- 绘制(Paint):把像素画到屏幕上
中途碰到普通 <script> 会暂停解析先执行脚本(第 2 题 defer 的意义就在这),碰到图片等资源则异步去下载。后面改动页面时,动了尺寸位置会触发重新布局(重排),只动颜色之类的只触发重绘,重排比重绘贵得多,这是性能优化的一个基础结论。
17. 如何优化网页性能?
这题先建立框架,细节等学完 Vue 主线再去前端性能优化展开。思路就三条线。
让传输的东西变小、变少:文本资源压缩(Gzip/Brotli)、图片压缩和懒加载(滚到可视区域才加载)、代码按需加载(用到哪个页面才下载哪个页面的代码,术语叫代码分割)、静态资源上 CDN(内容分发网络,把文件缓存到离用户更近的节点上)、利用好 HTTP 缓存。
让浏览器渲染时少折腾:减少 DOM 操作次数、避免频繁触发重排、高频事件加防抖节流(见编码题 21)、超长列表用虚拟滚动(只渲染看得见的几十行)。
让 JS 不挡道:脚本加 defer、大计算量任务丢给 Web Worker(浏览器里的后台线程,不占用负责渲染的主线程)。
18. 什么是跨域?如何解决?
浏览器有个同源策略:协议、域名、端口三者完全相同才算同源,页面里的 JS 默认只能请求同源的接口。页面在 http://localhost:5173,接口在 http://localhost:8080,端口不同就算跨域,请求会被浏览器拦下并在 Console 报 CORS 错误。
我第一次被它坑到时很懵:同一个接口用 Postman 请求明明是通的。后来才想明白,拦截这件事是浏览器干的,服务器其实正常返回了,只是浏览器看响应头里没有授权标记,就不把结果交给 JS。Postman 不是浏览器,自然没这回事。
解决方案主要两条。生产环境用 CORS,由后端在响应头里声明允许谁来访问(Spring 里就是 @CrossOrigin 注解或一个 CorsFilter 配置):
代码块收起展开
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, POST开发环境常用代理:让开发服务器替浏览器转发请求,浏览器只跟同源的开发服务器打交道,就不存在跨域了。后面会学的 Vite(构建工具,在前端开发工具链里讲)配起来是这样:
代码块收起展开
// vite.config.js(export default 是模块的导出语法,工具链篇首讲,这里先照抄)
export default {
server: {
proxy: {
'/api': { // 以 /api 开头的请求
target: 'https://api.example.com', // 都转发到这里
changeOrigin: true
}
}
}
}老资料里还会提 JSONP,利用 <script> 标签不受同源策略限制的特点绕过去,只支持 GET,现在基本淘汰了,知道名字即可。
19. 常见 HTTP 状态码有哪些?
写后端接口时天天返回这些码,换到前端视角,重点变成“在 Network 面板看到它该往哪查”。
2xx 成功:200 OK 最常见;201 Created 表示创建成功;204 No Content 成功但没有返回体。
3xx 重定向:301 永久重定向,302 临时重定向;304 Not Modified 表示资源没变、直接用缓存,Network 里看到它是好事。
4xx 客户端的问题:400 请求本身有毛病(常见是参数格式不对);401 未登录或 token 过期,前端通常要跳登录页;403 登录了但没权限;404 路径写错了或资源不存在。
5xx 服务器的问题:500 服务器内部报错,去翻后端日志;502/503 一般是网关后面的服务挂了或没起来。
前端排查的经验:4xx 先检查自己发的请求(URL、参数、有没有带 token),5xx 直接找后端。
20. localStorage、sessionStorage、Cookie 的区别?
| 特性 | localStorage | sessionStorage | Cookie |
|---|---|---|---|
| 容量 | 5~10MB | 5~10MB | 约 4KB |
| 有效期 | 永久,除非手动删 | 关闭标签页即清空 | 可设置过期时间 |
| 是否随请求自动发送 | 否 | 否 | 是,每次请求都带上 |
localStorage 在 JS 篇用过了(Todo 的持久化就是它)。sessionStorage 的接口和它一模一样,区别只是生命周期跟着标签页走。
Cookie 的特殊之处在最后一行:它会被浏览器自动塞进每个同源请求的请求头里发给服务器。
Java 后端熟悉的 Session 机制就建立在这上面,服务端 HttpSession 靠的正是浏览器 Cookie 里那个 JSESSIONID,以前只在后端见过它,现在知道它存在浏览器哪里了。
也因为“自动携带”,Cookie 只适合放身份凭证这类必须每次都传的小数据,普通业务数据放 localStorage。
代码块收起展开
localStorage.setItem('key', 'value')
localStorage.getItem('key')
localStorage.removeItem('key')
localStorage.clear()
sessionStorage.setItem('key', 'value') // 用法完全相同动手编码题
21. 手写防抖和节流
两者都是给高频事件降频的手段,先分清语义。防抖:事件停止触发一段时间后才执行,连续触发只算最后一次,典型场景是搜索框(用户停止输入 500ms 才发请求)。节流:固定时间窗口内最多执行一次,典型场景是滚动事件(每 200ms 最多处理一次)。
// 防抖:每次触发都重置计时器,只有最后一次能撑到执行
function debounce(fn, delay) {
let timer = null // 闭包保存计时器编号
return function (...args) { // ...args 把所有参数收进数组
clearTimeout(timer) // 又触发了,作废上一次的计时
timer = setTimeout(() => {
fn.apply(this, args) // 保持 this 和参数原样调用 fn
}, delay)
}
}
const handleInput = debounce(() => {
console.log('搜索')
}, 500)
input.addEventListener('input', handleInput)// 节流:记录上次执行时间,没到间隔就忽略
function throttle(fn, delay) {
let last = 0 // 闭包保存上次执行时间
return function (...args) {
const now = Date.now()
if (now - last >= delay) {
fn.apply(this, args)
last = now
}
}
}
const handleScroll = throttle(() => {
console.log('滚动')
}, 200)
window.addEventListener('scroll', handleScroll)这两个函数是闭包(第 11 题)最经典的实战应用:timer 和 last 被返回的函数记住,外界看不见也改不了。
22. 实现数组去重
代码块收起展开
// 方法一:Set,首选。Set 是自动去重的集合(类似 Java 的 HashSet),
// 数组塞进去重复项就没了,再用展开运算符倒回数组
const unique = arr => [...new Set(arr)]
// 方法二:filter,只保留第一次出现的元素
// indexOf 返回元素首次出现的下标,和当前下标相等说明是第一次出现
const unique2 = arr => arr.filter((item, index) => arr.indexOf(item) === index)
// 方法三:reduce,逐个累积,没见过的才加进结果
const unique3 = arr => arr.reduce(
(acc, item) => acc.includes(item) ? acc : [...acc, item],
[]
)
unique([1, 2, 2, 3, 3, 4]) // [1, 2, 3, 4]23. 实现 Promise.all
先弄清原版行为:Promise.all(promises) 接收一组 Promise,全部成功时返回结果数组(顺序和传入顺序一致,跟谁先完成无关),只要有一个失败就整体立刻失败。典型场景是页面初始化时并发请求多个接口,全到齐再渲染。
代码块收起展开
Promise.myAll = function (promises) {
return new Promise((resolve, reject) => {
const results = []
let count = 0 // 已完成的数量
promises.forEach((promise, index) => {
// Promise.resolve 包一层,兼容数组里混着普通值的情况
Promise.resolve(promise).then(
value => {
results[index] = value // 按原始位置放结果,保证顺序
count++
if (count === promises.length) {
resolve(results) // 全部到齐,整体成功
}
},
reason => reject(reason) // 任何一个失败,整体立刻失败
)
})
})
}两个容易写错的点:结果必须用 results[index] 按位置存,用 push 会变成按完成先后排序;完成计数不能用 results.length 判断:假如后面位置的先完成,比如 results[2] 先被赋值,length 会直接跳到 3,数组中间全是空位(这种叫稀疏数组),得单独用 count。
答不顺的题不用硬背,回对应基础篇把示例代码跑一遍,隔几天再来重答一次,比背答案有用得多。