← 已是第一轮 · 返回本次面经 · 已是最后一轮 →

本轮概述: 这一轮主要考察了候选人的自我介绍、职业规划、项目经验、前端技术细节以及算法问题。面试氛围较为全面,涵盖了从基础到高级的多个方面。

面试时间: 45分钟

本轮共 21 道题。答案默认折叠,便于先自行作答。

1. 自我介绍

题目要点

个人背景, 工作经验, 技术栈, 项目经验, 未来展望

参考答案

自我介绍应包括个人背景、工作经验、技术栈、项目经验和对未来的展望。重点在于展示自己的专业能力和对前端技术的热情。

2. 你的职业规划与前端技术方向的关系?

题目要点

技术趋势, 个人兴趣, 学习和实践, 技能提升, 未来发展

参考答案

回答时可以结合当前的技术趋势和个人兴趣,说明如何通过不断学习和实践来提升自己在前端领域的技能。同时,可以提到对未来技术发展的看法和如何适应这些变化。

3. 讲一个你参与过的项目

题库原题:你参与过哪些前端基建方面的建设?

题目要点

前端基建涵盖了从项目架构、构建工具配置到开发环境、自动化流程和安全等多个方面。良好的前端基建可以提升开发效率、代码质量和系统性能,为项目的长期维护和扩展打下坚实的基础。

参考答案

可以从以下方面进行回答:

1. 项目架构

  • 目录结构:定义项目文件和目录的组织结构,确保项目的可维护性和可扩展性。
  • 代码规范:统一代码风格和规范,使用 ESLint、Prettier 等工具进行代码检查和格式化。
  • 模块化:采用模块化设计,将代码拆分为独立的模块,提高可重用性和可维护性。
  • 脚手架:自动生成项目的基础结构,包括目录结构、配置文件、示例代码等。

2. 构建工具和配置

  • 构建工具:使用工具如 Webpack、Vite、Rollup 等进行项目的构建和打包。
  • 配置管理:配置构建工具以支持各种功能,如代码分割、热重载、环境变量等。
  • 优化:配置代码压缩、缓存策略、Tree Shaking 等,提升构建产物的性能和效率。

3. 开发环境

  • 开发服务器:设置本地开发服务器,支持热重载和调试功能。
  • 环境配置:管理开发、测试、生产环境的配置和变量。

4. 包管理

  • 依赖管理:使用 npm、Yarn 或 pnpm 等工具管理项目依赖,确保依赖版本一致性。
  • 发布管理:管理和发布自定义的 npm 包或组件库。

5. 自动化流程

  • CI/CD:配置持续集成(CI)和持续部署(CD)流程,自动化构建、测试和部署。
  • 测试:集成单元测试、集成测试和端到端测试工具,如 Jest、Cypress 等,确保代码质量。

6. 代码质量

  • 静态分析:使用 ESLint、TSLint 等工具进行代码静态检查。
  • 测试覆盖率:监控测试覆盖率,确保关键代码路径被充分测试。

7. 文档

  • 代码文档:编写和维护项目文档,包括 API 文档、开发指南和使用说明。
  • 自动化文档生成:使用工具如 Storybook、JSDoc 等生成组件库和 API 文档。

8. 组件库

  • 设计系统:构建和维护一套一致的设计系统和组件库,提高开发效率和界面一致性。
  • 共享组件:创建和管理共享组件,促进代码复用。

9. 性能优化

  • 前端性能:优化页面加载速度、响应时间和渲染性能。
  • 网络请求:管理和优化网络请求策略,减少请求次数和数据传输量。

10. 安全

  • 安全最佳实践:遵循前端安全最佳实践,如防范 XSS 和 CSRF 攻击。
  • 敏感数据保护:确保敏感数据不被暴露或滥用。

4. 项目中遇到的印象最深的难题是什么?如何解决的?

题目要点

难题背景, 影响, 解决方案, 最终结果

参考答案

选择一个具体的难题,详细描述问题的背景、影响、解决方案及最终结果。重点在于展示解决问题的思路和方法。

5. 项目中如何优化首屏加载时间?

题库原题:SPA(单页应用)首屏加载速度慢怎么解决?

题目要点

回答思路:

针对SPA(单页应用)首屏加载速度慢的问题,可以通过以下几种方式来解决:

1. 代码优化

  • 合并与压缩文件:使用Webpack等工具合并和压缩JavaScript和CSS文件,减少文件大小,提高加载速度。
  • 代码分割:利用Webpack的Code Splitting功能,将应用程序代码拆分为多个较小的文件,并在需要时动态加载,减少首屏加载所需的时间。
  • 懒加载:对于非首屏必需的组件或资源,采用懒加载技术,只在需要时加载,减少初始加载内容。

2. 图片优化

  • 压缩图片:对SPA中的图片进行压缩处理,减小图片大小,从而提升首屏加载速度。
  • 使用高效图片格式:如WebP,它比传统的JPEG、PNG等格式具有更高的压缩率和更好的性能。

3. 服务器优化

  • 缓存技术:使用缓存技术减少网络请求的数量和时间,例如对常用数据进行缓存,避免每次都重新请求。
  • CDN加速:将一些静态资源(如图片、CSS、JS等)放在CDN上,利用CDN的分布式网络缩短资源加载时间。
  • 优化服务器响应时间:通过优化数据库查询、使用更快的服务器硬件等方式,提高服务器响应速度。

4. 路由优化

  • 路由懒加载:将SPA中不同路由对应的代码进行分割,实现路由懒加载,这样用户切换路由时只加载当前路由所需的代码。

5. 使用服务端渲染(SSR)

  • SSR可以在服务器端生成HTML页面,减少客户端的渲染时间和数据请求时间,从而提高首屏加载速度。但需要注意的是,SSR需要服务器端的支持,开发成本相对较高。

6. 其他优化措施

  • 骨架屏:在页面加载过程中,先显示一个骨架屏,让用户感觉到页面正在加载,避免白屏问题,提高用户体验。
  • 优化JavaScript执行:确保JavaScript代码尽可能地高效,避免不必要的计算和循环,优化算法和数据结构。
参考答案

一、什么是首屏加载

首屏时间(First Contentful Paint),指的是浏览器从响应用户输入网址地址,到首屏内容渲染完成的时间,此时整个网页不一定要全部渲染完成,但需要展示当前视窗需要的内容

首屏加载可以说是用户体验中最重要的环节

关于计算首屏时间

利用performance.timing提供的数据:

image.png

通过DOMContentLoad或者performance来计算出首屏时间

// 方案一:
document.addEventListener('DOMContentLoaded', (event) => {
    console.log('first contentful painting');
});
// 方案二:
performance.getEntriesByName("first-contentful-paint")[0].startTime

// performance.getEntriesByName("first-contentful-paint")[0]
// 会返回一个 PerformancePaintTiming的实例,结构如下:
{
  name: "first-contentful-paint",
  entryType: "paint",
  startTime: 507.80000002123415,
  duration: 0,
};

二、加载慢的原因

在页面渲染的过程,导致加载速度慢的因素可能如下:

  • 网络延时问题
  • 资源文件体积是否过大
  • 资源是否重复发送请求去加载了
  • 加载脚本的时候,渲染内容堵塞了

三、解决方案

常见的几种SPA首屏优化方式

  • 减小入口文件积
  • 静态资源本地缓存
  • UI框架按需加载
  • 图片资源的压缩
  • 组件重复打包
  • 开启GZip压缩
  • 使用SSR

减小入口文件体积

常用的手段是路由懒加载,把不同路由对应的组件分割成不同的代码块,待路由被请求的时候会单独打包路由,使得入口文件变小,加载速度大大增加

image.png

vue-router配置路由的时候,采用动态加载路由的形式

routes:[
    path: 'Blogs',
    name: 'ShowBlogs',
    component: () => import('./components/ShowBlogs.vue')
]

以函数的形式加载路由,这样就可以把各自的路由文件分别打包,只有在解析给定的路由时,才会加载路由组件

静态资源本地缓存

后端返回资源问题:

  • 采用HTTP缓存,设置Cache-ControlLast-ModifiedEtag等响应头

  • 采用Service Worker离线缓存

前端合理利用localStorage

UI框架按需加载

在日常使用UI框架,例如element-UI、或者antd,我们通常会直接引用整个UI

import ElementUI from 'element-ui'
Vue.use(ElementUI)

但实际上我用到的组件只有按钮,分页,表格,输入与警告 所以我们要按需引用

import { Button, Input, Pagination, Table, TableColumn, MessageBox } from 'element-ui';
Vue.use(Button)
Vue.use(Input)
Vue.use(Pagination)

组件重复打包

假设A.js文件是一个常用的库,现在有多个路由使用了A.js文件,这就造成了重复下载

解决方案:在webpackconfig文件中,修改CommonsChunkPlugin的配置

minChunks: 3

minChunks为3表示会把使用3次及以上的包抽离出来,放进公共依赖文件,避免了重复加载组件

图片资源的压缩

图片资源虽然不在编码过程中,但它却是对页面性能影响最大的因素

对于所有的图片资源,我们可以进行适当的压缩

对页面上使用到的icon,可以使用在线字体图标,或者雪碧图,将众多小图标合并到同一张图上,用以减轻http请求压力。

开启GZip压缩

拆完包之后,我们再用gzip做一下压缩 安装compression-webpack-plugin

cnmp i compression-webpack-plugin -D

vue.congig.js中引入并修改webpack配置

const CompressionPlugin = require('compression-webpack-plugin')

configureWebpack: (config) => {
        if (process.env.NODE_ENV === 'production') {
            // 为生产环境修改配置...
            config.mode = 'production'
            return {
                plugins: [new CompressionPlugin({
                    test: /\.js$|\.html$|\.css/, //匹配文件名
                    threshold: 10240, //对超过10k的数据进行压缩
                    deleteOriginalAssets: false //是否删除原文件
                })]
            }
        }

在服务器我们也要做相应的配置 如果发送请求的浏览器支持gzip,就发送给它gzip格式的文件 我的服务器是用express框架搭建的 只要安装一下compression就能使用

const compression = require('compression')
app.use(compression())  // 在其他中间件使用之前调用

使用SSR

SSR(Server side ),也就是服务端渲染,组件或页面通过服务器生成html字符串,再发送到浏览器

从头搭建一个服务端渲染是很复杂的,vue应用建议使用Nuxt.js实现服务端渲染

小结:

减少首屏渲染时间的方法有很多,总的来讲可以分成两大部分 :资源加载优化 和 页面渲染优化

下图是更为全面的首屏优化的方案

image.png

大家可以根据自己项目的情况选择各种方式进行首屏渲染的优化

6. 讲一下你设计的权限登陆功能

题库原题:在前端应用中,怎么进行系统权限的设计?

题目要点

在前端应用中,进行系统权限的设计是为了保证不同用户在不同权限级别下能够访问相应的资源和功能。权限设计通常涉及 用户身份管理权限控制前后端协作 以及 安全性 考虑。以下是设计思路:

参考答案

在前端应用中,进行系统权限的设计是为了保证不同用户在不同权限级别下能够访问相应的资源和功能。权限设计通常涉及 用户身份管理权限控制前后端协作 以及 安全性 考虑。以下是设计思路:

1. 权限模型的设计

权限模型通常可以采用以下几种方式进行管理:

  • 基于角色的访问控制(RBAC,Role-Based Access Control): 这是最常见的权限设计模式。用户被分配一个或多个角色,每个角色对应一组权限。前端可以根据用户的角色显示不同的页面或组件。

  • 基于资源的访问控制: 每个资源(如菜单、页面、API)都设置特定的权限。根据用户是否具备访问某个资源的权限来控制可见性和操作。

  • 基于属性的访问控制(ABAC,Attribute-Based Access Control): 权限是根据用户属性(如部门、地域、职位)来控制的,可以实现更细粒度的权限管理。

2. 权限管理设计步骤

2.1. 权限系统初始化

  • 用户身份认证: 首先通过登录认证系统(OAuth、JWT、Session等)获取用户的身份信息和权限信息。用户登录后,后端返回 token(JWT)或者权限数据,前端需要保存用户的登录态。

  • 获取用户权限信息: 登录后,前端需要从服务器拉取用户的权限信息。通常权限信息包括:

    • 用户角色
    • 用户具体的权限点(可操作的页面、功能)

    权限信息可以通过 API 获取,也可以包含在登录返回的 token 中。

2.2. 路由权限控制

  • 路由守卫: 前端的路由系统需要和权限系统集成,来防止用户访问未授权的页面。比如在 Vue 或 React 中使用路由守卫,来在用户每次访问页面时,检查是否有权限进入。

    • 在 Vue 中可以使用 beforeEach 路由守卫:

      router.beforeEach((to, from, next) => {
        const userRoles = getUserRoles(); // 获取用户角色
        if (to.meta.roles && !userRoles.includes(to.meta.roles)) {
          next('/no-access'); // 无权限访问,重定向到错误页面
        } else {
          next(); // 有权限,继续访问
        }
      });
      
    • 在 React 中,可以通过 PrivateRoute 组件来封装路由检查:

      const PrivateRoute = ({ component: Component, roles, ...rest }) => (
        <Route
          {...rest}
          render={props =>
            hasPermission(roles) ? (
              <Component {...props} />
            ) : (
              <Redirect to="/no-access" />
            )
          }
        />
      );
      

2.3. 组件和元素级别的权限控制

除了路由级别的权限控制,有时需要在页面内进行细粒度的权限控制,比如某个按钮、表单项是否可见或可操作。

  • 基于权限判断是否渲染组件: 在渲染组件时,可以基于用户的权限动态控制显示或隐藏:

    const DeleteButton = () => {
      if (!hasPermission('delete-item')) {
        return null; // 无权限,不渲染按钮
      }
      return <button>Delete</button>;
    };
    
  • 基于权限动态禁用元素: 甚至可以根据权限来控制元素的状态,比如禁用某些功能:

    <button disabled={!hasPermission('edit-item')}>Edit</button>
    

2.4. API 请求的权限控制

前端可以根据权限,决定用户能否执行某些请求。如果用户无权限,可以不显示相关按钮或直接阻止请求:

  • 发起 API 请求前的权限校验: 在发起请求之前,先检查当前用户是否具备权限:

    if (!hasPermission('create-item')) {
      alert('You do not have permission to create items.');
      return;
    }
    api.createItem(data); // 发起请求
    
  • 后端校验权限: 由于前端的权限是可见的,最终权限控制还需在后端校验,防止用户通过绕过前端限制直接发起请求。后端需验证用户的角色和权限,确保用户有权执行请求。

3. 权限缓存和更新

前端在登录后,通常会将权限信息存储在内存(如 ReduxVuex 中)或者本地存储(如 localStoragesessionStorage)中,减少不必要的权限拉取请求。

  • 缓存权限: 可以将用户的权限信息在首次登录后存储起来,后续直接读取缓存,减少 API 请求:

    const userPermissions = JSON.parse(localStorage.getItem('user-permissions'));
    
  • 权限更新: 如果权限发生变动(比如管理员更新了用户角色),前端需要重新拉取权限数据,并更新缓存:

    api.updatePermissions().then(newPermissions => {
      localStorage.setItem('user-permissions', JSON.stringify(newPermissions));
    });
    

4. 安全性考虑

  • 防止前端绕过: 前端权限控制是用户体验层面的,但真正的权限校验必须由后端完成,防止用户通过修改前端代码或发送非法请求来获得未经授权的访问。

  • 避免敏感数据泄漏: 在权限不足的情况下,前端不应该加载任何与该权限相关的资源,避免泄漏敏感数据或功能。

5. 权限设计示例

权限配置结构

  • 用户信息:

    {
      "userId": "123",
      "roles": ["admin", "editor"],
      "permissions": ["view-dashboard", "edit-post", "delete-post"]
    }
    
  • 路由配置:

    const routes = [
      { path: '/dashboard', component: Dashboard, meta: { roles: ['admin', 'editor'] } },
      { path: '/admin', component: Admin, meta: { roles: ['admin'] } },
      { path: '/no-access', component: NoAccess }
    ];
    
  • 权限校验函数:

    function hasPermission(requiredPermissions) {
      const userPermissions = getUserPermissions(); // 从缓存或 API 获取用户权限
      return requiredPermissions.every(permission => userPermissions.includes(permission));
    }
    

6. 权限设计的前后端协作

前端通过 角色或权限 来控制页面显示和功能,而后端则要根据每个用户的权限返回不同的数据或响应。例如,某些 API 接口可能只允许管理员访问。

7. Token刷新机制如何实现?如何避免频繁刷新导致的性能问题?

题库原题:token过期后,页面如何实现无感刷新?

题目要点

通过使用刷新 token 机制和请求拦截器,可以实现 token 过期后的无感刷新,确保用户体验不受影响。如果刷新 token 失败,则通常需要用户重新登录。整个过程对用户是透明的,最大限度地提升了用户体验。

参考答案

当用户的身份验证 token 过期时,可以通过以下方法实现页面的无感刷新(即用户无感知的情况下自动刷新 token 并继续操作),通常是针对基于 JWT(JSON Web Token)的身份验证流程。

实现步骤

  1. 设置刷新 token 机制

    • 短生命周期的访问 token:访问 token 应该有较短的有效期,以确保较高的安全性。
    • 长生命周期的刷新 token:刷新 token 有较长的有效期,用于在访问 token 过期后获取新的访问 token
  2. 拦截请求

    • 使用拦截器(如 Axios 拦截器)来监控所有的 HTTP 请求。每次请求时,检查 token 是否存在且有效。
    • 在拦截器中处理 401 Unauthorized 响应,这是 token 过期的常见标志。
  3. 尝试获取新 token

    • 当收到 401 响应时,拦截器会自动发起刷新 token 的请求。使用保存的刷新 token 通过专门的 API 来获取新的访问 token
    • 如果刷新成功,更新存储中的 token(如 localStoragesessionStorage),并重试原来的请求。
  4. 无感刷新 token 并重发请求

    • 如果刷新 token 成功,则将新的访问 token 附加到原始请求的头部,然后重新发起该请求。用户在这个过程中不会感知到 token 的刷新和请求的重试。
  5. 处理刷新失败

    • 如果刷新 token 失败(如刷新 token 也过期了),则通常需要让用户重新登录。此时可以重定向到登录页面或显示提示信息。

示例代码(使用 Axios 拦截器)

import axios from 'axios';

const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  headers: {
    'Content-Type': 'application/json',
  },
});

// 请求拦截器,添加 token
apiClient.interceptors.request.use(config => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;
}, error => {
  return Promise.reject(error);
});

// 响应拦截器,处理 401 错误并尝试刷新 token
apiClient.interceptors.response.use(response => {
  return response;
}, async error => {
  const originalRequest = error.config;
  if (error.response.status === 401 && !originalRequest._retry) {
    originalRequest._retry = true;
    try {
      const refreshToken = localStorage.getItem('refreshToken');
      const response = await axios.post('https://api.example.com/refresh-token', {
        refreshToken: refreshToken,
      });
      const newToken = response.data.accessToken;
      localStorage.setItem('accessToken', newToken);
      originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
      return apiClient(originalRequest); // 重试原来的请求
    } catch (refreshError) {
      // 刷新 token 失败,可能需要重新登录
      console.log('Refresh token failed:', refreshError);
      // 可以在这里重定向到登录页面
    }
  }
  return Promise.reject(error);
});

export default apiClient;

8. 如果发现JWT Token泄露,如何快速响应?

题库原题:如果发现JWT Token泄露,如何快速响应?

题目要点

  1. 定位:确认泄露的是单个用户 Token 还是全局密钥。
  2. 拉黑:立即将泄露的 Token jti 加入 Redis 禁用名单。
  3. 阻断:如果是密钥泄露,立即更新环境变量中的 JWT_SECRET
  4. 通知:提醒受影响用户,并建议其修改密码(以防攻击者通过其他渠道获取了账号控制权)。
  5. 审计:检查该 Token 泄露期间产生的异常操作日志,进行回滚或补偿。
参考答案

由于 JWT 是无状态的(服务器默认不存储 Token,无法像 Session 那样轻易在服务端作废),快速响应的核心思路在于阻断该 Token 的有效性强制变更鉴权环境

以下是针对不同场景的快速响应方案:

1. 服务端拦截:引入“黑名单”机制(最快响应)

这是最直接的补救措施。虽然 JWT 旨在去中心化校验,但在紧急情况下,必须引入中心化检查。

  • 操作:将泄露的 Token 唯一标识(通常是 jti 载荷)或整个 Token 字符串存入 Redis 等极速缓存中,并设置过期时间(等于该 Token 的剩余有效期)。
  • 逻辑更新:修改后端的鉴权中间件,在校验 JWT 签名通过后,额外多一步检查:“该 Token 是否在 Redis 黑名单中?”。如果在,则拒绝请求。
  • 优点:秒级生效,精准拦截。

2. 变更签名密钥(最彻底但代价大)

如果怀疑是大规模泄露,或者加密私钥(Secret Key)本身已暴露:

  • 操作:立即在服务端更换用于签署 JWT 的 Secret Key
  • 后果:由于密钥变了,之前所有基于旧密钥生成的 Token(包括合法用户的)都会瞬间失效。
  • 适用场景:密钥泄露或遭受系统性攻击。虽然这会导致全服用户强制下线(需重新登录),但在极端安全风险下是必要的“熔断”。

3. 强制用户重新登录:变更 User Secret

如果你的 JWT 签名逻辑中引入了用户特有的变量(例如:HMAC256(payload, base_secret + user_password_hash)):

  • 操作:在数据库中重置该用户的某个关键字段(如随机生成的 token_salt 或直接让其修改密码)。
  • 原理:后端在校验时,由于该用户的 user_password_hashsalt 变了,旧 Token 算出的签名将无法匹配,从而实现单用户精准作废。

4. 移动端/客户端响应:主动清除

  • 操作:通过通知推送(Push Notification)或特定的指令,强制客户端执行注销逻辑,清除本地存储的 localStorageCookie 中的 Token。
  • 局限性:这只能防君子不防小人。如果攻击者已经拿到了 Token 并通过 Postman 等工具调用接口,客户端的清除动作毫无意义,必须配合上述服务端拦截。

5. 缩短过期时间与 Refresh Token 策略(长效机制)

如果泄露已经发生,而你的 Token 有效期设为了 7 天,那么损失是巨大的。

  • 响应举措:立即调低新生成 Token 的 exp(过期时间),例如缩短至 15 分钟。
  • 利用 Refresh Token:如果系统使用了双 Token 机制,立即在数据库中禁用对应的 Refresh Token,这样攻击者手中的 Access Token 一旦过期,将无法续期。

9. JWT包含哪些部分?分别起什么作用?

题目要点

Header, Payload, Signature, 令牌类型, 签名算法, 声明信息, 消息完整性

参考答案

JWT包含三部分:Header、Payload和Signature。Header用于描述令牌的类型和签名算法;Payload包含声明信息;Signature用于验证消息的完整性。

10. Vue-Router的Hash模式与History模式

题库原题:vue路由中,history和hash两种模式有什么区别?

题目要点

前端路由有 hash 模式和 history 模式两种。

hash 模式

  • 实现方式:通过在 URL 后面添加井号 # 加上路径来切换页面,例如 http://example.com/#/a
  • 优缺点
    • 优点:兼容性好,支持老版本的浏览器。
    • 缺点:URL 中存在井号 #,不够美观。

history 模式

  • 实现方式:使用 HTML5 的 History API 直接更改 URL,例如 http://example.com/a
  • 优缺点
    • 优点:URL 更美观,没有井号 #
    • 缺点:兼容性不如 hash 模式,且需要服务端支持,否则刷新页面会返回 404。
参考答案

前端路由有两种模式:hash 模式和 history 模式,接下来分析这两种模式的实现方式和优缺点。

hash 模式

hash 模式是一种把前端路由的路径用井号 # 拼接在真实 URL 后面的模式。当井号 # 后面的路径发生变化时,浏览器并不会重新发起请求,而是会触发 hashchange 事件。

示例

我们新建一个 hash.html 文件,内容为:

<a href="#/a">A页面</a>
<a href="#/b">B页面</a>
<div id="app"></div>
<script>
  function render() {
    app.innerHTML = window.location.hash
  }
  window.addEventListener('hashchange', render)
  render()
</script>

在上面的例子中,我们利用 a 标签设置了两个路由导航,把 app 当做视图渲染容器,当切换路由的时候触发视图容器的更新,这其实就是大多数前端框架哈希路由的实现原理。

总结一下 hash 模式的优缺点:

  • 优点:浏览器兼容性较好,连 IE8 都支持
  • 缺点:路径在井号 # 的后面,比较丑

history 模式

history API 是 H5 提供的新特性,允许开发者直接更改前端路由,即更新浏览器 URL 地址而不重新发起请求。

示例

我们新建一个 history.html,内容为:

<a href="javascript:toA();">A页面</a>
<a href="javascript:toB();">B页面</a>
<div id="app"></div>
<script>
  function render() {
    app.innerHTML = window.location.pathname
  }
  function toA() {
    history.pushState({}, null, '/a')
    render()
  }
  function toB() {
    history.pushState({}, null, '/b')
    render()
  }
  window.addEventListener('popstate', render)
</script>

history API 提供了丰富的函数供开发者调用,我们不妨把控制台打开,然后输入下面的语句来观察浏览器地址栏的变化:

history.replaceState({}, null, '/b') // 替换路由
history.pushState({}, null, '/a') // 路由压栈
history.back() // 返回
history.forward() // 前进
history.go(-2) // 后退2次

上面的代码监听了 popstate 事件,该事件能监听到:

  • 用户点击浏览器的前进和后退操作
  • 手动调用 history 的 backforwardgo 方法

监听不到:

  • history 的 pushStatereplaceState方法

这也是为什么上面的 toAtoB 函数内部需要手动调用 render 方法的原因。另外,大家可能也注意到 light-server 的命令多了 --historyindex '/history.html' 参数,这是干什么的呢?

浏览器在刷新的时候,会按照路径发送真实的资源请求,如果这个路径是前端通过 history API 设置的 URL,那么在服务端往往不存在这个资源,于是就返回 404 了。上面的参数的意思就是如果后端资源不存在就返回 history.html 的内容。

因此在线上部署基于 history API 的单页面应用的时候,一定要后端配合支持才行,否则会出现大量的 404。以最常用的 Nginx 为例,只需要在配置的 location / 中增加下面一行即可:

try_files $uri /index.html;

总结一下 history 模式的优缺点:

  • 优点:路径比较正规,没有井号 #
  • 缺点:兼容性不如 hash,且需要服务端支持,否则一刷新页面就404了

本答案由“前端面试题宝典”收集整理,PC端访问请前往: https://fe.ecool.fun/

11. Hash模式如何影响SEO?如何通过预渲染优化?

题库原题:Hash模式如何影响SEO?如何通过预渲染优化?

题目要点

  • Hash 痛点:由于 URL 锚点不发往后端,导致爬虫难以获取 JS 动态生成的页面内容。
  • 预渲染定义:在构建期将 SPA “快照化”为静态 HTML 的技术。
  • 性能加持:既解决了 SEO 抓取问题,又大幅优化了首屏加载性能。
  • 选型逻辑:内容更新不频繁的项目首选预渲染,实时性要求高的项目选 SSR。
参考答案

在单页面应用(SPA)的开发中,路由模式的选择不仅关乎 URL 的美观度,更直接决定了搜索引擎优化(SEO)的成败。

1. Hash 模式对 SEO 的影响

Hash 模式(即 URL 中带有 #)对 SEO 的影响主要是负面的,原因如下:

  • 搜索引擎的“视而不见”:根据 HTTP 规范,# 及其后面的内容被称为“片段标识符”(Fragment Identifier),它是用来指导浏览器行为的,永远不会被发送到服务器
  • 内容无法索引:传统的爬虫在访问 example.com/#/about 时,只会看到 example.com/ 的内容。对于爬虫来说,无论你的 Hash 路径如何变化,它们看到的都是同一个空壳 HTML。
  • 无法通过标准方式处理:虽然 Google 曾提出过 #!(Shebang)方案来尝试抓取 Hash 内容,但该方案早在 2015 年就已被废弃。目前主流的 SEO 建议是全面转向 History 模式

2. 预渲染(Prerendering)的原理

为了解决 SPA 这种“先有壳、后有内容”带来的 SEO 困境,预渲染成为了一种性价比极高的平衡方案。

预渲染的工作流程: 在项目打包构建阶段(Build Time),预渲染工具(如 prerender-spa-plugin)会在后台启动一个无头浏览器(Headless Browser,如 Puppeteer),模拟用户访问各个路由路径,并将渲染生成的完整 DOM 结构直接导出为静态的 .html 文件。


3. 如何通过预渲染优化 SEO?

通过预渲染,你可以让 Hash 模式(或 History 模式)的项目在爬虫面前呈现为“纯静态网站”。

A. 配置步骤

  1. 确定路由列表:指定哪些页面需要 SEO(通常是首页、产品页、文章页)。
  2. 构建拦截:在 Webpack 或 Vite 插件中配置预渲染插件。
  3. 生成静态页:打包后,你的 dist 目录会生成对应的文件夹结构,例如 /about/index.html

B. 核心优势

  • 快如闪电的首屏:用户访问时直接下载包含内容的 HTML,无需等待 JS 解析和 API 请求,极大地提升了 FCP(首次内容绘制)
  • 爬虫可见性:当爬虫抓取 example.com/about 时,它获取到的是已经填充好文本和 Meta 标签的 HTML,完美解决索引问题。

C. 与服务端渲染(SSR)的区别

特性预渲染 (Prerendering)服务端渲染 (SSR)
执行时机构建项目时(一次性)用户请求时(实时)
服务器压力极低(纯静态文件托管)较高(需运行 Node.js 渲染逻辑)
动态性低(内容更新需重新打包)高(内容实时更新)
适用场景静态营销页、文档、博客电商、社交媒体、高频更新平台

4. 工程实践建议

如果你由于某些限制(如不支持服务器端重写)必须使用 Hash 模式 却又想做 SEO:

  1. 伪装路径:通过预渲染工具,将 /#/about 对应的静态内容生成在 /about/index.html
  2. TDK 动态注入:在预渲染时,确保每个页面的 titledescriptionkeywords 已经根据数据正确注入。
  3. 混合模式:对于纯动态内容,依然使用异步加载;对于需要被索引的文本,在构建阶段完成预渲染。

12. 如何处理404页面?动态路由如何与服务端配合?

题目要点

  • 前端层面:在路由配置末尾放置捕获逻辑,展示友好的 UI。
  • 服务端层面:配置 try_files 或类似的 Fallback 机制,确保动态路径刷新不丢失。
  • 体验层面:区分“路径不存在”和“数据不存在”两种情况,提供不同的引导。
参考答案

1. 前端如何处理 404 页面?

在 Vue Router 或 React Router 中,404 通常通过**通配符路由(Catch-all Route)**来实现。

  • 实现逻辑:路由匹配遵循“从上到下”的原则。我们将一个路径为 *(Vue 2)或 /:pathMatch(.*)*(Vue 3)的路由放在映射表的最后一位
  • 交互细节:当之前的业务路径(如 /home, /about)都没有命中时,系统会回退到这个兜底路由,渲染指定的 NotFound.vue 组件。

2. 动态路由如何与服务端配合?

动态路由(如 /user/:id)的本质是 URL 模式匹配。在生产环境下,为了让这些路径在刷新后依然可用,前后端必须达成以下默契:

A. 核心问题:刷新即 404

在单页面应用(SPA)中,/user/123 这个路径在服务器上并不真实存在对应的文件夹或文件。

  • 如果用户从首页点击跳转,是 JS 控制的,没问题。
  • 如果用户在 /user/123 手动刷新浏览器,浏览器会向服务器请求该文件。服务器找不到 user/123.html,就会返回原生的 404。

B. 解决方案:服务端重定向(Fallback)

服务端需要配置:“对于所有找不到静态资源的请求,全部统一返回 index.html。”

  • Nginx 配置示例
location / {
    try_files $uri $uri/ /index.html;
}
  • 逻辑链路
  1. 用户刷新 /user/123
  2. Nginx 发现本地没这个文件,于是返回 index.html
  3. 浏览器加载 index.html 里的 JS 脚本(即你的路由配置)。
  4. 前端路由启动,发现当前的 URL 是 /user/123,匹配到对应的动态组件并渲染。

3. 高级进阶:真假 404 的区分

当引入了服务端重定向后,所有的路径都会返回 index.html(状态码 200)。这会产生一个 SEO 问题:真正的非法路径也返回了 200。

为了更专业地处理,建议采取以下策略:

  1. 前端判断:如果路由匹配到了通配符 NotFound 组件,说明这是一个非法路径。
  2. API 联动:如果是动态路由(如 /user/999),但后端返回该用户 ID 不存在,前端应主动调用路由方法跳转到 404 页面。
  3. 服务端渲染(SSR)配合:在 Nuxt.js 或 Next.js 中,服务端可以直接在渲染时发现 404,并在响应头中返回真正的 404 状态码,这对搜索引擎非常友好。

13. 在History模式下直接刷新带有路径的页面会发生什么?如何解决?

题目要点

History模式, 刷新页面, 服务器请求, 404错误, 服务器重定向, 服务端渲染

参考答案

在History模式下刷新带有路径的页面会导致浏览器向服务器发送请求,如果没有相应的后端路由配置,会返回404错误。可以通过配置服务器重定向或使用服务端渲染来解决。

14. 讲一下从代码编译到生产环境上线的完整流程。

题目要点

代码编写, 版本控制, 代码审查, 构建打包, 自动化测试, 测试环境, 回归测试, 生产环境, 监控, 维护

参考答案

完整的流程包括代码编写、版本控制、代码审查、构建打包、自动化测试、部署到测试环境、回归测试、部署到生产环境、监控和维护。重点在于每个步骤的细节和工具的使用。

15. Vite与Webpack

题库原题:聊聊 vite 和 webpack 的区别

题目要点

  • Webpack:成熟的模块打包工具,功能强大但配置复杂,适合需要高度定制和复杂构建需求的项目。
  • Vite:现代化的开发工具,提供快速的开发体验和优化的生产构建,适合追求开发效率和现代化特性的项目。

选择 Vite 还是 Webpack 取决于项目的需求和开发团队的偏好。如果重点是开发体验和快速反馈,Vite 是一个很好的选择。如果需要高度定制化和广泛的插件支持,Webpack 可能更适合。

参考答案

Vite 和 Webpack 都是前端打包工具,它们的作用类似,但实现方式和使用方法有所不同。以下是它们之间的一些区别:

  1. 构建速度:Vite 的构建速度比 Webpack 更快,因为 Vite 在开发环境下使用了浏览器原生的 ES 模块加载,而不是像 Webpack 一样使用打包后的文件进行模块加载。在 Vite 中,每个模块都可以独立地进行编译和缓存,这意味着它只需要重新编译修改过的模块,而不是整个应用程序。这使得 Vite 开发起来更加高效。

  2. 配置复杂度:Vite 的配置相对更简单,因为它无需进行大量的配置,只需指定一些基本的选项就可以开始开发。Webpack 的配置更加复杂,需要针对具体项目进行不同的配置,且需要理解各种插件、Loader 等概念。

  3. 生态环境:Webpack 的生态环境更加成熟,在社区中拥有广泛的支持和丰富的插件库。而 Vite 尚处于发展阶段,尽管其已经获得了很多关注,但其生态系统仍然不太完善。

  4. 功能特性:Webpack 是一个功能更加全面的打包工具,支持各种 Loader 和插件,可以处理多种类型的文件和资源。而 Vite 的设计初衷是专注于开发环境下的快速构建,因此其对一些高级特性的支持相对较少。

综上所述,Vite 更适合用于开发环境下的快速构建,而 Webpack 则更适合用于生产环境下的复杂应用程序的打包处理。选择使用哪种工具需要根据具体项目需求进行评估。

16. Vite的按需编译原理是什么?如何通过预构建优化性能?

题库原题:说下Vite的原理

题目要点

Vite 是一个基于 ESbuild 和 Rollup 的新一代前端构建工具,它旨在提供极致的开发体验。Vite 的工作原理包括依赖预构建、按需加载和文件系统缓存等,这些特性使得 Vite 在开发环境中能够快速启动和响应。Vite 的主要特点包括:

  1. 快速启动:Vite 利用浏览器原生的 ES Module 解析能力,直接提供开发环境源码,无需等待整个应用的构建。
  2. 按需编译:Vite 只在浏览器请求相关模块时进行编译,从而实现真正的按需加载。
  3. 依赖预构建:Vite 使用 esbuild 对项目依赖进行预构建,以提高编译速度。
  4. 缓存优化:Vite 利用 HTTP 缓存和文件系统缓存来优化性能。
  5. 生产环境集成:Vite 集成 Rollup 进行生产环境打包,提供成熟的插件机制。
  6. 高度集成:Vite 提供了开箱即用的配置,简化了开发流程。
  7. 支持多种框架:Vite 不仅支持 Vue,也支持 React 等其他框架。
  8. 内置 SSR 支持:Vite 内置了服务端渲染支持。
  9. TypeScript 支持:Vite 原生支持 TypeScript。

Vite 与 Webpack 的主要区别在于 Vite 更注重开发环境的性能,而 Webpack 则提供了更丰富的配置和更灵活的构建流程。尽管 Vite 在生产环境中使用 Rollup 打包,但它的开发环境体验已经足够强大,适合大多数前端开发需求。

参考答案

背景

这里的背景介绍会从与Vite紧密相关的两个概念的发展史说起,一个是JavaScript的模块化标准,另一个是前端构建工具。

共存的模块化标准

为什么JavaScript会有多种共存的模块化标准?因为js在设计之初并没有模块化的概念,随着前端业务复杂度不断提高,模块化越来越受到开发者的重视,社区开始涌现多种模块化解决方案,它们相互借鉴,也争议不断,形成多个派系,从CommonJS开始,到ES6正式推出ES Modules规范结束,所有争论,终成历史,ES Modules也成为前端重要的基础设施。

  • CommonJS:现主要用于Node.js(Node@13.2.0开始支持直接使用ES Module)
  • AMDrequire.js 依赖前置,市场存量不建议使用
  • CMDsea.js 就近执行,市场存量不建议使用
  • ES Module:ES语言规范,标准,趋势,未来

对模块化发展史感兴趣的可以看下《前端模块化开发那点历史》@玉伯,而Vite的核心正是依靠浏览器对ES Module规范的实现。

发展中的构建工具

近些年前端工程化发展迅速,各种构建工具层出不穷,目前Webpack仍然占据统治地位,npm 每周下载量达到两千多万次。下面是我按 npm 发版时间线列出的开发者比较熟知的一些构建工具。

当前工程化痛点

现在常用的构建工具如Webpack,主要是通过抓取-编译-构建整个应用的代码(也就是常说的打包过程),生成一份编译、优化后能良好兼容各个浏览器的的生产环境代码。在开发环境流程也基本相同,需要先将整个应用构建打包后,再把打包后的代码交给dev server(开发服务器)。

Webpack等构建工具的诞生给前端开发带来了极大的便利,但随着前端业务的复杂化,js代码量呈指数增长,打包构建时间越来越久,dev server(开发服务器)性能遇到瓶颈:

  • 缓慢的服务启动: 大型项目中dev server启动时间达到几十秒甚至几分钟。

  • 缓慢的HMR热更新: 即使采用了 HMR 模式,其热更新速度也会随着应用规模的增长而显著下降,已达到性能瓶颈,无多少优化空间。

缓慢的开发环境,大大降低了开发者的幸福感,在以上背景下Vite应运而生。


什么是Vite?

基于esbuild与Rollup,依靠浏览器自身ESM编译功能, 实现极致开发体验的新一代构建工具!

概念

先介绍以下文中会经常提到的一些基础概念:

  • 依赖: 指开发不会变动的部分(npm包、UI组件库),esbuild进行预构建。
  • 源码: 浏览器不能直接执行的非js代码(.jsx、.css、.vue等),vite只在浏览器请求相关源码的时候进行转换,以提供ESM源码。

开发环境

  • 利用浏览器原生的ES Module编译能力,省略费时的编译环节,直给浏览器开发环境源码,dev server只提供轻量服务。
  • 浏览器执行ESM的import时,会向dev server发起该模块的ajax请求,服务器对源码做简单处理后返回给浏览器。
  • Vite中HMR是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块失活,使得无论应用大小如何,HMR 始终能保持快速更新。
  • 使用esbuild处理项目依赖,esbuild使用go编写,比一般node.js编写的编译器快几个数量级。

生产环境

  • 集成Rollup打包生产环境代码,依赖其成熟稳定的生态与更简洁的插件机制。

处理流程对比

Webpack通过先将整个应用打包,再将打包后代码提供给dev server,开发者才能开始开发。

Vite直接将源码交给浏览器,实现dev server秒开,浏览器显示页面需要相关模块时,再向dev server发起请求,服务器简单处理后,将该模块返回给浏览器,实现真正意义的按需加载。


基本用法

创建vite项目

$ npm create vite@latest

选取模板

Vite 内置6种常用模板与对应的TS版本,可满足前端大部分开发场景,可以点击下列表格中模板直接在 StackBlitz 中在线试用,还有其他更多的 社区维护模板可以使用。

JavaScriptTypeScript
vanillavanilla-ts
vuevue-ts
reactreact-ts
preactpreact-ts
litlit-ts
sveltesvelte-ts

启动

{
  "scripts": {
    "dev": "vite", // 启动开发服务器,别名:`vite dev`,`vite serve`
    "build": "vite build", // 为生产环境构建产物
    "preview": "vite preview" // 本地预览生产构建产物
  }
}

实现原理

ESbuild 编译

esbuild 使用go编写,cpu密集下更具性能优势,编译速度更快,以下摘自官网的构建速度对比:
浏览器:“开始了吗?”
服务器:“已经结束了。”
开发者:“好快,好喜欢!!”

image.png

依赖预构建

  • 模块化兼容: 如开头背景所写,现仍共存多种模块化标准代码,Vite在预构建阶段将依赖中各种其他模块化规范(CommonJS、UMD)转换 成ESM,以提供给浏览器。
  • 性能优化: npm包中大量的ESM代码,大量的import请求,会造成网络拥塞。Vite使用esbuild,将有大量内部模块的ESM关系转换成单个模块,以减少 import模块请求次数。

按需加载

  • 服务器只在接受到import请求的时候,才会编译对应的文件,将ESM源码返回给浏览器,实现真正的按需加载。

缓存

  • HTTP缓存: 充分利用http缓存做优化,依赖(不会变动的代码)部分用max-age,immutable 强缓存,源码部分用304协商缓存,提升页面打开速度。
  • 文件系统缓存: Vite在预构建阶段,将构建后的依赖缓存到node_modules/.vite ,相关配置更改时,或手动控制时才会重新构建,以提升预构建速度。

重写模块路径

浏览器import只能引入相对/绝对路径,而开发代码经常使用npm包名直接引入node_module中的模块,需要做路径转换后交给浏览器。

  • es-module-lexer 扫描 import 语法
  • magic-string 重写模块的引入路径
// 开发代码
import { createApp } from 'vue'

// 转换后
import { createApp } from '/node_modules/vue/dist/vue.js'

源码分析

Webpack-dev-server类似Vite同样使用WebSocket与客户端建立连接,实现热更新,源码实现基本可分为两部分,源码位置在:

  • vite/packages/vite/src/client client(用于客户端)
  • vite/packages/vite/src/node server(用于开发服务器)

client 代码会在启动服务时注入到客户端,用于客户端对于WebSocket消息的处理(如更新页面某个模块、刷新页面);server 代码是服务端逻辑,用于处理代码的构建与页面模块的请求。

简单看了下源码(vite@2.7.2),核心功能主要是以下几个方法(以下为源码截取,部分逻辑做了删减):

  1. 命令行启动服务npm run dev后,源码执行cli.ts,调用createServer方法,创建http服务,监听开发服务器端口。
// 源码位置 vite/packages/vite/src/node/cli.ts
const { createServer } = await import('./server')
try {
    const server = await createServer({
        root,
        base: options.base,
        ...
    })
    if (!server.httpServer) {
        throw new Error('HTTP server not available')
    }
    await server.listen()
}
  1. createServer方法的执行做了很多工作,如整合配置项、创建http服务(早期通过koa创建)、创建WebSocket服务、创建源码的文件监听、插件执行、optimize优化等。下面注释中标出。
// 源码位置 vite/packages/vite/src/node/server/index.ts
export async function createServer(
    inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
    // Vite 配置整合
    const config = await resolveConfig(inlineConfig, 'serve', 'development')
    const root = config.root
    const serverConfig = config.server

    // 创建http服务
    const httpServer = await resolveHttpServer(serverConfig, middlewares, httpsOptions)

    // 创建ws服务
    const ws = createWebSocketServer(httpServer, config, httpsOptions)

    // 创建watcher,设置代码文件监听
    const watcher = chokidar.watch(path.resolve(root), {
        ignored: [
            '**/node_modules/**',
            '**/.git/**',
            ...(Array.isArray(ignored) ? ignored : [ignored])
        ],
        ...watchOptions
    }) as FSWatcher

    // 创建server对象
    const server: ViteDevServer = {
        config,
        middlewares,
        httpServer,
        watcher,
        ws,
        moduleGraph,
        listen,
        ...
    }

    // 文件监听变动,websocket向前端通信
    watcher.on('change', async (file) => {
        ...
        handleHMRUpdate()
    })

    // 非常多的 middleware
    middlewares.use(...)

    // optimize
    const runOptimize = async () => {...}

    return server
}
  1. 使用chokidar监听文件变化,绑定监听事件。
// 源码位置 vite/packages/vite/src/node/server/index.ts
  const watcher = chokidar.watch(path.resolve(root), {
    ignored: [
      '**/node_modules/**',
      '**/.git/**',
      ...(Array.isArray(ignored) ? ignored : [ignored])
    ],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
    ...watchOptions
  }) as FSWatcher
  1. 通过 ws 来创建WebSocket服务,用于监听到文件变化时触发热更新,向客户端发送消息。
// 源码位置 vite/packages/vite/src/node/server/ws.ts
export function createWebSocketServer(...){
    let wss: WebSocket
    const hmr = isObject(config.server.hmr) && config.server.hmr
    const wsServer = (hmr && hmr.server) || server

    if (wsServer) {
        wss = new WebSocket({ noServer: true })
        wsServer.on('upgrade', (req, socket, head) => {
            // 服务就绪
            if (req.headers['sec-websocket-protocol'] === HMR_HEADER) {
                wss.handleUpgrade(req, socket as Socket, head, (ws) => {
                    wss.emit('connection', ws, req)
                })
            }
        })
    } else {
        ...
    }
    // 服务准备就绪,就能在浏览器控制台看到熟悉的打印 [vite] connected.
    wss.on('connection', (socket) => {
        socket.send(JSON.stringify({ type: 'connected' }))
        ...
    })
    // 失败
    wss.on('error', (e: Error & { code: string }) => {
        ...
    })
    // 返回ws对象
    return {
        on: wss.on.bind(wss),
        off: wss.off.bind(wss),
        // 向客户端发送信息
        // 多个客户端同时触发
        send(payload: HMRPayload) {
            const stringified = JSON.stringify(payload)
            wss.clients.forEach((client) => {
                // readyState 1 means the connection is open
                client.send(stringified)
            })
        }
    }
}
  1. 在服务启动时会向浏览器注入代码,用于处理客户端接收到的WebSocket消息,如重新发起模块请求、刷新页面。
//源码位置 vite/packages/vite/src/client/client.ts
async function handleMessage(payload: HMRPayload) {
  switch (payload.type) {
    case 'connected':
      console.log(`[vite] connected.`)
      break
    case 'update':
      notifyListeners('vite:beforeUpdate', payload)
      ...
      break
    case 'custom': {
      notifyListeners(payload.event as CustomEventName<any>, payload.data)
      ...
      break
    }
    case 'full-reload':
      notifyListeners('vite:beforeFullReload', payload)
      ...
      break
    case 'prune':
      notifyListeners('vite:beforePrune', payload)
      ...
      break
    case 'error': {
      notifyListeners('vite:error', payload)
      ...
      break
    }
    default: {
      const check: never = payload
      return check
    }
  }
}

优势

  • 快!快!非常快!!
  • 高度集成,开箱即用。
  • 基于ESM急速热更新,无需打包编译。
  • 基于esbuild的依赖预处理,比Webpack等node编写的编译器快几个数量级。
  • 兼容Rollup庞大的插件机制,插件开发更简洁。
  • 不与Vue绑定,支持React等其他框架,独立的构建工具。
  • 内置SSR支持。
  • 天然支持TS。

不足

  • Vue仍为第一优先支持,量身定做的编译插件,对React的支持不如Vue强大。
  • 虽然已经推出2.0正式版,已经可以用于正式线上生产,但目前市场上实践少。
  • 生产环境集成Rollup打包,与开发环境最终执行的代码不一致。

与 webpack 对比

由于Vite主打的是开发环境的极致体验,生产环境集成Rollup,这里的对比主要是Webpack-dev-serverVite-dev-server的对比:

  • 到目前很长时间以来Webpack在前端工程领域占统治地位,Vite推出以来备受关注,社区活跃,GitHub star 数量激增,目前达到37.4K image.png
  • Webpack配置丰富使用极为灵活但上手成本高,Vite开箱即用配置高度集成
  • Webpack启动服务需打包构建,速度慢,Vite免编译可秒开
  • Webpack热更新需打包构建,速度慢,Vite毫秒响应
  • Webpack成熟稳定、资源丰富、大量实践案例,Vite实践较少
  • Vite使用esbuild编译,构建速度比webpack快几个数量级

兼容性

  • 默认目标浏览器是在script标签上支持原生 ESM 和 原生 ESM 动态导入
  • 可使用官方插件 @vitejs/plugin-legacy,转义成传统版本和相对应的polyfill

未来探索

  • 传统构建工具性能已到瓶颈,主打开发体验的Vite,可能会受到欢迎。
  • 主流浏览器基本支持ESM,ESM将成为主流。
  • ViteVue3.0代替vue-cli,作为官方脚手架,会大大提高使用量。
  • Vite2.0推出后,已可以在实际项目中使用Vite
  • 如果觉得直接使用Vite太冒险,又确实有dev server速度慢的问题需要解决,可以尝试用Vite单独搭建一套dev server

相关资源

官方插件

除了支持现有的Rollup插件系统外,官方提供了四个最关键的插件

  • @vitejs/plugin-vue 提供 Vue3 单文件组件支持
  • @vitejs/plugin-vue-jsx 提供 Vue3 JSX 支持(专用的 Babel 转换插件)
  • @vitejs/plugin-react 提供完整的 React 支持
  • @vitejs/plugin-legacy 为打包后的文件提供传统浏览器兼容性支持

17. Webpack的Tree Shaking与Vite的Dead Code Elimination有何异同?

题库原题:Webpack的Tree Shaking与Vite的Dead Code Elimination有何异同?

题目要点

  • Tree Shaking 是从外部看:这个模块提供的“果实”没人要,那就摇下来。
  • DCE 是从内部看:这行代码执行了也没意义(或者跑不到),那就擦掉。

Vite (Rollup) 的优势在于其预设的 Tree Shaking 算法更符合现代 JS 特性,生成的产物通常更干净;而 Webpack 则提供了更丰富的插件和配置来精细控制这个过程。

参考答案

在现代前端构建工具中,Tree Shaking(摇树优化)和 Dead Code Elimination(DCE,死代码消除)虽然最终目标都是为了减小产物体积,但它们的实现路径和侧重点有所不同。

简单来说:Tree Shaking 是基于模块关系的“导出过滤”,而 DCE 是基于代码执行逻辑的“无效擦除”。


1. Webpack 的 Tree Shaking

Webpack 的 Tree Shaking 深度依赖于 ES 模块(ESM) 的静态结构。

  • 核心原理:Webpack 在构建过程中,会通过静态分析识别出哪些模块导出了但没有被 import。它会在生成的 bundle 中标记这些未使用的导出,最后由压缩工具(如 Terser 或 ESBuild)进行删除。
  • 关键依赖
  • 静态分析:必须使用 import/export,不能使用 CommonJS 的 require(因为 require 是动态的)。
  • Side Effects(副作用):这是 Webpack 的痛点。如果一个模块仅仅被引入但未被使用,但它修改了全局变量,Webpack 默认不敢删。开发者需要在 package.json 中配置 "sideEffects": false 来告知 Webpack 放心摇树。

2. Vite 的优化策略(Rollup 驱动)

Vite 在生产环境构建时使用的是 Rollup。Rollup 的 Tree Shaking 被公认为比 Webpack 更为激进和精准。

  • 更细粒度的分析:Rollup 不仅关注模块间的导出关系,还会分析代码内部的属性访问。如果一个对象被导出,但它的某个属性从未被访问过,Rollup 往往能更智能地将其剥离。
  • Vite 的 DCE:在开发阶段,Vite 几乎不做 Tree Shaking(为了极致的 HMR 速度)。在生产阶段,Vite 结合了 Rollup 的 Tree Shaking 和 ESBuild 的压缩能力。
  • Dead Code Elimination (DCE) 的内涵:DCE 的范畴更广。例如,代码中有一段 if (false) { ... },这不属于模块导出问题,而是逻辑死路。Vite/Rollup 能够识别并直接删除这些永远不会执行的语句。

3. 两者的异同对比

特性Webpack (Tree Shaking)Vite/Rollup (DCE + Tree Shaking)
底层依据主要依赖 ESM 的静态导入导出关系。基于更深层的 AST(抽象语法树)分析。
副作用处理较为保守,高度依赖 sideEffects 配置。默认分析更精准,能更好地识别纯函数调用。
压缩阶段配合标记未使用代码,由 Terser 执行最终删除。在打包过程中就尽量减少无用代码进入 bundle。
处理广度侧重于“模块级别”的剔除。既有模块剔除,也有强力的代码块逻辑消除。

4. 为什么 DCE 有时会“失效”?

无论是 Webpack 还是 Vite,最怕的就是副作用(Side Effects)

  • 场景:如果你在代码中写了 const a = window.someInjectedFunc(),构建工具无法确定删除 a 是否会破坏程序的全局环境,因此即使 a 没被使用,它也会被保留。
  • 解决方案:使用 /*#__PURE__*/ 注释。这会显式告诉构建工具:这个函数调用是没有副作用的,如果结果没被用到,请直接删掉它。

18. let/const/var的区别

题库原题:var、let、const之间有什么区别?

题目要点

可以从以下要点来阐述:

1. 声明提升(Hoisting)

  • var:变量声明会被提升到作用域的顶部,但赋值不会。即在变量声明之前可以访问到变量,但值为 undefined
  • letconst:声明不会被提升。变量在声明之前不可访问(会导致“暂时性死区”,Temporal Dead Zone)。

2. 作用域(Scope)

  • var:具有函数作用域(function scope),即仅在函数内有效,或者在全局作用域中。
  • letconst:具有块级作用域(block scope),即在 {} 代码块内有效。

3. 变量重声明(Re-declaration)

  • var:可以在相同作用域内多次声明同名变量,不会报错。
  • letconst:在相同作用域内不能重复声明同名变量,会报错。

4. 变量赋值(Re-assignment)

  • varlet:允许重新赋值,即可以改变变量的值。
  • const:声明的变量不能被重新赋值,但需要注意,const 对象的内容(如数组或对象的属性)是可以修改的。

5. 初始化

  • var:可以在声明时不初始化,默认为 undefined
  • letconst:必须在声明时初始化,const 不能不初始化。

6. 全局对象属性(Global Object Property)

  • var:在全局作用域中声明的变量会成为全局对象的属性(在浏览器中是 window 对象)。
  • letconst:在全局作用域中声明的变量不会成为全局对象的属性。
参考答案

一、var

在ES5中,顶层对象的属性和全局变量是等价的,用var声明的变量既是全局变量,也是顶层变量

注意:顶层对象,在浏览器环境指的是window对象,在 Node 指的是global对象

var a = 10;
console.log(window.a) // 10

使用var声明的变量存在变量提升的情况

console.log(a) // undefined
var a = 20

在编译阶段,编译器会将其变成以下执行

var a
console.log(a)
a = 20

使用var,我们能够对一个变量进行多次声明,后面声明的变量会覆盖前面的变量声明

var a = 20
var a = 30
console.log(a) // 30

在函数中使用使用var声明变量时候,该变量是局部的

var a = 20
function change(){
    var a = 30
}
change()
console.log(a) // 20

而如果在函数内不使用var,该变量是全局的

var a = 20
function change(){
   a = 30
}
change()
console.log(a) // 30

二、let

letES6新增的命令,用来声明变量

用法类似于var,但是所声明的变量,只在let命令所在的代码块内有效

{
    let a = 20
}
console.log(a) // ReferenceError: a is not defined.

不存在变量提升

console.log(a) // 报错ReferenceError
let a = 2

这表示在声明它之前,变量a是不存在的,这时如果用到它,就会抛出一个错误

只要块级作用域内存在let命令,这个区域就不再受外部影响

var a = 123
if (true) {
    a = 'abc' // ReferenceError
    let a;
}

使用let声明变量前,该变量都不可用,也就是大家常说的“暂时性死区”

最后,let不允许在相同作用域中重复声明

let a = 20
let a = 30
// Uncaught SyntaxError: Identifier 'a' has already been declared

注意的是相同作用域,下面这种情况是不会报错的

let a = 20
{
    let a = 30
}

因此,我们不能在函数内部重新声明参数

function func(arg) {
  let arg;
}
func()
// Uncaught SyntaxError: Identifier 'arg' has already been declared

三、const

const声明一个只读的常量,一旦声明,常量的值就不能改变

const a = 1
a = 3
// TypeError: Assignment to constant variable.

这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值

const a;
// SyntaxError: Missing initializer in const declaration

如果之前用varlet声明过变量,再用const声明同样会报错

var a = 20
let b = 20
const a = 30
const b = 30
// 都会报错

const实际上保证的并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动

对于简单类型的数据,值就保存在变量指向的那个内存地址,因此等同于常量

对于复杂类型的数据,变量指向的内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的,并不能确保改变量的结构不变

const foo = {};

// 为 foo 添加一个属性,可以成功
foo.prop = 123;
foo.prop // 123

// 将 foo 指向另一个对象,就会报错
foo = {}; // TypeError: "foo" is read-only

其它情况,constlet一致

四、区别

varletconst三者区别可以围绕下面五点展开:

  • 变量提升
  • 暂时性死区
  • 块级作用域
  • 重复声明
  • 修改声明的变量
  • 使用

变量提升

var 声明的变量存在变量提升,即变量可以在声明之前调用,值为undefined

// 2023.4.25 更新

letconst不存在变量提升,即它们所声明的变量一定要在声明后使用,否则报错

let / const 不存在变量提升是不完全正确的,只能说由于暂时性死区的存在使得我们无法直观感受到变量提升的效果。

let 和 const 定义的变量都会被提升,但是不会被初始化,不能被引用,不会像var定义的变量那样,初始值为undefined。

当进入let变量的作用域时,会立即给它创建存储空间,但是不会对它进行初始化。

变量的赋值可以分为三个阶段:

  • 创建变量,在内存中开辟空间
  • 初始化变量,将变量初始化为undefined
  • 真正赋值

关于let、var和const:

  • let 的「创建」过程被提升了,但是初始化没有提升。
  • var 的「创建」和「初始化」都被提升了。
  • const 的「创建」「初始化」和「赋值」都被提升了。
// var
console.log(a)  // undefined
var a = 10

// let
console.log(b)  // Cannot access 'b' before initialization
let b = 10

// const
console.log(c)  // Cannot access 'c' before initialization
const c = 10

暂时性死区

var不存在暂时性死区

letconst存在暂时性死区,只有等到声明变量的那一行代码出现,才可以获取和使用该变量

// var
console.log(a)  // undefined
var a = 10

// let
console.log(b)  // Cannot access 'b' before initialization
let b = 10

// const
console.log(c)  // Cannot access 'c' before initialization
const c = 10

块级作用域

var不存在块级作用域

letconst存在块级作用域

// var
{
    var a = 20
}
console.log(a)  // 20

// let
{
    let b = 20
}
console.log(b)  // Uncaught ReferenceError: b is not defined

// const
{
    const c = 20
}
console.log(c)  // Uncaught ReferenceError: c is not defined

重复声明

var允许重复声明变量

letconst在同一作用域不允许重复声明变量

// var
var a = 10
var a = 20 // 20

// let
let b = 10
let b = 20 // Identifier 'b' has already been declared

// const
const c = 10
const c = 20 // Identifier 'c' has already been declared

修改声明的变量

varlet可以

const声明一个只读的常量。一旦声明,常量的值就不能改变

// var
var a = 10
a = 20
console.log(a)  // 20

//let
let b = 10
b = 20
console.log(b)  // 20

// const
const c = 10
c = 20
console.log(c) // Uncaught TypeError: Assignment to constant variable

使用

能用const的情况尽量使用const,其他情况下大多数使用let,避免使用var

19. 在循环中使用letvar的区别?闭包问题如何解决?

题目要点

循环, var, let, 共享变量, 新变量, 闭包, IIFE, 立即执行函数表达式

参考答案

在循环中使用var会导致所有迭代共享同一个变量,而使用let每次迭代都会创建一个新的变量。闭包问题可以通过使用IIFE(立即执行函数表达式)来解决。

20. 如何保证项目在不同设备(如手机/平板)上的UI一致性?

题目要点

  • 布局逻辑:移动优先 + 媒体查询断点。
  • 缩放策略:使用 vwrem 替代固定 px
  • 内容伸缩:Flexbox 和 Grid 负责处理元素间的动态填充。
  • 未来趋势:引入容器查询实现像素级的组件自适应。
参考答案

在前端工程中,保证跨设备(手机/平板/折叠屏)UI一致性的核心不在于让所有元素“长得一模一样”,而在于实现 “比例协调、逻辑统一、体验自适应”

1. 响应式布局基础:多阶断点设计

不要试图为每一个机型写样式,而是基于屏幕宽度(Viewport Width)设定断点(Breakpoints)

  • 设计原则:采用 Mobile First(移动端优先)。先写小屏样式,再通过 @media 查询为平板和大屏进行增强。
  • 断点划分建议
  • < 768px:手机(垂直)。
  • 768px ~ 1024px:平板(垂直)或大屏手机。
  • > 1024px:平板(横屏)或桌面端。

2. 跨端单位方案:从 px 转向相对单位

固定像素(px)是跨端一致性的天敌。我们需要让元素随屏幕缩放。

  • 方案 A:Viewport 单位 (vw/vh): 将 UI 稿的宽度等分为 100 份。例如 750px 的设计稿中,一个 75px 的元素即为 10vw。这能保证元素在不同宽度的手机上保持绝对的比例一致。
  • 方案 B:Rem 适配(经典方案): 通过脚本(如 lib-flexible)动态修改根节点 <html>font-size。配合插件(如 postcss-pxtorem)自动转换,适合需要兼容较旧环境的项目。
  • 方案 C:Clamp 响应式函数: 现代 CSS 推荐使用 font-size: clamp(14px, 2vw, 20px);。它允许你设定最小值、首选值和最大值,防止文字在平板上过大或在小屏上缩写。

3. 弹性容器:Flexbox 与 Grid 的黄金组合

一致性不仅是尺寸,更是间距和对齐逻辑

  • Flex 布局:处理一维布局(导航栏、列表)。利用 justify-content: space-between 保证元素在不同宽度下自动均匀分布。
  • Grid 布局:处理二维网格(如卡片瀑布流)。通过 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));,可以让页面在手机上显示 1 列,在宽屏平板上自动变为 3 列,且保持卡片宽度合理。

4. 高级进阶:容器查询 (Container Queries)

这是 UI 一致性的最新范式。

  • 痛点:传统的媒体查询(Media Queries)只看屏幕有多宽。但同一个组件在手机全屏展示,和在平板的侧边栏展示,其可用空间可能是一样的。
  • 方案:使用 @container。它让组件根据其父容器的大小来决定显示样式。这才是真正的“组件化自适应”,保证了组件在任何布局环境下的一致性表现。

5. 跨设备体验的“隐形”细节

  • 图像适配:使用 <picture> 标签或 srcset。为高像素密度的设备(Retina 屏)提供 @2x/@3x 图片,防止 UI 模糊。
  • 点击区域:手机点击区域至少为 44x44px。在平板上,虽然屏幕大了,但手指操作的本质没变,间距需保持呼吸感。
  • 安全区域 (Safe Area):处理刘海屏和底部操作条。使用 padding-bottom: env(safe-area-inset-bottom); 确保 UI 不被遮挡。

21. 给定一个字符串 s ,请你找出其中不含有重复字符的 最长 子串 的长度。

题库原题:无重复字符的最长子串

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

  • 示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
  • 示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
  • 示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
  • 示例 4:
输入: s = ""
输出: 0
  • 提示:
    • 0 <= s.length <= 5 * 104
    • s 由英文字母、数字、符号和空格组成

题目要点

我们先用一个例子考虑如何在较优的时间复杂度内通过本题。

参考答案

滑动窗口

思路和算法

我们先用一个例子考虑如何在较优的时间复杂度内通过本题。

我们不妨以示例一中的字符串 abcabcbb 为例,找出从每一个字符开始的,不包含重复字符的最长子串,那么其中最长的那个字符串即为答案。对于示例一中的字符串,我们列举出这些结果,其中括号中表示选中的字符以及最长的字符串:

  • 以 (a)bcabcbb 开始的最长字符串为 (abc)abcbb;
  • 以 a(b)cabcbb 开始的最长字符串为 a(bca)bcbb;
  • 以 ab(c)abcbb 开始的最长字符串为 ab(cab)cbb;
  • 以 abc(a)bcbb 开始的最长字符串为 abc(abc)bb;
  • 以 abca(b)cbb 开始的最长字符串为 abca(bc)bb;
  • 以 abcab(c)bb 开始的最长字符串为 abcab(cb)b;
  • 以 abcabc(b)b 开始的最长字符串为 abcabc(b)b;
  • 以 abcabcb(b) 开始的最长字符串为 abcabcb(b)。

发现了什么?如果我们依次递增地枚举子串的起始位置,那么子串的结束位置也是递增的!这里的原因在于,假设我们选择字符串中的第 k 个字符作为起始位置,并且得到了不包含重复字符的最长子串的结束位置为 r(k) ,。那么当我们选择第 k+1 个字符作为起始位置时,首先从 k+1 到 r(k) 的字符显然是不重复的,并且由于少了原本的第 k 个字符,我们可以尝试继续增大 r(k),直到右侧出现了重复字符为止。

这样一来,我们就可以使用「滑动窗口」来解决这个问题了:

我们使用两个指针表示字符串中的某个子串(或窗口)的左右边界,其中左指针代表着上文中「枚举子串的起始位置」,而右指针即为上文中的 r(k);

在每一步的操作中,我们会将左指针向右移动一格,表示 我们开始枚举下一个字符作为起始位置,然后我们可以不断地向右移动右指针,但需要保证这两个指针对应的子串中没有重复的字符。在移动结束后,这个子串就对应着 以左指针开始的,不包含重复字符的最长子串。我们记录下这个子串的长度;

在枚举结束后,我们找到的最长的子串的长度即为答案。

var lengthOfLongestSubstring = function(s) {
    // 哈希集合,记录每个字符是否出现过
    const occ = new Set();
    const n = s.length;
    // 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动
    let rk = -1, ans = 0;
    for (let i = 0; i < n; ++i) {
        if (i != 0) {
            // 左指针向右移动一格,移除一个字符
            occ.delete(s.charAt(i - 1));
        }
        while (rk + 1 < n && !occ.has(s.charAt(rk + 1))) {
            // 不断地移动右指针
            occ.add(s.charAt(rk + 1));
            ++rk;
        }
        // 第 i 到 rk 个字符是一个极长的无重复字符子串
        ans = Math.max(ans, rk - i + 1);
    }
    return ans;
};

复杂度分析

时间复杂度:O(N),其中 N 是字符串的长度。左指针和右指针分别会遍历整个字符串一次。

空间复杂度:O(∣Σ∣),其中 Σ 表示字符集(即字符串中可以出现的字符),∣Σ∣ 表示字符集的大小。在本题中没有明确说明字符集,因此可以默认为所有 ASCII 码在 [0,128) 内的字符,即 ∣Σ∣=128。我们需要用到哈希集合来存储出现过的字符,而字符最多有 ∣Σ∣ 个,因此空间复杂度为 O(∣Σ∣)。


← 已是第一轮 · 返回本次面经 · 已是最后一轮 →