前端核心

前端核心

这是一份自测题集。学完HTML基础CSS基础JavaScript基础三篇,再动手做完前端完整示例之后,我用这些题检查自己哪些是真懂了、哪些只是眼熟。
建议每题先自己答(编码题动手写),再看解析,答不上来就回对应的基础篇补课。

HTML 部分

1. HTML5 新增了哪些语义化标签?

代码块HTML · 11 行收起展开
<header>     <!-- 头部 -->
<nav>        <!-- 导航 -->
<main>       <!-- 主内容 -->
<article>    <!-- 独立成篇的内容,如一篇文章 -->
<section>    <!-- 章节 -->
<aside>      <!-- 侧边栏 -->
<footer>     <!-- 页脚 -->
<figure>     <!-- 图片等媒体的容器 -->
<figcaption> <!-- 媒体的说明文字 -->
<time>       <!-- 时间 -->
<mark>       <!-- 高亮文本 -->

为什么要用它们:<div> 什么都能装,但从代码上看不出每一块是干什么的;换成语义化标签,页面结构自己就能把自己讲清楚。受益方是三类“读代码的人”:搜索引擎(更容易理解页面结构,搜索排名会更好,也就是常说的 SEO)、屏幕阅读器(视障用户靠它导航页面)、以及几个月后回来维护的自己。

2. <script> 的 defer 和 async 有什么区别?

先回忆背景:浏览器解析 HTML 是从上往下的,碰到普通 <script> 会停下来,先下载并执行完脚本再继续解析。脚本一大,页面就白屏等着。

代码块HTML · 8 行收起展开
<!-- 普通:阻塞解析,下载完立即执行 -->
<script src="script.js"></script>

<!-- async:后台下载不阻塞,下载完立刻执行,多个脚本谁先下完谁先跑,顺序不保证 -->
<script src="script.js" async></script>

<!-- defer:后台下载不阻塞,等 HTML 全部解析完再按书写顺序执行 -->
<script src="script.js" defer></script>

日常默认用 defer:不挡渲染,执行时页面元素都已解析完(不会出现调试篇里那种“选不到元素”的报错),而且多个脚本之间顺序有保证。async 适合和页面其他脚本没有依赖关系的独立脚本,比如统计代码。

3. 行内元素和块级元素有什么区别?

块级元素独占一行,宽高、边距都能设,适合当结构骨架,比如 divph1ulsection
行内元素不换行,跟文字排在一起,宽高由内容撑开、设了也不生效,比如 spanastrong
还有一类行内块,排列上像行内(不换行),但又能设宽高,比如 imginputbutton

三者可以用 display 互相转换:

代码块CSS · 3 行收起展开
display: block;         /* 转块级 */
display: inline;        /* 转行内 */
display: inline-block;  /* 转行内块 */

CSS 部分

4. 盒模型是什么?

页面上每个元素都是一个矩形的盒子,由内到外四层:content(内容)、padding(内边距,内容和边框之间的空隙)、border(边框)、margin(外边距,盒子和别的盒子之间的距离)。

容易翻车的点是 width 到底量的是哪一层,这由 box-sizing 决定:

代码块CSS · 13 行收起展开
.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,我平时的默认选择:

代码块CSS · 6 行收起展开
.container {
  display: flex;
  justify-content: center;  /* 主轴(水平)居中 */
  align-items: center;      /* 交叉轴(垂直)居中 */
  height: 100vh;            /* vh 是视口高度的百分之一,100vh 就是满屏高 */
}

方法二,Grid,写起来最短:

代码块CSS · 5 行收起展开
.container {
  display: grid;
  place-items: center;
  height: 100vh;
}

方法三,绝对定位加 transform,适合不知道元素自身尺寸的场景:

代码块CSS · 8 行收起展开
.container { position: relative; height: 100vh; }
.item {
  position: absolute;
  top: 50%;
  left: 50%;
  /* 先把左上角挪到正中,再往回挪自身宽高的一半 */
  transform: translate(-50%, -50%);
}

方法四,绝对定位加 margin: auto,要求元素有明确宽高:

代码块CSS · 7 行收起展开
.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)。多个选择器组合时把分数加起来比大小:

代码块CSS · 6 行收起展开
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-blockposition: absolutefloat 本身(都能触发,但各有布局副作用)

BFC 的三个作用对应记:包住内部浮动(清除浮动)、阻止相邻元素的外边距折叠、不被旁边的浮动元素覆盖。

JavaScript 部分

9. var、let、const 的区别?

特性varletconst
作用域函数作用域块作用域块作用域
变量提升
重复声明允许不允许不允许
重新赋值允许允许不允许

“变量提升”的意思是 var 声明会被偷偷挪到函数开头,导致声明之前访问它不会报错,只会拿到 undefined,这是很多老代码诡异 bug 的来源。

代码块JAVASCRIPT · 20 行收起展开
// 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 由调用方式决定,谁调用指向谁。

代码块JAVASCRIPT · 9 行收起展开
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,写起来短很多:

代码块JAVASCRIPT · 2 行收起展开
const add = function (a, b) { return a + b }  // 普通函数
const add2 = (a, b) => a + b                  // 箭头函数,一行

11. 什么是闭包?

人话版:函数记住了自己“出生地”的变量,哪怕出生地(外层函数)已经执行完退场了,这些变量依然活着,只有它能访问。

代码块JAVASCRIPT · 13 行收起展开
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(失败),状态一旦变定就不再改变。

代码块JAVASCRIPT · 16 行收起展开
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 的标准用法:

代码块JAVASCRIPT · 4 行收起展开
fetch('/api/user')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.log(error))

async/await 是它的语法糖,把异步代码写成同步的样子,日常首选:

代码块JAVASCRIPT · 9 行收起展开
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>,在它身上绑一个就够:

代码块JAVASCRIPT · 6 行收起展开
ul.addEventListener('click', (e) => {
  // e.target 是实际被点中的元素
  if (e.target.tagName === 'LI') {
    console.log('点击了', e.target.textContent)
  }
})

14. 数组常用方法有哪些?

先分清会不会改原数组,这决定了用的时候要不要小心:

代码块JAVASCRIPT · 19 行收起展开
// 会改变原数组
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)    // 是否包含

高频的几个过一遍手感:

代码块JAVASCRIPT · 8 行收起展开
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)           // true

mapfilterreduce 是 Vue 模板和各种数据处理里出场率最高的三个,必须闭着眼能写。

15. 深拷贝和浅拷贝的区别?

浅拷贝只复制第一层,嵌套的对象复制的是引用,两边还是同一个:

代码块JAVASCRIPT · 7 行收起展开
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 是同一个对象

深拷贝是完全独立的副本,改哪边都不影响另一边:

代码块JAVASCRIPT · 17 行收起展开
// 方法一: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 后的渲染流程:

  1. 从上往下解析 HTML,构建 DOM 树
  2. 解析 CSS,构建 CSSOM 树(样式规则树)
  3. 两棵树合成渲染树,只包含实际要显示的节点
  4. 布局(Layout):算出每个节点的位置和尺寸
  5. 绘制(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 配置):

代码块PLAINTEXT · 2 行收起展开
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, POST

开发环境常用代理:让开发服务器替浏览器转发请求,浏览器只跟同源的开发服务器打交道,就不存在跨域了。后面会学的 Vite(构建工具,在前端开发工具链里讲)配起来是这样:

代码块JAVASCRIPT · 11 行收起展开
// 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 的区别?

特性localStoragesessionStorageCookie
容量5~10MB5~10MB约 4KB
有效期永久,除非手动删关闭标签页即清空可设置过期时间
是否随请求自动发送是,每次请求都带上

localStorage 在 JS 篇用过了(Todo 的持久化就是它)。sessionStorage 的接口和它一模一样,区别只是生命周期跟着标签页走。

Cookie 的特殊之处在最后一行:它会被浏览器自动塞进每个同源请求的请求头里发给服务器。
Java 后端熟悉的 Session 机制就建立在这上面,服务端 HttpSession 靠的正是浏览器 Cookie 里那个 JSESSIONID,以前只在后端见过它,现在知道它存在浏览器哪里了。
也因为“自动携带”,Cookie 只适合放身份凭证这类必须每次都传的小数据,普通业务数据放 localStorage。

代码块JAVASCRIPT · 6 行收起展开
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 题)最经典的实战应用:timerlast 被返回的函数记住,外界看不见也改不了。

22. 实现数组去重

代码块JAVASCRIPT · 15 行收起展开
// 方法一: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,全部成功时返回结果数组(顺序和传入顺序一致,跟谁先完成无关),只要有一个失败就整体立刻失败。典型场景是页面初始化时并发请求多个接口,全到齐再渲染。

代码块JAVASCRIPT · 20 行收起展开
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

答不顺的题不用硬背,回对应基础篇把示例代码跑一遍,隔几天再来重答一次,比背答案有用得多。

延伸阅读