知乎前端面试题及参考答案
Webpack 和 Vite 的区别是什么?
- 构建原理:
- Webpack 是基于传统的打包方式,它会将所有的模块依赖进行分析,然后打包成一个或多个 bundle。在开发过程中,当代码发生变化时,需要重新构建整个项目,构建速度会随着项目规模的增大而变慢。
- Vite 利用了浏览器对 ES 模块的支持,在开发阶段直接以 ES 模块的形式提供代码,通过原生的 ES 模块导入方式来处理模块依赖,只有在生产环境才会进行打包优化等操作,大大提高了开发阶段的启动速度和热更新速度。
- 性能表现:
- Webpack 在处理大型项目时,由于需要处理大量的模块和进行复杂的优化操作,打包时间可能会较长。但在生产环境中,经过优化后,其打包后的代码性能表现良好。
- Vite 在开发环境下具有极快的冷启动速度和热更新速度,能够显著提升开发效率。不过在生产环境中,由于其优化策略相对较新,对于一些复杂项目的优化可能不如 Webpack 成熟。
- 适用场景:
- Webpack 适用于大型复杂的项目,尤其是需要对代码进行深度优化、有特定的打包需求(如多页面应用、对代码体积有严格要求等)的项目。它有丰富的插件和 Loader 生态,可以满足各种个性化的需求。
- Vite 更适合于现代前端开发,特别是以 Vue、React 等框架为主的单页面应用开发。对于追求快速开发体验、对开发环境的性能要求较高的项目,Vite 是一个很好的选择。
请详细阐述 Webpack 的打包流程。
- 初始化参数:从配置文件和 Shell 命令中读取与合并参数,得出最终的参数。这些参数包括入口文件、输出路径、模块规则、插件等信息。
- 创建 Compiler 对象:根据参数创建一个 Compiler 对象,它是 Webpack 的核心,负责整个打包过程的管理和调度。
- 构建 AST 语法树:从入口文件开始,使用 Loader 对模块进行加载和转换,将代码解析为 AST(抽象语法树),分析模块之间的依赖关系。例如,遇到 JavaScript 模块,会分析其中的 import 或 require 语句,找到所依赖的其他模块。
- 递归解析依赖:根据 AST 中分析出的依赖关系,递归地加载和解析所有依赖的模块,同样使用 Loader 进行处理,将它们都转换为浏览器可识别的代码。
- 生成 Chunk:将所有模块按照一定的规则(如根据入口文件、动态导入等)进行分组,形成一个个 Chunk。每个 Chunk 可以包含多个模块,这些模块最终会被打包到一个文件中。
- 生成 Assets:对每个 Chunk 进行优化,如压缩代码、去除重复代码、进行代码分割等操作,然后将优化后的 Chunk 转换为最终的 Asset,即生成的打包文件,通常是.js、.css 等文件。
- 输出文件:将生成的 Asset 按照配置的输出路径和文件名,写入到磁盘中,完成打包过程。在输出过程中,还可以通过插件来对文件进行进一步的处理,如添加版权声明、生成 HTML 文件等。
Webpack 升级优化具体有哪些要点?
- 优化 Loader 配置:
- 减少 Loader 的使用数量,只使用必要的 Loader,避免过多的 Loader 对性能的影响。例如,如果项目中不需要对图片进行特殊处理,就可以移除相关的图片处理 Loader。
- 对 Loader 进行缓存,使用 cache-loader 等工具,将 Loader 的处理结果缓存起来,下次构建时如果文件没有变化,就可以直接使用缓存,加快构建速度。
- 升级 Webpack 版本:
- 新版本的 Webpack 通常会有性能上的优化和新的特性。例如,Webpack 5 引入了更好的模块联邦机制、内置的持久化缓存等,能够提高构建效率和代码的可维护性。
- 及时跟进 Webpack 官方的更新文档,了解新版本的变化和优化点,合理地进行版本升级。
- 代码分割:
- 使用 SplitChunksPlugin 等工具,将代码按照路由、组件等进行分割,避免将所有代码打包到一个文件中,提高代码的加载性能。例如,将不同页面的代码分割成不同的 Chunk,用户访问某个页面时只需要加载对应的 Chunk。
- 对于第三方库,可以将其单独打包成一个 Chunk,利用浏览器的缓存机制,提高页面的加载速度。
- 优化插件配置:
- 合理使用插件,避免使用过多不必要的插件,减少插件对构建过程的影响。例如,对于一些只在开发环境需要的插件,在生产环境可以禁用。
- 选择性能更好的插件,如使用 TerserPlugin 进行 JavaScript 代码压缩,比一些传统的压缩插件性能更优。
- 优化构建目标:
- 根据项目的实际需求,合理设置构建目标,如针对不同的浏览器环境进行优化。如果项目主要面向现代浏览器,可以使用更高级的 JavaScript 特性,减少对旧浏览器的兼容处理,从而减小打包文件的体积。
- 对于一些不需要在构建时进行处理的代码,可以将其排除在构建之外,提高构建速度。例如,对于一些纯静态的资源文件,可以直接将其复制到输出目录,而不经过 Webpack 的处理。
Webpack SplitChunks 是什么,如何配置?
Webpack SplitChunks 是一个用于代码分割的插件,它可以将 Webpack 打包出来的代码,按照一定的规则分割成多个 Chunk,从而提高代码的加载性能和可维护性。
其配置方式如下:
module.exports = {optimization: {splitChunks: {chunks: 'async', // 可选值有'all'、'async'、'initial',表示对哪些类型的chunk进行分割,'async'表示只对异步加载的chunk进行分割minSize: 30000, // 模块最小大小,达到此大小才会被分割minChunks: 1, // 模块被引用的最小次数,达到此次数才会被分割maxAsyncRequests: 5, // 按需加载时最大的并行请求数maxInitialRequests: 3, // 入口点的最大并行请求数automaticNameDelimiter: '~', // 生成的chunk文件名的分隔符name: true, // 可以是布尔值或字符串,用于指定生成的chunk的名称cacheGroups: { // 缓存组,用于更精细地控制代码分割vendors: { // 自定义的缓存组名称,这里以vendors为例test: /[\\/]node_modules[\\/]/, // 匹配node_modules目录下的模块priority: -10 // 优先级,数值越大优先级越高},default: { // 默认的缓存组minChunks: 2, // 模块被引用的最小次数priority: -20, // 优先级reuseExistingChunk: true // 如果当前chunk包含已从主bundle中分割出的模块,则重用该模块,而不是生成新的模块}}}}
};
通过上述配置,可以根据项目的需求,将代码分割成不同的 Chunk,例如将第三方库分割到一个单独的 Chunk 中,将多次引用的公共模块提取出来等,从而提高代码的加载效率和缓存利用率。
从 Webpack 1 - 4 升级,为什么打包文件变小且速度更快?
- 模块解析优化:Webpack 2 及后续版本对模块解析算法进行了改进。例如,采用了更高效的依赖图构建方式,能够更准确地分析模块之间的依赖关系,避免了一些不必要的模块被打包进来,从而减小了打包文件的体积。同时,优化后的解析算法在处理大量模块时速度更快,提高了打包效率。
- Tree - shaking 优化:Webpack 2 开始正式支持 Tree - shaking,它可以在打包过程中分析代码中的导入和导出,去除那些没有被实际使用的代码。在 Webpack 1 中,没有很好的机制来处理未使用的代码,导致打包文件中包含了很多冗余代码。而 Webpack 4 进一步完善了 Tree - shaking 的功能,能够更智能地识别和剔除无用代码,使得打包文件体积显著减小。
- 代码压缩改进:Webpack 4 默认使用了更先进的代码压缩工具,如 TerserPlugin。相比 Webpack 1 中使用的一些传统压缩工具,TerserPlugin 能够更有效地压缩 JavaScript 代码,通过更精细的优化策略,如变量名缩短、死代码消除、代码结构优化等,在不影响代码功能的前提下,最大限度地减小了代码体积,同时压缩速度也有所提升。
- 多进程处理:Webpack 4 引入了多进程处理的能力,能够利用多核 CPU 的优势,将一些耗时的任务(如代码编译、压缩等)分配到多个进程中并行执行。与 Webpack 1 单进程处理相比,大大提高了打包速度,特别是在处理大型项目时,这种并行处理的优势更加明显。
- 缓存机制升级:Webpack 2 及以后版本改进了缓存机制,能够更好地缓存模块的编译结果和依赖关系。在后续的构建过程中,如果模块没有发生变化,就可以直接使用缓存,避免了重复的编译和处理,从而加快了打包速度。Webpack 4 进一步优化了缓存的管理和使用,使得缓存的命中率更高,效果更显著。
请列举你知道的 React Hooks
React Hooks 是 React 16.8 版本引入的新特性,它可以让开发者在不编写类的情况下使用状态和其他 React 特性。以下是一些常见的 React Hooks:
- ** useState**:用于在函数组件中添加状态。它接受一个初始状态值作为参数,并返回一个数组,其中第一个元素是当前状态值,第二个元素是用于更新状态的函数。例如,
const [count, setCount] = useState(0);
定义了一个名为count
的状态,初始值为0
,setCount
函数用于更新count
的值。 - ** useEffect**:用于处理副作用,比如数据获取、订阅事件、操作 DOM 等。它接受一个回调函数和一个依赖项数组作为参数。回调函数会在组件挂载、更新或卸载时执行,而依赖项数组则决定了回调函数的执行时机。如果依赖项数组为空,回调函数只会在组件挂载和卸载时执行一次;如果依赖项数组中的某个值发生变化,回调函数就会重新执行。
- ** useContext**:用于在组件之间共享数据,避免了通过 props 层层传递数据的繁琐。它接受一个
Context
对象作为参数,并返回该Context
的当前值。在使用useContext
的组件中,只要Context
的值发生变化,组件就会重新渲染。 - ** useReducer**:类似于
useState
,但更适合用于管理复杂的状态逻辑。它接受一个reducer
函数和一个初始状态作为参数,并返回一个数组,其中第一个元素是当前状态,第二个元素是用于触发状态更新的dispatch
函数。reducer
函数根据接收到的action
来更新状态。 - ** useCallback**:用于缓存函数,避免在组件重新渲染时不必要的函数重新创建。它接受一个回调函数和一个依赖项数组作为参数,返回一个 memoized 后的函数。只有当依赖项数组中的值发生变化时,才会重新创建函数。
- ** useMemo**:用于缓存计算结果,避免在组件重新渲染时进行不必要的计算。它接受一个计算函数和一个依赖项数组作为参数,返回计算函数的结果。只有当依赖项数组中的值发生变化时,才会重新计算结果。
useMemo 和 useCallback 的区别是什么
useMemo
和 useCallback
都是 React Hooks 中用于性能优化的工具,它们的主要区别在于作用和返回值。
- 作用:
useMemo
的主要作用是缓存计算结果,它会在依赖项不变的情况下,返回上一次计算的结果,避免重新计算。例如,当计算一个复杂的列表数据或进行昂贵的 DOM 操作时,使用useMemo
可以提高性能。useCallback
则主要用于缓存函数,确保在组件重新渲染时,只有依赖项发生变化,函数才会重新创建,否则会返回之前缓存的函数。这在将函数作为prop
传递给子组件,且子组件依赖于函数的引用不变时非常有用,可以避免子组件不必要的重新渲染。 - 返回值:
useMemo
返回的是通过传入的函数计算得到的结果。useCallback
返回的是一个 memoized 后的函数,即经过缓存处理的函数。
示例:
import React, { useMemo, useCallback } from 'react';const MyComponent = () => {const [count, setCount] = React.useState(0);// 使用useMemo计算双倍的值const doubleCount = useMemo(() => {console.log('计算双倍的值');return count * 2;}, [count]);// 使用useCallback缓存函数const handleClick = useCallback(() => {setCount(count + 1);}, [count]);return (<div><p>Count: {count}</p><p>Double Count: {doubleCount}</p><button onClick={handleClick}>增加计数</button></div>);
};export default MyComponent;
在上述代码中,useMemo
确保只有 count
变化时才重新计算 doubleCount
,useCallback
确保 handleClick
函数在 count
不变时不会重新创建。
useRef 有哪些作用
useRef
是 React 中的一个 Hook,它有以下几个主要作用:
- 引用 DOM 元素:可以使用
useRef
来获取对 DOM 元素的引用,从而直接操作 DOM。例如,获取输入框元素并设置焦点、滚动到某个特定的 DOM 元素等。通过ref
属性将useRef
创建的引用绑定到 DOM 元素上,然后就可以在组件中通过ref.current
访问该 DOM 元素。 - 保存可变值:
useRef
创建的引用是一个可变的对象,其.current
属性可以被修改,并且不会导致组件重新渲染。这使得它适合用于保存一些在组件生命周期内需要随时访问和修改的值,比如定时器的 ID、WebSocket 连接对象等。与useState
不同,useState
的更新会触发组件重新渲染,而useRef
的更新不会。 - 在函数组件中保存数据:类似于类组件中的实例属性,
useRef
可以在函数组件中保存一些数据,这些数据在组件的多次渲染之间保持不变。例如,可以使用useRef
来记录某个函数被调用的次数,或者保存上一次渲染时的某个状态值,以便在当前渲染中进行比较和处理。
示例:
import React, { useRef, useEffect } from 'react';const MyComponent = () => {const inputRef = useRef(null);useEffect(() => {// 组件挂载后,将焦点设置到输入框inputRef.current.focus();}, []);return (<div><input ref={inputRef} type="text" placeholder="请输入内容" /></div>);
};export default MyComponent;
在这个例子中,useRef
用于获取输入框的引用,然后在 useEffect
中通过 ref.current.focus()
将焦点设置到输入框上。
若想在父组件中执行子组件内部方法,但不知该方法名,如何实现
在 React 中,如果想在父组件中执行子组件内部方法且不知道方法名,可以通过以下几种方式实现:
- 使用 ref 和 forwardRef:可以在子组件中使用
React.forwardRef
来转发ref
,然后在父组件中通过ref
来访问子组件的实例,进而调用子组件的方法。首先,在子组件中定义forwardRef
,将ref
传递给子组件的render
方法中的元素。然后在父组件中,使用useRef
创建一个ref
,并将其传递给子组件。最后,通过ref.current
来调用子组件的方法。 - 通过状态管理:可以使用状态管理库如 Redux 或 React Context 来实现。将子组件的方法绑定到一个全局状态中,然后在父组件中通过触发状态更新来间接调用子组件的方法。在子组件中,将方法注册到状态管理库中,在父组件中,通过调用状态管理库提供的方法来触发子组件方法的执行。
- 事件机制:在子组件中通过
props
接收一个父组件传递的回调函数,当子组件内部的某个事件发生时,调用这个回调函数,并将相关信息传递给父组件。父组件可以根据接收到的信息来决定是否执行子组件的某个方法。虽然这种方式没有直接调用子组件的方法,但可以通过在回调函数中执行相应的逻辑来达到类似的效果。
示例:使用 ref
和 forwardRef
的方式如下:
import React, { useRef, forwardRef } from 'react';const ChildComponent = forwardRef((props, ref) => {const handleClick = () => {console.log('子组件方法被调用');};return (<div ref={ref}><button onClick={handleClick}>子组件按钮</button></div>);
});const ParentComponent = () => {const childRef = useRef(null);const handleParentClick = () => {// 调用子组件的方法childRef.current.handleClick();};return (<div><ChildComponent ref={childRef} /><button onClick={handleParentClick}>父组件按钮</button></div>);
};export default ParentComponent;
在这个例子中,通过 forwardRef
将 ref
转发到子组件,父组件通过 ref.current
调用了子组件的 handleClick
方法。
为什么不能在条件判断和循环中使用 React Hooks
React Hooks 必须在函数组件的顶层调用,不能在条件判断、循环或嵌套函数中使用,这是由 React 的渲染机制和 Hooks 的工作原理决定的。
- 保证 Hook 调用顺序的一致性:React 依靠 Hook 的调用顺序来正确地将状态和副作用与组件进行关联。当在条件判断或循环中使用 Hooks 时,可能会导致 Hook 的调用顺序在不同的渲染周期中发生变化。例如,在某个条件为真时调用了一个
useState
Hook,而在下次渲染时该条件为假,这个useState
Hook 就不会被调用,这会打乱 React 内部维护的 Hook 调用顺序,导致状态混乱和错误。 - 确保组件的确定性:React 需要组件在每次渲染时都具有相同的结构和行为,这样才能有效地进行更新和优化。如果在条件判断或循环中使用 Hooks,组件的结构可能会在不同的渲染之间发生变化,这会使 React 难以追踪和管理组件的状态和副作用,也会影响到 React 的性能优化策略,例如无法正确地进行
memoization
或shouldComponentUpdate
等优化。
为了确保 React Hooks 的正确使用和组件的稳定性、可预测性,必须遵循在函数组件顶层调用 Hooks 的规则。这样可以保证 React 能够正确地管理组件的状态和副作用,实现高效的渲染和更新。
父组件如何向子孙组件传值?
在 React 中,父组件向子孙组件传值有以下几种方式:
- 通过 props 层层传递:这是最基本的方式。父组件将数据作为 props 传递给子组件,子组件再将其传递给它的子组件,以此类推,直到传递到目标子孙组件。例如,在一个多层嵌套的组件结构中,顶层父组件
Parent
有一个状态data
,它可以将data
作为 props 传递给直接子组件Child
,如<Child data={data} />
。然后Child
组件又可以将接收到的data
继续传递给它的子组件GrandChild
,即<GrandChild data={data} />
。这种方式在组件嵌套较浅时比较适用,但如果嵌套层次过深,会导致代码繁琐,且任何中间组件都需要透传 props,不够灵活。 - 使用 React Context:Context 提供了一种在组件树中共享数据的方式,无需通过 props 层层传递。首先,创建一个 Context 对象,例如
const MyContext = React.createContext()
。在父组件中,通过MyContext.Provider
将数据包裹起来,如<MyContext.Provider value={data}>...</MyContext.Provider>
,这样在其内部的子孙组件都可以通过useContext
钩子来获取这个数据。子孙组件中可以这样使用:const data = useContext(MyContext)
。这种方式适合传递一些全局共享的数据,如用户信息、主题等,能避免 props 的层层传递。 - 借助状态管理库:如 Redux 或 MobX 等。将数据存储在全局的状态管理库中,父组件可以将数据更新到状态管理库中,子孙组件通过订阅状态管理库中的数据来获取最新值。以 Redux 为例,父组件通过
dispatch
一个 action 来更新 store 中的数据,子孙组件通过connect
函数或者useSelector
钩子来获取 store 中的数据。这种方式适用于大型应用中,对数据的管理和更新更加集中和可维护。
子孙组件中如何修改通过 useContext 获取到的值?
通常情况下,直接修改通过useContext
获取到的值是不被推荐的,因为这可能会导致数据的不可控和难以调试。更好的做法是通过上下文提供的更新函数来修改值。
首先,在创建 Context 时,应该同时提供一个用于更新数据的函数。例如:
const MyContext = React.createContext();function Parent() {const [data, setData] = React.useState(0);const updateData = (newValue) => {setData(newValue);};return (<MyContext.Provider value={{ data, updateData }}>{/* 子组件树 */}</MyContext.Provider>);
}
在子孙组件中,可以通过以下方式获取并调用更新函数来修改值:
function GrandChild() {const { data, updateData } = React.useContext(MyContext);const handleClick = () => {updateData(data + 1);};return (<div><p>Data: {data}</p><button onClick={handleClick}>Update Data</button></div>);
}
这样,通过在上下文提供的更新函数中进行数据更新,能够保证数据的更新是可预测和可控的,同时也遵循了 React 的单向数据流动原则。
context 封装的值改变时,会触发组件重新渲染吗?
当 context 封装的值改变时,会触发使用了该 context 的组件重新渲染。
React 的 Context 机制会在Provider
的值发生变化时,通知所有使用了该Context
的子孙组件进行更新。这是因为当Context
的值改变时,React 会认为Context
的引用发生了变化,从而触发依赖该Context
的组件的重新渲染。
例如,有一个ThemeContext
,当ThemeContext.Provider
的value
属性发生变化时,所有通过useContext(ThemeContext)
获取主题信息的组件都会重新渲染。这样可以确保组件能够及时获取到最新的上下文数据,并根据新的数据进行界面的更新。
然而,如果Context
的值是一个对象或数组等引用类型,并且在更新时没有创建新的引用,只是修改了内部的属性,那么可能不会触发组件的重新渲染。因为 React 默认是通过引用比较来判断Context
的值是否发生变化的。为了确保在这种情况下也能正确触发重新渲染,需要在更新Context
的值时,创建一个新的引用,比如通过展开运算符创建一个新的对象或数组。
若某个子孙组件中使用了 useEffect,其依赖项记录了某个 context,当 context 值改变,会触发 useEffect 内包裹的函数执行吗?会触发该组件重新渲染吗?
当子孙组件中useEffect
的依赖项记录了某个context
,并且context
值改变时,会触发useEffect
内包裹的函数执行,同时也会触发该组件重新渲染。
useEffect
会在组件挂载、更新以及卸载时执行,其中更新时的执行是根据依赖项来判断的。当依赖项中的context
值发生变化,React 会认为组件的依赖发生了改变,从而触发useEffect
中函数的重新执行。
而context
值的改变本身就会导致使用该context
的组件重新渲染,因为 React 的Context
机制会在Provider
的值变化时通知相关组件进行更新。所以在这种情况下,组件会先因为context
值的改变而重新渲染,然后由于useEffect
的依赖项变化,导致useEffect
内的函数被执行。
例如,一个组件通过useContext
获取了用户信息userInfo
,并在useEffect
中依赖了userInfo
,当userInfo
在context
中发生变化时,组件会重新渲染,并且useEffect
会根据新的userInfo
执行相应的副作用逻辑,比如重新获取用户相关的数据或者更新界面上与用户信息相关的部分。
React 生命周期中各个周期分别做什么?
React 的生命周期主要分为挂载、更新和卸载三个阶段,每个阶段又包含了不同的生命周期方法。
- 挂载阶段:
constructor
:用于初始化组件的状态和绑定方法。可以在构造函数中通过super(props)
传递props
,并初始化state
,如this.state = { count: 0 };
。getDerivedStateFromProps
:这是一个静态方法,在组件挂载和更新时都会被调用。它可以根据props
的变化来更新state
,返回一个对象来更新状态,若不需要更新则返回null
。render
:是组件的核心方法,用于描述组件的 UI 结构。它根据props
和state
返回一个 React 元素,不能在其中修改state
或执行副作用操作。componentDidMount
:在组件挂载到 DOM 后调用。通常用于执行一些需要访问 DOM 的操作,比如获取 DOM 元素的尺寸,或者发起网络请求获取数据等。
- 更新阶段:
shouldComponentUpdate
:用于判断组件是否需要更新。可以根据props
和state
的变化来决定是否重新渲染组件,返回true
表示需要更新,false
则表示不需要。默认情况下,只要props
或state
发生变化,组件就会更新。getDerivedStateFromProps
:如前所述,在更新阶段也会被调用,用于根据新的props
更新state
。render
:在更新阶段也会再次执行,根据最新的props
和state
重新生成 React 元素。getSnapshotBeforeUpdate
:在更新发生之前,在 DOM 更新之前调用。可以用于获取更新前的 DOM 状态,比如滚动位置等,返回的值会作为componentDidUpdate
的第三个参数。componentDidUpdate
:在组件更新后调用。可以在这个方法中根据更新前后的props
和state
的变化来执行一些操作,比如更新 DOM 的样式或者发送分析数据等。
- 卸载阶段:
componentWillUnmount
:在组件从 DOM 中卸载之前调用。用于清理一些副作用,比如取消定时器、清除事件监听器等,以避免内存泄漏。
React 组件间状态管理和通信方式有哪些,如何使用 context?
组件间状态管理和通信方式
- Props 传递:这是最基础的组件通信方式。父组件通过将数据作为 props 传递给子组件,子组件接收并使用这些数据。例如,父组件有一个状态
userName
,可以通过<ChildComponent userName={userName} />
传递给子组件。这种方式简单直接,但对于多层嵌套组件,会导致 props 层层传递,代码变得复杂。 - 回调函数:父组件可以将一个回调函数作为 props 传递给子组件,子组件在需要时调用这个回调函数并传递数据给父组件。比如,子组件中有一个按钮,点击按钮时调用父组件传递的回调函数并传递点击事件的相关信息。
- 事件总线(Event Bus):创建一个全局的事件总线对象,组件可以在这个对象上发布和订阅事件。一个组件发布事件时,其他订阅了该事件的组件会接收到通知并执行相应的操作。不过,这种方式会使组件之间的耦合度增加,不利于代码的维护。
- 状态管理库:如 Redux、MobX 等。Redux 采用单向数据流的设计,通过 actions、reducers 和 store 来管理应用的状态。组件可以通过连接到 store 来获取状态和触发 actions。MobX 则基于响应式编程的思想,通过可观察对象和反应式函数来管理状态。
- Context API:提供了一种在组件树中共享数据的方式,无需通过 props 层层传递。
如何使用 Context
- 创建 Context:使用
React.createContext()
创建一个 Context 对象。例如:
const MyContext = React.createContext();
- 提供 Context 值:在父组件中使用
MyContext.Provider
包裹子组件,并通过value
属性提供要共享的值。例如:
function ParentComponent() {const sharedData = { message: 'Hello from context' };return (<MyContext.Provider value={sharedData}><ChildComponent /></MyContext.Provider>);
}
- 消费 Context 值:在子孙组件中,可以使用
useContext
钩子或者MyContext.Consumer
来获取 Context 中的值。使用useContext
的示例如下:
function ChildComponent() {const contextData = useContext(MyContext);return <p>{contextData.message}</p>;
}
如何设计一个状态管理系统,例如左侧标签栏、右侧展示栏的场景?
需求分析
在左侧标签栏、右侧展示栏的场景中,需要管理的状态包括标签栏的选中状态、右侧展示栏的内容状态等。状态管理系统要确保当标签栏的选中项改变时,右侧展示栏能够正确显示相应的内容。
设计步骤
- 选择状态管理方案:可以选择使用 React 的 Context API 或者状态管理库(如 Redux)。如果项目规模较小,使用 Context API 即可;如果项目规模较大,建议使用 Redux 来进行更复杂的状态管理。
- 定义状态结构:确定需要管理的状态,例如标签栏的选项列表、当前选中的标签索引、右侧展示栏的内容数据等。以使用 Context API 为例,可以定义如下状态:
const initialState = {tabs: ['Tab 1', 'Tab 2', 'Tab 3'],selectedTabIndex: 0,tabContents: ['Content for Tab 1', 'Content for Tab 2', 'Content for Tab 3']
};
- 创建状态管理组件:使用
React.createContext()
创建 Context 对象,并创建一个状态管理组件来提供状态和更新状态的方法。例如:
const TabContext = React.createContext();function TabProvider({ children }) {const [state, setState] = React.useState(initialState);const handleTabChange = (index) => {setState((prevState) => ({...prevState,selectedTabIndex: index}));};return (<TabContext.Provider value={{ state, handleTabChange }}>{children}</TabContext.Provider>);
}
- 在组件中使用状态:在左侧标签栏组件中,通过
useContext
获取状态和更新方法,根据选中状态渲染标签,并在点击标签时调用更新方法。在右侧展示栏组件中,根据选中的标签索引显示相应的内容。例如:
function TabBar() {const { state, handleTabChange } = useContext(TabContext);return (<div>{state.tabs.map((tab, index) => (<buttonkey={index}onClick={() => handleTabChange(index)}className={index === state.selectedTabIndex ? 'active' : ''}>{tab}</button>))}</div>);
}function ContentDisplay() {const { state } = useContext(TabContext);return <p>{state.tabContents[state.selectedTabIndex]}</p>;
}
请对比 React 与 Vue 的差别
设计理念
- React:基于 JavaScript 为核心,强调声明式编程和组件化开发。它将 UI 看作是状态的函数,通过状态的变化来驱动 UI 的更新。React 使用虚拟 DOM 来提高渲染效率,开发者需要手动管理组件的状态和生命周期。
- Vue:更注重易用性和渐进式开发。它提供了更简洁的模板语法,对于初学者来说更容易上手。Vue 也使用虚拟 DOM,但在内部对状态的响应式处理做了更多的封装,开发者可以更方便地管理状态。
语法风格
- React:使用 JSX 语法,将 HTML 和 JavaScript 混合在一起,使得代码更加灵活,但也增加了一定的学习成本。例如:
function App() {return <h1>Hello, React!</h1>;
}
- Vue:使用模板语法,将 HTML、CSS 和 JavaScript 分离,代码结构更加清晰。例如:
<template><h1>Hello, Vue!</h1>
</template><script>
export default {name: 'App'
};
</script>
状态管理
- React:通常使用状态管理库(如 Redux、MobX)来管理复杂的状态。这些库需要开发者手动编写大量的代码来管理状态的更新和传递。
- Vue:自带了响应式系统,对于简单的状态管理可以直接使用
data
选项。对于复杂的状态管理,Vue 也有对应的状态管理库(如 Vuex),其使用方式相对简单,与 Vue 的集成更加紧密。
生态系统
- React:拥有庞大的生态系统,有丰富的第三方库和工具可供选择。但由于生态系统过于庞大,选择合适的库和工具可能会有一定的难度。
- Vue:生态系统相对较小,但也足够满足大多数项目的需求。Vue 的官方文档和社区支持都非常友好,对于初学者来说更容易找到相关的资源。
学习曲线
- React:学习曲线较陡,需要掌握 JSX、虚拟 DOM、状态管理等概念,对于初学者来说可能有一定的难度。
- Vue:学习曲线较平缓,其简洁的模板语法和响应式系统使得初学者能够更快地上手。
React 17 与 16 有哪些差别?
事件委托机制
- React 16:将事件委托到 document 上。这意味着在 React 应用中,所有的事件处理都会在 document 上进行统一处理。这种方式可能会导致一些与原生事件处理的冲突,并且在一些复杂的场景下,事件冒泡和捕获的行为可能会不符合预期。
- React 17:将事件委托到根 DOM 节点上。这样可以避免与原生事件处理的冲突,同时也使得 React 应用在与其他非 React 代码集成时更加方便。例如,在一个包含多个 React 应用的页面中,每个应用的事件处理可以独立进行,不会相互干扰。
事件传播和兼容性
- React 16:在处理事件传播时,可能会与原生事件的传播机制产生一些差异,导致开发者需要额外处理一些兼容性问题。
- React 17:对事件传播机制进行了优化,使其更符合原生事件的传播机制。这减少了开发者在处理事件时的兼容性问题,提高了代码的可维护性。
生命周期方法变更
- React 16:包含一些即将被废弃的生命周期方法,如
componentWillMount
、componentWillReceiveProps
和componentWillUpdate
。这些方法在异步渲染的情况下可能会导致一些问题。 - React 17:没有引入新的生命周期方法,但强调了使用新的生命周期方法(如
getDerivedStateFromProps
和getSnapshotBeforeUpdate
)来替代即将被废弃的方法。这有助于开发者编写更稳定、更易于维护的代码。
版本升级兼容性
- React 17:主要是为了简化升级过程,使得后续版本的升级更加容易。它没有引入重大的 API 变更,因此从 React 16 升级到 React 17 相对比较平滑,开发者只需要进行一些小的调整即可。
与其他库的集成
- React 17:在与其他库(如 React Native)集成时,表现更好。它提供了更好的兼容性和稳定性,减少了在集成过程中出现的问题。
React 的 SSR 如何处理?
什么是 SSR
SSR(Server-Side Rendering)即服务器端渲染,是指在服务器端将 React 组件渲染成 HTML 字符串,然后将其发送到客户端。这样可以提高页面的首屏加载速度,有利于搜索引擎优化(SEO)。
处理步骤
- 服务器端设置:首先需要搭建一个服务器,如使用 Express 或 Koa。在服务器端引入 React 和 ReactDOMServer 模块。例如,使用 Express 的示例如下:
const express = require('express');
const app = express();
const React = require('react');
const ReactDOMServer = require('react-dom/server');
- 创建 React 组件:编写要进行服务器端渲染的 React 组件。例如:
function App() {return <h1>Hello, SSR!</h1>;
}
- 服务器端渲染:在服务器的路由处理函数中,使用
ReactDOMServer.renderToString
或ReactDOMServer.renderToStaticMarkup
方法将 React 组件渲染成 HTML 字符串。例如:
app.get('/', (req, res) => {const html = ReactDOMServer.renderToString(<App />);const page = `<!DOCTYPE html><html><head><title>React SSR</title></head><body><div id="root">${html}</div><script src="client.js"></script></body></html>`;res.send(page);
});
- 客户端激活:在客户端,需要使用
ReactDOM.hydrate
方法将服务器端渲染的 HTML 与 React 组件进行激活,使其具有交互性。例如:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';ReactDOM.hydrate(<App />, document.getElementById('root'));
- 打包和部署:使用 Webpack 等工具对客户端代码进行打包,然后将服务器端代码和客户端代码部署到服务器上。
注意事项
- 数据获取:在服务器端和客户端都需要处理数据获取的问题。可以使用一些库(如
react-query
)来统一处理数据获取,确保在服务器端和客户端都能正确获取数据。 - 状态管理:状态管理库(如 Redux)在 SSR 中需要进行特殊处理,确保服务器端和客户端的状态一致。
- 样式处理:需要确保服务器端和客户端的样式处理一致,可以使用 CSS-in-JS 方案或其他样式处理工具。
HTTP 301 和 302 状态码的区别是什么,触发 301 时,浏览器会把更新的 URL 存放在何处?
HTTP 301 和 302 状态码都用于重定向,但它们之间存在明显区别。
301 状态码表示永久重定向。当服务器返回 301 状态码时,意味着请求的资源已经永久移动到了新的 URL。搜索引擎会将旧的 URL 权重转移到新的 URL,并且在后续的搜索结果中,会直接显示新的 URL。对于浏览器而言,它会记住这个重定向信息,下次再访问旧的 URL 时,会直接请求新的 URL,而不再向旧的 URL 发送请求。这有助于提高网站的访问效率,同时也方便用户访问到最新的资源。
302 状态码表示临时重定向。这表明请求的资源只是暂时移动到了新的 URL。搜索引擎不会将旧的 URL 权重转移到新的 URL,并且在搜索结果中仍然会显示旧的 URL。浏览器在接收到 302 重定向后,下次访问旧的 URL 时,还是会先向旧的 URL 发送请求,然后根据服务器的响应再决定是否重定向到新的 URL。
当触发 301 时,浏览器会把更新的 URL 存放在缓存中。具体来说,不同的浏览器可能会有不同的存储方式,但一般都会在其内部的缓存数据库中记录这个重定向信息。当再次访问旧的 URL 时,浏览器会首先检查缓存,如果发现有对应的 301 重定向记录,就会直接使用新的 URL 发起请求,而无需再与服务器进行交互。这种缓存机制可以提高用户的访问速度,减少不必要的网络请求。
请介绍浏览器缓存机制
浏览器缓存机制是一种重要的性能优化手段,它可以减少对服务器的请求,提高页面的加载速度。浏览器缓存主要分为强缓存和协商缓存。
强缓存是指浏览器直接从本地缓存中读取资源,而无需向服务器发送请求。它通过响应头中的 Expires
和 Cache-Control
字段来控制。Expires
是一个具体的时间戳,表示资源的过期时间。但由于它使用的是服务器的时间,可能会存在时间不一致的问题,因此现在更多地使用 Cache-Control
。Cache-Control
是一个更灵活的指令,可以设置多个值,如 max-age
表示资源的有效时间(以秒为单位),no-cache
表示需要进行协商缓存,no-store
表示不使用缓存。
协商缓存是指浏览器在使用缓存之前,会先向服务器发送一个请求,询问服务器该资源是否有更新。如果服务器返回 304 状态码,表示资源没有更新,浏览器可以使用本地缓存;如果服务器返回新的资源,浏览器则会更新本地缓存。协商缓存通过 ETag
和 Last-Modified
字段来实现。ETag
是资源的唯一标识符,服务器会在响应头中返回该资源的 ETag
值。浏览器在下次请求时,会在请求头中带上 If-None-Match
字段,其值为之前获取的 ETag
。服务器会比较这个 ETag
值,如果相同则返回 304 状态码。Last-Modified
表示资源的最后修改时间,服务器会在响应头中返回该值。浏览器在下次请求时,会在请求头中带上 If-Modified-Since
字段,其值为之前获取的 Last-Modified
。服务器会比较这个时间,如果资源没有修改,则返回 304 状态码。
浏览器缓存机制的工作流程是:首先检查强缓存,如果强缓存有效,则直接使用本地缓存;如果强缓存失效,则进行协商缓存,向服务器发送请求,根据服务器的响应决定是否使用本地缓存或更新缓存。
如何解决跨域问题
跨域是指浏览器从一个域名的网页去请求另一个域名的资源时,由于浏览器的同源策略,会受到限制。同源策略要求协议、域名和端口都相同,否则就会产生跨域问题。以下是几种常见的解决跨域问题的方法。
JSONP(JSON with Padding)是一种古老的跨域解决方案。它的原理是利用 <script>
标签的 src
属性不受同源策略限制的特点。服务器返回的数据会被包裹在一个回调函数中,客户端通过 <script>
标签请求服务器数据,并在页面中定义好这个回调函数。当服务器返回数据时,会自动执行这个回调函数,从而获取到服务器的数据。但 JSONP 只支持 GET 请求,且安全性较低。
CORS(Cross-Origin Resource Sharing)是现代浏览器支持的跨域解决方案。它是一种跨域资源共享机制,通过在服务器端设置响应头来允许跨域请求。服务器需要在响应头中添加 Access-Control-Allow-Origin
字段,指定允许访问的域名。如果需要允许携带凭证(如 cookies),还需要添加 Access-Control-Allow-Credentials
字段,并设置为 true
。CORS 支持所有的 HTTP 请求方法,是目前比较推荐的跨域解决方案。
代理服务器是一种在服务器端进行跨域请求的方法。客户端将请求发送到同源的代理服务器,代理服务器再将请求转发到目标服务器,并将目标服务器的响应返回给客户端。这样,客户端和代理服务器是同源的,不会受到跨域限制。常见的代理服务器有 Nginx、Apache 等。
使用 WebSocket 协议也可以解决跨域问题。WebSocket 协议不受同源策略的限制,它是一种双向通信协议,可以在浏览器和服务器之间建立实时连接。通过 WebSocket,客户端和服务器可以直接进行数据交互,而无需考虑跨域问题。
什么是 304 协商缓存?
304 协商缓存是浏览器缓存机制中的一种重要方式,它用于在强缓存失效的情况下,判断本地缓存的资源是否仍然可用。
当浏览器第一次请求资源时,服务器会在响应头中返回该资源的一些信息,如 ETag
和 Last-Modified
。ETag
是资源的唯一标识符,服务器会根据资源的内容生成一个哈希值作为 ETag
。Last-Modified
表示资源的最后修改时间。浏览器会将这些信息和资源一起缓存到本地。
当浏览器再次请求该资源时,会先检查强缓存是否有效。如果强缓存失效,浏览器会向服务器发送一个请求,并在请求头中带上 If-None-Match
和 If-Modified-Since
字段。If-None-Match
的值为之前获取的 ETag
,If-Modified-Since
的值为之前获取的 Last-Modified
。
服务器接收到请求后,会比较 If-None-Match
和当前资源的 ETag
,以及 If-Modified-Since
和当前资源的最后修改时间。如果 ETag
相同且资源的最后修改时间没有变化,服务器会返回 304 状态码,表示资源没有更新,浏览器可以使用本地缓存。如果 ETag
不同或资源的最后修改时间有变化,服务器会返回新的资源和 200 状态码。
304 协商缓存的优点是可以减少服务器的响应数据量,提高网站的性能。同时,它也可以避免不必要的资源下载,节省用户的流量。通过使用协商缓存,浏览器可以在保证资源更新的前提下,尽可能地利用本地缓存,提高页面的加载速度。
主要的 HTTP 状态码有哪些?
HTTP 状态码是服务器返回给客户端的三位数字代码,用于表示请求的结果。以下是一些主要的 HTTP 状态码。
1xx 状态码表示信息性状态码,主要用于协议的交互过程中,目前这类状态码使用较少。例如,100 Continue 表示客户端可以继续发送请求。
2xx 状态码表示成功状态码。其中,200 OK 是最常见的状态码,表示请求成功,服务器已经处理了请求并返回了相应的资源。201 Created 表示请求已经成功,并创建了新的资源。204 No Content 表示请求成功,但没有返回任何内容。
3xx 状态码表示重定向状态码。如前面提到的 301 Moved Permanently 表示永久重定向,302 Found 表示临时重定向。304 Not Modified 表示协商缓存命中,资源没有更新。
4xx 状态码表示客户端错误状态码。400 Bad Request 表示客户端发送的请求有语法错误,不能被服务器所识别。401 Unauthorized 表示请求需要进行身份验证,客户端没有提供有效的身份凭证。403 Forbidden 表示服务器理解请求客户端的请求,但是拒绝执行此请求。404 Not Found 表示请求的资源不存在。
5xx 状态码表示服务器错误状态码。500 Internal Server Error 表示服务器内部发生错误,无法完成请求。502 Bad Gateway 表示作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应。503 Service Unavailable 表示服务器暂时无法处理请求,通常是由于服务器过载或维护中。
这些状态码可以帮助开发者和用户了解请求的处理结果,从而进行相应的处理。不同的状态码代表了不同的情况,开发者可以根据状态码来调试和优化应用程序。
HTTP Options 请求的作用是什么?
HTTP OPTIONS 请求方法用于获取服务器针对特定资源所支持的请求方法,或者服务器的性能信息。它在现代 Web 开发中有着重要的用途。
在 CORS(跨域资源共享)机制里,OPTIONS 请求充当了预检请求的角色。当浏览器发起一个跨域的非简单请求(例如包含自定义请求头、使用 PUT、DELETE 等非 GET 和 POST 的请求方法)时,浏览器会先发送一个 OPTIONS 请求到服务器。这个预检请求的目的是询问服务器是否允许该跨域请求。服务器会返回一系列响应头,告知浏览器支持的请求方法、允许的请求头以及是否允许携带凭证等信息。例如,服务器可能会返回 Access-Control-Allow-Methods
头,列出它支持的请求方法;返回 Access-Control-Allow-Headers
头,表明允许的请求头。浏览器根据这些响应信息,决定是否继续发送实际的请求。
OPTIONS 请求还能用于获取服务器的性能信息。客户端可以通过发送 OPTIONS 请求,查看服务器针对特定资源所支持的请求方法。比如,当客户端想要知道一个 API 接口支持哪些操作时,就可以发送 OPTIONS 请求。服务器会返回 Allow
头,其中包含了该资源支持的所有请求方法,像 GET、POST、PUT、DELETE 等。客户端依据这些信息,能正确地与服务器进行交互。
此外,OPTIONS 请求在调试和测试过程中也非常有用。开发者可以利用它来快速检查服务器的配置和权限设置。通过分析 OPTIONS 请求的响应,开发者能够发现服务器端可能存在的问题,例如 CORS 配置错误、请求方法不支持等。
JS 基本数据类型有哪些?
JavaScript 拥有七种基本数据类型,分别是 undefined
、null
、boolean
、number
、string
、symbol
和 bigint
。
undefined
类型仅有一个值,即 undefined
。当变量被声明但未赋值,或者函数没有返回值时,其值就为 undefined
。例如:
let a;
console.log(a); // 输出 undefined
null
同样只有一个值,即 null
。它表示一个空对象指针,通常用于手动将变量赋值为空。比如:
let b = null;
console.log(b); // 输出 null
boolean
类型有两个值,true
和 false
,常用于条件判断。例如:
let isDone = true;
if (isDone) {console.log('任务已完成');
}
number
类型用于表示整数和浮点数。JavaScript 中的 number
采用 IEEE 754 双精度 64 位浮点数格式。它可以表示各种数值,包括正数、负数、零、小数等。例如:
let num1 = 10;
let num2 = 3.14;
string
类型用于表示文本数据,由零个或多个 16 位 Unicode 字符组成。可以使用单引号、双引号或反引号来定义字符串。例如:
let str1 = 'Hello';
let str2 = "World";
let str3 = `Hello, ${str2}`;
symbol
是 ES6 引入的一种新的数据类型,它表示独一无二的值。可以使用 Symbol()
函数来创建 symbol
。例如:
let sym = Symbol('description');
bigint
是 ES2020 引入的新类型,用于表示任意大的整数。可以在整数后面加上 n
或者使用 BigInt()
函数来创建 bigint
。例如:
let bigNum1 = 123456789012345678901234567890n;
let bigNum2 = BigInt('123456789012345678901234567890');
symbol 的使用场景有哪些?
symbol
作为 ES6 引入的新数据类型,在 JavaScript 中有诸多实用的使用场景。
用于创建对象的私有属性和方法是 symbol
的一个重要应用。在 JavaScript 里,对象的属性名通常是字符串,这就导致属性名容易冲突。而 symbol
是独一无二的,使用 symbol
作为对象的属性名可以避免属性名冲突,从而实现私有属性和方法。例如:
const privateMethod = Symbol('privateMethod');
const myObject = {[privateMethod]: function() {console.log('这是一个私有方法');},publicMethod: function() {this[privateMethod]();}
};
myObject.publicMethod(); // 可以调用私有方法
console.log(myObject.privateMethod); // 输出 undefined,无法直接访问
symbol
还能用于定义常量。在项目中,常量名可能会重复,使用 symbol
可以确保常量的唯一性。例如:
const COLOR_RED = Symbol('red');
const COLOR_GREEN = Symbol('green');
function getColorName(color) {switch (color) {case COLOR_RED:return '红色';case COLOR_GREEN:return '绿色';default:return '未知颜色';}
}
在使用 Symbol.iterator
实现迭代器时,symbol
也发挥着重要作用。ES6 引入了迭代器协议,对象可以通过实现 Symbol.iterator
方法来成为可迭代对象。例如:
const myArray = [1, 2, 3];
const iterator = myArray[Symbol.iterator]();
console.log(iterator.next()); // 输出 { value: 1, done: false }
另外,symbol
可用于模拟枚举。在 JavaScript 中没有内置的枚举类型,但可以使用 symbol
来模拟。例如:
const Direction = {UP: Symbol('up'),DOWN: Symbol('down'),LEFT: Symbol('left'),RIGHT: Symbol('right')
};
function move(direction) {switch (direction) {case Direction.UP:console.log('向上移动');break;case Direction.DOWN:console.log('向下移动');break;case Direction.LEFT:console.log('向左移动');break;case Direction.RIGHT:console.log('向右移动');break;}
}
move(Direction.UP);
ES6 中 const、var、let 的区别是什么?
在 ES6 里,const
、var
和 let
都用于声明变量,但它们之间存在显著的区别。
从作用域方面来看,var
具有函数作用域。也就是说,在函数内部使用 var
声明的变量,在整个函数内部都可以访问。例如:
function testVar() {if (true) {var x = 10;}console.log(x); // 输出 10
}
而 let
和 const
具有块级作用域,块级作用域指的是由 {}
包裹的代码块。在块级作用域内使用 let
或 const
声明的变量,只能在该块级作用域内访问。例如:
function testLet() {if (true) {let y = 20;const z = 30;}console.log(y); // 报错,y 未定义console.log(z); // 报错,z 未定义
}
关于变量提升,var
存在变量提升的现象。这意味着在变量声明之前,变量已经存在于当前作用域中,只是值为 undefined
。例如:
console.log(a); // 输出 undefined
var a = 10;
然而,let
和 const
不存在变量提升。在变量声明之前访问它们,会产生 ReferenceError
。例如:
console.log(b); // 报错,b 未定义
let b = 20;
在变量的重新赋值方面,var
和 let
声明的变量可以被重新赋值。例如:
var c = 10;
c = 20;
let d = 30;
d = 40;
但 const
声明的常量一旦赋值,就不能再重新赋值。不过,如果 const
声明的是一个对象或数组,对象的属性或数组的元素是可以修改的。例如:
const obj = { name: '张三' };
obj.name = '李四'; // 合法
const a = {}; a.b = 'zhangsan'; 这句话是否合法,为什么?
这句话是合法的。
const
关键字用于声明常量,其特点是一旦赋值,就不能再重新赋值。不过,这里的不能重新赋值是指不能改变变量所指向的内存地址。当使用 const
声明一个对象 a
时,a
存储的是对象的引用,也就是对象在内存中的地址。
在代码 const a = {};
中,a
被赋值为一个空对象,它指向了这个空对象在内存中的地址。而 a.b = 'zhangsan';
这行代码,是在修改 a
所指向的对象的属性。它并没有改变 a
所指向的内存地址,只是在这个对象内部添加了一个新的属性 b
,并将其值设置为 'zhangsan'
。
例如,我们可以通过以下代码进一步验证:
const a = {};
const originalAddress = a; // 记录原始的对象引用
a.b = 'zhangsan';
console.log(a.b); // 输出 'zhangsan'
console.log(originalAddress === a); // 输出 true,说明引用地址未改变
所以,const a = {}; a.b = 'zhangsan';
是合法的,因为它没有违反 const
声明常量时不能改变变量引用地址的规则,只是对对象的属性进行了修改。
请解释 JS 事件循环机制,分析给定代码的执行结果
JavaScript 是单线程的,这意味着它一次只能执行一个任务。为了处理异步操作,JavaScript 引入了事件循环机制。事件循环机制主要涉及到调用栈、任务队列(宏任务队列和微任务队列)。
调用栈是 JavaScript 用来管理函数调用的一种数据结构,遵循后进先出(LIFO)的原则。当一个函数被调用时,它会被压入调用栈;当函数执行完毕后,它会从调用栈中弹出。
任务队列分为宏任务队列和微任务队列。宏任务包括 setTimeout
、setInterval
、I/O 操作
、UI 渲染
等;微任务包括 Promise.then
、MutationObserver
等。
事件循环的工作流程如下:
- 首先,JavaScript 引擎会执行调用栈中的同步代码。
- 当调用栈中的同步代码执行完毕后,会检查微任务队列。如果微任务队列中有任务,会依次将微任务队列中的任务取出并执行,直到微任务队列为空。
- 当微任务队列清空后,会从宏任务队列中取出一个宏任务放入调用栈中执行。
- 执行完一个宏任务后,又会重复步骤 2 和 3,不断循环。
以下是一个示例代码及执行结果分析:
console.log('1');setTimeout(() => {console.log('2');
}, 0);Promise.resolve().then(() => {console.log('3');
});console.log('4');
执行结果:
- 首先执行同步代码
console.log('1')
,输出1
。 - 遇到
setTimeout
,它是一个宏任务,会在 0 毫秒后将回调函数放入宏任务队列。 - 遇到
Promise.resolve().then
,它是一个微任务,会将回调函数放入微任务队列。 - 继续执行同步代码
console.log('4')
,输出4
。 - 此时调用栈为空,开始检查微任务队列,执行
Promise
的回调函数,输出3
。 - 微任务队列清空后,从宏任务队列中取出
setTimeout
的回调函数执行,输出2
。
所以最终的输出结果是 1
、4
、3
、2
。
请阐述 JS Promise 的实现原理,列举 Promise 有哪些 API,并手写你熟悉的 Promise API
实现原理
Promise 是一种异步编程的解决方案,用于处理异步操作的结果。它有三种状态:pending
(进行中)、fulfilled
(已成功)、rejected
(已失败)。状态一旦改变,就不会再变。
Promise 的核心原理是通过一个构造函数和两个回调函数(resolve
和 reject
)来管理状态。当异步操作成功时,调用 resolve
函数将状态从 pending
变为 fulfilled
;当异步操作失败时,调用 reject
函数将状态从 pending
变为 rejected
。
常用 API
Promise.then()
:用于处理 Promise 成功的结果,它接收一个回调函数作为参数。Promise.catch()
:用于处理 Promise 失败的结果,它接收一个回调函数作为参数。Promise.finally()
:无论 Promise 的状态如何,都会执行,它接收一个回调函数作为参数。Promise.all()
:接收一个 Promise 数组,当所有 Promise 都成功时,返回一个新的 Promise,其结果是一个包含所有 Promise 结果的数组;只要有一个 Promise 失败,就会立即返回该失败的 Promise。Promise.race()
:接收一个 Promise 数组,哪个 Promise 最先完成(成功或失败),就返回哪个 Promise 的结果。
手写 Promise.all
function myPromiseAll(promises) {return new Promise((resolve, reject) => {const results = [];let completedCount = 0;if (promises.length === 0) {resolve(results);return;}promises.forEach((promise, index) => {Promise.resolve(promise).then((value) => {results[index] = value;completedCount++;if (completedCount === promises.length) {resolve(results);}}).catch((error) => {reject(error);});});});
}
如何实现 sum (1)(2)(3)(4) = 10 这样的函数,考虑其拓展性
要实现 sum(1)(2)(3)(4) = 10
这样的函数,需要利用函数的柯里化和闭包的特性。函数柯里化是指将一个多参数函数转换为一系列单参数函数的过程。
以下是实现代码:
function sum(a) {function innerSum(b) {return sum(a + b);}innerSum.toString = function () {return a;};return innerSum;
}// 测试
console.log(sum(1)(2)(3)(4)); // 隐式转换调用 toString 方法,输出 10
代码解释
sum
函数接收一个参数a
,并返回一个内部函数innerSum
。innerSum
函数接收一个参数b
,并返回sum(a + b)
,这样就可以不断地进行累加。- 为
innerSum
函数添加toString
方法,当需要将函数转换为字符串时,会调用该方法返回累加的结果。
拓展性
这种实现方式具有很好的拓展性。可以根据需要继续调用 sum
函数进行累加,例如 sum(1)(2)(3)(4)(5)
会得到 15。
深度比较两个对象的函数如何实现
深度比较两个对象需要考虑对象的属性和值是否完全相同,包括对象的嵌套结构。以下是实现代码:
function deepEqual(obj1, obj2) {// 如果两个对象是同一个引用,直接返回 trueif (obj1 === obj2) {return true;}// 如果其中一个不是对象,直接返回 falseif (typeof obj1!== 'object' || obj1 === null || typeof obj2!== 'object' || obj2 === null) {return false;}const keys1 = Object.keys(obj1);const keys2 = Object.keys(obj2);// 如果属性数量不同,返回 falseif (keys1.length!== keys2.length) {return false;}for (let key of keys1) {if (!keys2.includes(key) ||!deepEqual(obj1[key], obj2[key])) {return false;}}return true;
}// 测试
const objA = { a: 1, b: { c: 2 } };
const objB = { a: 1, b: { c: 2 } };
console.log(deepEqual(objA, objB)); // 输出 true
代码解释
- 首先判断两个对象是否是同一个引用,如果是则直接返回
true
。 - 然后判断两个对象是否为对象类型,如果有一个不是对象,则返回
false
。 - 获取两个对象的所有属性名,并比较属性数量是否相同,如果不同则返回
false
。 - 遍历其中一个对象的属性名,检查另一个对象是否包含该属性,并且递归调用
deepEqual
函数比较属性值。 - 如果所有属性和值都相同,则返回
true
。
请说明基本数据类型和引用数据类型的区别
存储方式
- 基本数据类型:包括
undefined
、null
、boolean
、number
、string
、symbol
和bigint
。它们的值直接存储在栈内存中,访问速度较快。 - 引用数据类型:如
object
(包括Array
、Function
等)。它们在栈内存中存储的是一个引用地址,该地址指向堆内存中实际存储对象的空间。
赋值方式
- 基本数据类型:赋值时会复制其值。例如:
let a = 10;
let b = a;
b = 20;
console.log(a); // 输出 10
这里 b
复制了 a
的值,修改 b
不会影响 a
。
- 引用数据类型:赋值时复制的是引用地址。例如:
let obj1 = { name: '张三' };
let obj2 = obj1;
obj2.name = '李四';
console.log(obj1.name); // 输出 李四
这里 obj2
和 obj1
指向同一个对象,修改 obj2
会影响 obj1
。
比较方式
- 基本数据类型:比较的是值是否相等。例如:
let num1 = 10;
let num2 = 10;
console.log(num1 === num2); // 输出 true
- 引用数据类型:比较的是引用地址是否相等。例如:
let objA = { a: 1 };
let objB = { a: 1 };
console.log(objA === objB); // 输出 false
虽然 objA
和 objB
的属性和值相同,但它们是不同的对象,引用地址不同。
内存管理
- 基本数据类型:当变量离开作用域时,栈内存中的值会被自动释放。
- 引用数据类型:当没有变量引用堆内存中的对象时,该对象会成为垃圾对象,等待垃圾回收机制回收。
ES6 新引入了哪些特性,如 map、forEach 等的使用和区别
ES6(ECMAScript 2015)为 JavaScript 带来了众多实用特性,显著提升了开发效率与代码质量。
新特性列举
- 块级作用域:
let
和const
关键字用于声明变量,具有块级作用域,避免了var
带来的变量提升和作用域混乱问题。例如:
{let x = 10;const y = 20;console.log(x, y);
}
console.log(x);
这里 x
和 y
仅在 {}
块内有效。
- 箭头函数:提供了更简洁的函数定义方式,并且没有自己的
this
、arguments
、super
或new.target
。例如:
const sum = (a, b) => a + b;
- 模板字符串:使用反引号 定义字符串,支持变量插值和多行字符串。例如:
const name = 'John';
const message = `Hello, ${name}!`;
- 解构赋值:允许从数组或对象中提取值并赋值给变量。例如:
const [a, b] = [1, 2];
const { x, y } = { x: 10, y: 20 };
- 默认参数:函数参数可以设置默认值。例如:
function greet(name = 'Guest') {console.log(`Hello, ${name}!`);
}
- 扩展运算符:用于展开数组或对象。例如:
const arr1 = [1, 2];
const arr2 = [...arr1, 3, 4];
- 类和继承:引入了
class
关键字来定义类,使用extends
实现继承。例如:
class Animal {constructor(name) {this.name = name;}speak() {console.log(`${this.name} makes a noise.`);}
}class Dog extends Animal {speak() {console.log(`${this.name} barks.`);}
}
- Promise 对象:用于处理异步操作,避免回调地狱。例如:
const promise = new Promise((resolve, reject) => {setTimeout(() => {resolve('Success');}, 1000);
});
map
和 forEach
的使用与区别
map
:用于创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。例如:
const numbers = [1, 2, 3];
const squared = numbers.map(num => num * num);
这里 squared
是一个新数组 [1, 4, 9]
。
forEach
:对数组的每个元素执行一次提供的函数,没有返回值。例如:
const numbers = [1, 2, 3];
numbers.forEach(num => console.log(num));
它只是遍历数组并执行回调函数,不返回新数组。
你使用过 Vue 吗,Vue 的数据双向绑定是如何实现的
我熟悉 Vue,Vue 的数据双向绑定是其核心特性之一,极大地简化了视图和数据的交互。
实现原理
Vue 的数据双向绑定主要基于 Object.defineProperty () 方法(Vue 2.x)和 Proxy 对象(Vue 3.x)。在 Vue 2.x 中,其实现步骤如下:
- 数据劫持:当一个 Vue 实例创建时,Vue 会遍历
data
选项中的所有属性,使用Object.defineProperty()
将这些属性转换为getter/setter
。这样,当这些属性的值发生变化时,Vue 能够检测到。例如:
let obj = {};
let value = 10;
Object.defineProperty(obj, 'property', {get() {return value;},set(newValue) {value = newValue;// 通知视图更新}
});
- 发布 - 订阅模式:Vue 内部维护了一个依赖收集和通知机制。每个
getter
对应一个Dep
(依赖)对象,当一个属性被访问时,会触发getter
,此时会将依赖收集到Dep
中。当属性值发生变化时,会触发setter
,Dep
会通知所有订阅者(Watcher)进行更新。 - 视图更新:每个
Watcher
对应一个 DOM 节点,当Dep
通知Watcher
时,Watcher
会更新对应的 DOM 节点。例如,当data
中的某个属性值改变时,setter
被触发,Dep
通知相关的Watcher
,Watcher
会更新对应的 DOM 元素显示新的值。
示例
<template><div><input v-model="message" /><p>{{ message }}</p></div>
</template><script>
export default {data() {return {message: 'Hello, Vue!'};}
};
</script>
在这个例子中,v-model
指令实现了输入框和 message
数据的双向绑定。当输入框内容改变时,message
数据会更新;当 message
数据改变时,输入框内容也会更新。
Vue2 中使用 Object.defineProperty 有哪些劣势
在 Vue2 中,使用 Object.defineProperty
虽然实现了数据双向绑定,但存在一些劣势。
无法检测对象属性的添加和删除
Object.defineProperty
是在对象已经存在的属性上进行劫持,对于新增的属性和删除的属性无法自动进行响应式处理。例如:
const vm = new Vue({data: {user: {name: 'John'}}
});// 新增属性
vm.user.age = 20;
这里新增的 age
属性不会触发视图更新。需要使用 Vue.set
或 this.$set
方法来手动添加响应式属性。
无法检测数组的某些变化
Object.defineProperty
无法检测数组的以下几种变化:
- 通过索引直接修改数组元素。例如:
const vm = new Vue({data: {items: ['a', 'b', 'c']}
});vm.items[1] = 'd';
这里直接通过索引修改数组元素不会触发视图更新。
- 修改数组的长度。例如:
vm.items.length = 2;
同样,修改数组长度也不会触发视图更新。需要使用 Vue 提供的变异方法(如 push
、pop
、splice
等)来操作数组,以确保视图更新。
性能问题
当对象的属性较多时,使用 Object.defineProperty
会对每个属性进行劫持,这会带来一定的性能开销。尤其是在初始化大型对象时,会导致初始化时间变长。
深层嵌套对象处理复杂
对于深层嵌套的对象,需要递归地使用 Object.defineProperty
进行劫持,这增加了代码的复杂度和性能开销。而且,如果嵌套层次过深,可能会导致性能问题。
Vue 的 v-for 指令不写 key 会有什么问题,使用随机数作为 key 可以吗
不写 key 的问题
在 Vue 中,v-for
指令用于循环渲染列表。如果不写 key
,Vue 会采用默认的 “就地复用” 策略。这意味着当列表的顺序发生变化时,Vue 不会移动 DOM 元素,而是直接复用已有的 DOM 元素进行更新。
例如,有一个列表 ['a', 'b', 'c']
渲染成 <li>
元素,当列表变为 ['c', 'a', 'b']
时,由于没有 key
,Vue 不会重新排列 DOM 元素,而是直接更新元素的内容。这可能会导致一些问题,比如:
- 表单输入状态丢失:如果列表中有表单输入元素,当列表顺序改变时,输入框的内容可能会错乱,因为 DOM 元素被复用,输入框的状态没有正确更新。
- 动画效果异常:在使用过渡动画时,由于 DOM 元素没有正确移动,动画效果可能无法正常显示。
使用随机数作为 key
不建议使用随机数作为 key
。虽然随机数可以保证每个 key
的唯一性,但每次渲染时 key
都会发生变化,这会导致 Vue 认为每个元素都是全新的,从而频繁地销毁和创建 DOM 元素,带来不必要的性能开销。
正确的做法是使用列表中每个元素的唯一标识作为 key
,例如元素的 id
。这样可以帮助 Vue 准确地识别每个元素,在列表发生变化时,能够高效地更新 DOM 元素,避免不必要的渲染和性能问题。例如:
<template><ul><li v-for="item in items" :key="item.id">{{ item.name }}</li></ul>
</template><script>
export default {data() {return {items: [{ id: 1, name: 'Item 1' },{ id: 2, name: 'Item 2' },{ id: 3, name: 'Item 3' }]};}
};
</script>
这里使用 item.id
作为 key
,确保了每个元素的唯一性,提高了渲染效率。
请介绍 flex 布局
Flex 布局(Flexible Box Layout)即弹性布局,是一种为盒状模型提供最大灵活性的布局模型。它旨在提供一种更高效的方式来对容器中的子元素进行排列、对齐和分配空间。
基本概念
- 容器(Flex Container):使用
display: flex
或display: inline-flex
声明的元素成为弹性容器。 - 项目(Flex Item):弹性容器的子元素称为弹性项目。
- 主轴(Main Axis):弹性容器默认有一个主轴,主轴的方向可以通过
flex-direction
属性来改变。 - 交叉轴(Cross Axis):与主轴垂直的轴称为交叉轴。
容器属性
flex-direction
:定义主轴的方向,可选值有row
(默认,水平从左到右)、row-reverse
(水平从右到左)、column
(垂直从上到下)、column-reverse
(垂直从下到上)。flex-wrap
:定义子元素是否换行,可选值有nowrap
(默认,不换行)、wrap
(换行)、wrap-reverse
(换行,且顺序反转)。flex-flow
:是flex-direction
和flex-wrap
的简写形式。justify-content
:定义子元素在主轴上的对齐方式,可选值有flex-start
(默认,左对齐)、flex-end
(右对齐)、center
(居中对齐)、space-between
(两端对齐,中间间隔相等)、space-around
(每个元素两侧间隔相等)。align-items
:定义子元素在交叉轴上的对齐方式,可选值有stretch
(默认,拉伸填充容器)、flex-start
(顶部对齐)、flex-end
(底部对齐)、center
(居中对齐)、baseline
(基线对齐)。align-content
:定义多行子元素在交叉轴上的对齐方式,当只有一行子元素时,该属性无效。可选值有stretch
(默认,拉伸填充容器)、flex-start
(顶部对齐)、flex-end
(底部对齐)、center
(居中对齐)、space-between
(两端对齐,中间间隔相等)、space-around
(每行两侧间隔相等)。
项目属性
order
:定义子元素的排列顺序,数值越小越靠前,默认为 0。flex-grow
:定义子元素的放大比例,默认为 0,即不放大。flex-shrink
:定义子元素的缩小比例,默认为 1,即空间不足时会缩小。flex-basis
:定义子元素在主轴上的初始大小,默认为auto
。flex
:是flex-grow
、flex-shrink
和flex-basis
的简写形式,默认值为0 1 auto
。align-self
:定义单个子元素在交叉轴上的对齐方式,可覆盖align-items
的设置。
示例
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>.flex-container {display: flex;justify-content: space-around;align-items: center;flex-wrap: wrap;background-color: lightgray;padding: 10px;}.flex-item {background-color: blue;color: white;padding: 20px;margin: 10px;}</style>
</head><body><div class="flex-container"><div class="flex-item">Item 1</div><div class="flex-item">Item 2</div><div class="flex-item">Item 3</div></div>
</body></html>
在这个例子中,flex-container
是弹性容器,flex-item
是弹性项目。通过设置容器的属性,实现了子元素的水平分布和居中对齐,并且在空间不足时会换行。
如何实现垂直居中布局?
在前端开发中,垂直居中布局是一个常见需求,针对不同的场景有多种实现方式。
行内元素垂直居中
- 单行文本:对于单行文本,可通过设置元素的
line-height
等于元素的height
来实现垂直居中。例如,若有一个div
元素高度为 50px,要让其中的单行文本垂直居中,只需设置line-height: 50px
即可。
div {height: 50px;line-height: 50px;
}
- 多行文本:可以使用
display: table-cell
和vertical-align: middle
来实现。将父元素设置为display: table-cell
,然后利用vertical-align: middle
使子元素垂直居中。
.parent {display: table-cell;vertical-align: middle;height: 200px;
}
块级元素垂直居中
- flex 布局:使用
display: flex
或display: inline-flex
将父元素转换为弹性容器,再通过align-items: center
和justify-content: center
实现子元素的垂直和水平居中。
.parent {display: flex;align-items: center;justify-content: center;height: 300px;
}
- 绝对定位与负边距:当子元素宽度和高度固定时,可使用绝对定位和负边距实现垂直居中。先将子元素的
top
和left
设为 50%,再通过负边距将其向上和向左移动自身宽度和高度的一半。
.parent {position: relative;height: 300px;
}
.child {position: absolute;top: 50%;left: 50%;width: 100px;height: 100px;margin-top: -50px;margin-left: -50px;
}
- 绝对定位与
transform
:若子元素宽度和高度不固定,可使用transform: translate(-50%, -50%)
来实现垂直居中。
.parent {position: relative;height: 300px;
}
.child {position: absolute;top: 50%;left: 50%;transform: translate(-50%, -50%);
}
绝对定位与 flex
结合
对于绝对定位的元素,可结合 flex
布局实现垂直居中。将父元素设置为 position: relative
,子元素设置为 position: absolute
,并使用 display: flex
进行布局。
.parent {position: relative;height: 300px;
}
.child {position: absolute;top: 0;bottom: 0;left: 0;right: 0;display: flex;align-items: center;justify-content: center;
}
position 属性有哪些值,分别有什么作用?
position
属性用于指定一个元素在文档中的定位方式,它有多个取值,每个取值都有不同的作用。
static
static
是 position
的默认值。元素按照正常的文档流进行布局,top
、right
、bottom
、left
和 z-index
属性对其无效。例如:
div {position: static;
}
这个 div
元素会像普通元素一样,按照 HTML 中的顺序依次排列。
relative
relative
表示相对定位。元素会相对于其正常位置进行定位,top
、right
、bottom
、left
属性可以用来调整元素的位置。元素虽然位置发生了改变,但在文档流中仍然占据原来的空间。比如:
div {position: relative;top: 20px;left: 30px;
}
这个 div
元素会相对于其正常位置向下移动 20px,向右移动 30px,但原来的位置仍然保留。
absolute
absolute
是绝对定位。元素会脱离正常的文档流,相对于最近的已定位祖先元素(即 position
值不为 static
的祖先元素)进行定位。如果没有已定位的祖先元素,则相对于初始包含块(通常是 html
元素)定位。例如:
.parent {position: relative;
}
.child {position: absolute;top: 50px;left: 50px;
}
这里的 .child
元素会相对于 .parent
元素进行定位。
fixed
fixed
是固定定位。元素会脱离正常的文档流,相对于浏览器窗口进行定位。无论页面如何滚动,元素都会固定在指定的位置。常用于实现固定导航栏、侧边栏等。比如:
.navbar {position: fixed;top: 0;left: 0;width: 100%;
}
这个导航栏会固定在浏览器窗口的顶部。
sticky
sticky
是粘性定位。它是相对定位和固定定位的混合。元素在屏幕范围内时,会按照正常的文档流进行布局;当滚动到屏幕范围之外时,会固定在指定的位置。例如:
.header {position: sticky;top: 0;
}
这个头部元素在滚动时,当滚动到屏幕顶部时会固定在顶部。
常用的 CSS 选择器有哪些,它们的权重是如何计算的?
CSS 选择器用于选择 HTML 元素并应用样式,不同的选择器有不同的优先级,也就是权重。
常用的 CSS 选择器
- 元素选择器:通过元素名称来选择元素,如
p
、div
、h1
等。例如:
p {color: red;
}
- 类选择器:通过元素的
class
属性来选择元素,以.
开头。例如:
.my-class {font-size: 16px;
}
- ID 选择器:通过元素的
id
属性来选择元素,以#
开头。例如:
#my-id {background-color: yellow;
}
- 属性选择器:通过元素的属性来选择元素,如
[attribute]
、[attribute=value]
等。例如:
input[type="text"] {border: 1px solid gray;
}
- 伪类选择器:用于选择处于特定状态的元素,如
:hover
、:active
、:first-child
等。例如:
a:hover {color: blue;
}
- 伪元素选择器:用于选择元素的特定部分,如
::before
、::after
、::first-letter
等。例如:
p::first-letter {font-size: 20px;
}
- 组合选择器:将多个选择器组合起来使用,如后代选择器(
div p
)、子元素选择器(div > p
)、相邻兄弟选择器(div + p
)、通用兄弟选择器(div ~ p
)等。
权重计算
CSS 选择器的权重是一个由四个值组成的数字,分别为 [内联样式, ID 选择器数量, 类选择器/属性选择器/伪类选择器数量, 元素选择器/伪元素选择器数量]
。权重值越大,优先级越高。
- 内联样式:直接写在 HTML 元素的
style
属性中的样式,权重为[1, 0, 0, 0]
。 - ID 选择器:每个 ID 选择器的权重为
[0, 1, 0, 0]
。 - 类选择器、属性选择器、伪类选择器:每个的权重为
[0, 0, 1, 0]
。 - 元素选择器、伪元素选择器:每个的权重为
[0, 0, 0, 1]
。
例如,#my-id.my-class p
的权重为[0, 1, 1, 1]
。当多个选择器作用于同一个元素时,会根据权重来决定最终应用的样式。如果权重相同,则后面的样式会覆盖前面的样式。
如何隐藏一个元素,若要使元素既不被移除又被隐藏,有哪些方法?
在前端开发中,隐藏元素是常见需求,有多种方法可以实现元素的隐藏,且有些方法能让元素既不被移除又处于隐藏状态。
display: none
使用 display: none
可以完全隐藏元素,元素不会在页面中占据任何空间,就好像它不存在于文档流中一样。例如:
.hidden {display: none;
}
这种方式隐藏的元素不会响应任何事件,并且在页面布局中不会影响其他元素的位置。
visibility: hidden
visibility: hidden
也能隐藏元素,但元素仍然会在页面中占据原来的空间。它只是在视觉上不可见,但其位置和大小仍然会影响其他元素的布局。例如:
.invisible {visibility: hidden;
}
该元素仍然会响应事件,只是用户看不到它。
opacity: 0
通过设置 opacity: 0
可以让元素变得完全透明,从而在视觉上隐藏元素。元素仍然会占据空间,并且会响应事件。例如:
.transparent {opacity: 0;
}
这种方法适用于需要元素在隐藏状态下仍然能接收事件的场景。
position
偏移
将元素的 position
属性设置为 absolute
或 fixed
,然后通过 top
、left
等属性将元素移出可视区域。例如:
.outside {position: absolute;top: -9999px;left: -9999px;
}
元素虽然在可视区域外,但仍然存在于文档流中,并且可以通过 JavaScript 重新定位到可视区域。
clip-path
使用 clip-path
属性可以裁剪元素,使其不可见。例如:
.clipped {clip-path: polygon(0px 0px, 0px 0px, 0px 0px, 0px 0px);
}
这种方法可以创建复杂的裁剪形状来隐藏元素,并且元素仍然占据空间。
CSS 中出现两个相同的类定义,如何避免冲突?
在 CSS 中,当出现两个相同的类定义时,可能会导致样式冲突,影响页面的显示效果。可以采用以下方法来避免冲突。
命名空间
使用命名空间是一种有效的方法。可以为类名添加前缀,以区分不同模块或组件的样式。例如,对于一个博客项目,文章部分的样式类名可以添加 article-
前缀,评论部分的样式类名可以添加 comment-
前缀。
.article-title {font-size: 24px;
}
.comment-title {font-size: 20px;
}
这样可以清晰地划分不同部分的样式,避免类名冲突。
BEM 命名规范
BEM(Block Element Modifier)是一种广泛使用的 CSS 命名规范。它将元素分为块(Block)、元素(Element)和修饰符(Modifier)三个部分。块是一个独立的实体,元素是块的组成部分,修饰符用于改变块或元素的外观或行为。例如:
.blog-post {/* 块样式 */
}
.blog-post__title {/* 元素样式 */
}
.blog-post--featured {/* 修饰符样式 */
}
使用 BEM 命名规范可以使类名更加清晰和具有语义,减少类名冲突的可能性。
CSS Modules
CSS Modules 是一种在前端框架(如 React、Vue 等)中常用的技术。它会将类名局部化,每个类名在其所在的模块中是唯一的。在使用 CSS Modules 时,类名会被编译成一个哈希值,从而避免全局冲突。例如,在 React 中使用 CSS Modules:
import styles from './styles.module.css';function MyComponent() {return <div className={styles.myClass}>Hello, World!</div>;
}
这里的 myClass
在 styles.module.css
中定义,会被编译成一个唯一的类名。
使用 scoped
属性(Vue)
在 Vue 中,可以为 <style>
标签添加 scoped
属性,使样式只作用于当前组件。例如:
<template><div class="my-component"><p>Hello, Vue!</p></div>
</template><style scoped>
.my-component {background-color: lightblue;
}
</style>
这样,.my-component
样式只会应用于当前组件,不会影响其他组件。
你平时写过拓扑图吗,是如何实现的?
拓扑图的实现通常需要结合多种技术和工具。首先要选择合适的绘图库,比如 D3.js,它具有强大的数据可视化能力,可通过操作 DOM 和 SVG 来创建各种图形元素并绑定数据。也可以使用 ECharts,它提供了丰富的图表类型和交互功能,对拓扑图的支持也较为友好。
确定绘图库后,需准备数据。拓扑图的数据一般包含节点和边的信息,节点可以是服务器、设备等,边则表示它们之间的连接关系。数据结构可能类似于:
const data = {nodes: [{ id: 'node1', name: '服务器1', x: 100, y: 100 },{ id: 'node2', name: '服务器2', x: 200, y: 200 }],edges: [{ source: 'node1', target: 'node2' }]
};
然后,使用绘图库根据数据来绘制节点和边。以 D3.js 为例,可通过enter
和append
方法来创建节点和边的 SVG 元素,并设置其属性,如位置、颜色、大小等。对于节点的文本标签,也可以通过创建text
元素并设置其内容和位置来实现。
最后,为了增强用户体验,还可以添加交互功能,如鼠标悬停显示节点详细信息、点击节点展开或收缩相关内容、拖动节点改变位置等。这些交互功能可以通过 D3.js 或其他库提供的事件绑定方法来实现。
你的项目权限管理是如何实现的?
在项目中实现权限管理,一般有以下几个关键步骤。首先是权限设计,需要根据项目的功能和用户角色,确定不同的权限级别和权限范围。例如,将用户角色分为管理员、普通用户等,管理员可能具有所有功能的操作权限,而普通用户只能进行部分只读操作。
然后是数据库设计,创建相关的数据表来存储用户角色、权限信息以及它们之间的关联关系。常见的表结构包括用户表、角色表、权限表以及角色 - 权限关联表。通过这些表可以清晰地管理用户与权限之间的关系。
在前端实现方面,可在路由配置中进行权限控制。对于需要特定权限才能访问的页面,在路由的meta
字段中设置权限要求。当用户访问页面时,通过路由守卫来检查用户的权限是否满足要求。如果权限不足,则重定向到无权限提示页面或其他合适的页面。
在页面元素级别,也可以根据用户权限来控制元素的显示和隐藏。例如,通过在组件中使用v-if
或v-show
指令,结合用户权限信息来决定是否显示某些按钮、菜单等元素。
后端的权限管理主要是在接口层面进行控制。对于不同的接口,根据其功能和所需权限进行标注。当用户发起请求时,后端中间件或拦截器会检查用户的权限,只有具备相应权限的用户才能访问对应的接口,否则返回权限不足的错误信息。
项目中遇到过哪些难点,你是如何解决的?
在项目开发中,常遇到性能优化的难点。例如,页面加载速度慢,可能是由于资源文件过大、请求过多等原因导致。为解决这个问题,首先会对图片等静态资源进行压缩,使用工具如 ImageOptim 来减小图片文件大小,同时根据图片的用途选择合适的格式,如 PNG 适合透明背景的图片,JPEG 适合色彩丰富的照片。对于代码方面,会进行代码拆分和懒加载,将页面中不常用的组件或模块进行异步加载,只有在需要使用时才进行加载,这样可以减少初始加载的文件大小,提高页面的加载速度。
另一个常见难点是兼容性问题。不同浏览器对 CSS 样式和 JavaScript 代码的解析可能存在差异,导致页面在某些浏览器上显示异常或功能无法正常使用。解决方法是通过使用 CSS 预处理器如 Sass 或 Less,利用其提供的混合宏(Mixin)来编写兼容不同浏览器的 CSS 代码。对于 JavaScript 的兼容性问题,会使用工具如 Babel 将 ES6 等新特性的代码转换为兼容旧浏览器的代码。同时,在项目开发过程中,会进行全面的浏览器测试,包括 Chrome、Firefox、Safari、IE 等主流浏览器,及时发现并解决兼容性问题。
还有数据交互方面的难点,比如与后端接口的数据传输和处理。如果数据格式不符合前端需求,需要在前端进行数据转换和处理。可以使用工具库如 Lodash 来对数据进行操作,如过滤、映射、排序等。若遇到数据加载缓慢或请求失败的情况,会通过设置合理的缓存策略来提高数据加载速度,同时在请求失败时提供友好的提示信息,并设计重试机制,让用户可以重新发起请求。
项目中使用哪种通信方式,是否会封装一些请求?
在项目中,常用的通信方式是基于 HTTP 协议的 AJAX(Asynchronous JavaScript and XML)技术,通过XMLHttpRequest
对象来实现浏览器与服务器之间的异步数据传输。这种方式可以在不刷新页面的情况下获取和更新数据,提供更好的用户体验。随着技术的发展,也会使用 Fetch API,它提供了更简洁的接口来进行网络请求,并且基于 Promise 实现,使得代码的异步处理更加方便。
通常会对请求进行封装,这样做有很多好处。首先,提高了代码的可维护性和可复用性。通过封装,可以将请求的逻辑集中在一个地方,当需要修改请求的参数、处理请求的错误等操作时,只需要在封装的函数或模块中进行修改,而不用在每个使用请求的地方都进行修改。例如,可以封装一个request
函数,接收请求的 URL、方法、参数等信息,在函数内部统一处理请求的发送和响应的处理。
function request(url, method = 'GET', data = {}) {return new Promise((resolve, reject) => {const xhr = new XMLHttpRequest();xhr.open(method, url);xhr.setRequestHeader('Content - Type', 'application/json');xhr.onload = () => {if (xhr.status === 200) {resolve(JSON.parse(xhr.responseText));} else {reject(new Error('请求失败'));}};xhr.onerror = () => {reject(new Error('网络错误'));};xhr.send(JSON.stringify(data));});
}
其次,封装请求还可以对请求进行统一的拦截和处理。比如在请求发送前,可以添加一些公共的请求头,如身份验证信息等。在响应返回后,可以对响应进行统一的错误处理,根据不同的错误码给用户提供相应的提示信息。还可以在封装中实现缓存机制,对于一些频繁请求且数据不经常变化的接口,将请求结果进行缓存,下次请求时直接从缓存中获取数据,提高数据加载速度。
- 项目中的刷新是整体刷新吗?
在项目中,一般不会采用整体刷新的方式。因为整体刷新会导致用户当前的操作状态丢失,页面重新加载所有资源,包括 HTML、CSS、JavaScript 文件等,这会带来较长的等待时间,影响用户体验。
相反,会采用局部刷新的方式。例如,在使用 Vue 或 React 等框架的项目中,通过数据驱动视图的更新。当数据发生变化时,框架会自动根据数据的变化来更新相应的视图组件,而不会影响到其他未发生变化的部分。以 Vue 为例,当响应式数据更新时,Vue 会通过其虚拟 DOM diff 算法来比较新旧虚拟 DOM 树的差异,然后只更新发生变化的 DOM 节点,实现局部刷新。
对于一些需要更新页面部分内容的场景,比如用户进行搜索操作后,只需要更新搜索结果展示区域,而页面的导航栏、侧边栏等其他部分不需要重新加载。可以通过 AJAX 请求获取新的数据,然后使用 JavaScript 操作 DOM 来更新相应的元素。
另外,在一些单页应用(SPA)中,通过路由切换来实现页面的局部更新。当用户点击不同的导航链接时,路由系统会根据路由配置加载相应的组件,并更新页面的部分区域,而不是整个页面重新刷新。这样可以保持页面的流畅性和用户操作的连贯性,提高用户体验。只有在一些特殊情况下,如用户手动点击浏览器的刷新按钮或页面出现严重错误需要重新加载时,才会进行整体刷新,但这并不是项目中常规的刷新方式。
图片为什么要转换成 base64,png 和 jpg 有什么差别?
将图片转换成 base64 格式存在多方面的原因。从性能角度来看,能够减少 HTTP 请求。在传统模式下,网页加载图片时需要单独发起 HTTP 请求来获取图片资源,而将图片转换为 base64 编码后,可以将其直接嵌入到 HTML、CSS 或 JavaScript 文件中,这样就无需额外的请求,从而加快页面的加载速度。特别是对于一些小图标,这种方式能显著提升性能。
在兼容性方面,base64 编码的图片可以在任何支持 HTML 和 CSS 的环境中正常显示,无需考虑图片文件的存储位置和服务器配置等问题。这在跨平台和跨设备的应用场景中非常实用。
在安全性上,base64 编码可以对图片进行一定程度的保护。虽然它并非真正意义上的加密,但能防止图片被轻易下载和盗用。
PNG 和 JPG 是两种常见的图片格式,它们存在诸多差别。在文件大小方面,JPG 格式通常更适合存储色彩丰富、细节复杂的图片,因为它采用了有损压缩算法,能在保证一定画质的前提下大幅减小文件大小。而 PNG 格式则分为无损压缩的 PNG - 8 和 PNG - 24 等类型,对于简单的图标、透明背景的图片,PNG - 8 能在较小的文件大小下保持较好的质量;对于需要高质量和透明效果的图片,PNG - 24 更合适,但文件大小相对较大。
在色彩表现上,JPG 支持多达 1670 万种颜色,能很好地还原照片等色彩丰富的图像。PNG - 8 支持 256 种颜色,适合色彩简单的图像;PNG - 24 支持真彩色,色彩表现能力与 JPG 相当,但由于无损压缩,文件会更大。
在透明度支持方面,PNG 格式支持透明通道,可实现半透明或全透明效果,这在制作图标、网页元素等需要与背景融合的图片时非常有用。而 JPG 格式不支持透明度,所有像素都必须是不透明的。
WebGL 与 canvas 的区别和各自特点是什么?
WebGL(Web Graphics Library)和 canvas 都是用于在网页上进行图形绘制的技术,但它们存在明显的区别和各自独特的特点。
canvas 是 HTML5 新增的元素,通过 JavaScript 可以在该元素上进行 2D 图形的绘制。它的特点是使用简单,学习成本较低。对于初学者来说,只需掌握基本的 JavaScript 和 canvas 的 API,就可以绘制出简单的图形,如矩形、圆形、线条等。canvas 适用于创建简单的动画、游戏和数据可视化图表等。例如,通过不断更新 canvas 上的图形位置和状态,可以实现简单的动画效果。它还可以与 CSS 结合,实现一些基本的样式和布局。然而,canvas 的性能相对较低,尤其是在处理复杂的图形和大量的绘制操作时,可能会出现卡顿现象。而且 canvas 的图形绘制是基于像素的,一旦绘制完成,很难对单个图形元素进行修改和操作。
WebGL 是基于 OpenGL ES 2.0 的 3D 绘图标准,通过 JavaScript 可以在网页上实现高性能的 3D 图形渲染。它的特点是性能强大,能够处理复杂的 3D 场景和大量的图形数据。WebGL 可以利用 GPU 的并行计算能力,实现快速的图形渲染,使得 3D 模型的展示更加流畅和真实。例如,在一些在线游戏、3D 建模和虚拟现实应用中,WebGL 发挥着重要作用。WebGL 的图形绘制是基于顶点和片元着色器的,开发者可以通过编写着色器代码来实现自定义的光照、材质和特效等。但是,WebGL 的学习曲线较陡,需要掌握一定的 3D 数学知识和 OpenGL 相关的概念,开发难度较大。
重绘和回流是什么,为什么 translate 不会触发回流?
重绘和回流是浏览器渲染页面时的两个重要概念。回流(Reflow),也称为重排,指的是当 DOM 的变化影响了元素的布局信息(元素的大小、位置、边距等)时,浏览器需要重新计算元素在视口内的布局信息,将其安放到界面中的正确位置。例如,当改变元素的宽度、高度、边距、字体大小等属性时,都会触发回流。回流是一个比较昂贵的操作,因为浏览器需要重新计算整个文档中所有元素的布局信息,这可能会导致页面的闪烁和卡顿。
重绘(Repaint)是指当一个元素的外观发生改变,但没有影响到布局信息时,浏览器会将该元素的外观重新绘制。例如,改变元素的颜色、背景色、透明度等属性,只会触发重绘,而不会触发回流。重绘的开销相对较小,因为它只需要重新绘制元素的外观,而不需要重新计算布局信息。
translate
是 CSS 的一个变换属性,用于移动元素的位置。它不会触发回流,是因为 translate
是通过修改元素的渲染层的位置来实现移动的,而不会影响元素的布局信息。浏览器在渲染页面时,会将元素的布局信息和渲染信息分开处理。translate
只是改变了元素在渲染层的位置,而没有改变元素的大小、位置、边距等布局信息,因此不会触发回流。这使得使用 translate
进行元素的移动操作更加高效,能够避免因回流带来的性能问题,常用于实现动画效果,提供流畅的用户体验。
设计一个算法,从一个 10 万条数据的数组中随机取 5 万条数据。(js 代码完整实现)
要从一个包含 10 万条数据的数组中随机选取 5 万条数据,可以使用 Fisher - Yates 洗牌算法的变种。该算法的核心思想是对数组进行洗牌,然后取前 5 万条数据。
以下是完整的 JavaScript 代码实现:
function getRandomData(arr, count) {const shuffled = arr.slice();let len = shuffled.length;let temp;let index;// Fisher - Yates 洗牌算法while (len) {index = Math.floor(Math.random() * len--);temp = shuffled[len];shuffled[len] = shuffled[index];shuffled[index] = temp;}return shuffled.slice(0, count);
}// 模拟 10 万条数据的数组
const largeArray = [];
for (let i = 0; i < 100000; i++) {largeArray.push(i);
}// 随机选取 5 万条数据
const randomData = getRandomData(largeArray, 50000);
console.log(randomData);
在上述代码中,getRandomData
函数接收一个数组 arr
和一个整数 count
作为参数。首先,使用 slice
方法复制原数组,避免修改原数组。然后,使用 Fisher - Yates 洗牌算法对复制后的数组进行洗牌。在洗牌过程中,从数组的最后一个元素开始,依次与前面随机位置的元素交换位置。最后,使用 slice
方法从洗牌后的数组中截取前 count
条数据并返回。通过这种方式,可以确保选取的数据是随机的,并且不会改变原数组的顺序。