Published on

面试 Javascript

Authors
  • avatar
    Name
    Shelton Ma
    Twitter

TypeScript

1. TypeScript与JavaScript的区别是什么?为什么使用TypeScript而不是JavaScript?

TypeScript是JavaScript的超集,主要增加了静态类型检查和更强的类型系统。TypeScript在编译时会检查代码中的类型错误,避免运行时错误。它还支持高级类型功能,如接口、泛型、类型别名等,能提高代码的可维护性和可读性。使用TypeScript可以提前捕获潜在的错误,提升开发效率。

2. 解释一下TypeScript中的接口(Interface)和类型别名(Type Alias)的区别

  • 接口(Interface) 用于定义对象的结构,支持扩展(extends)和实现(implements)。它更注重定义“形状”,通常用于面向对象编程。
  • 类型别名(Type Alias) 可以为任何类型(包括基本类型、联合类型、交叉类型等)创建别名,不仅限于对象。它更灵活,但不支持扩展。type Name = string; type NameOrResolver = Name | NameResolver;
  • interface 主要用于定义对象类型,支持声明合并;type 可以定义更广泛的类型,包括基本类型、联合类型等。

3. 什么是泛型(Generics)?如何使用它来提高代码的可重用性和类型安全性?

泛型是指在定义函数、类或接口时不预先指定具体的类型,而是在使用时动态地指定类型。泛型提高了代码的可重用性和类型安全性,避免了重复代码。比如,function identity<T>(arg: T): T { return arg; },这个函数可以接受任何类型的参数,并返回相同类型的结果。

4. 如何使用TypeScript的声明合并(Declaration Merging)?举一个实际应用的例子

声明合并是指多个相同名字的接口或类型会合并为一个。比如,interface Person { name: string }interface Person { age: number } 会合并成 interface Person { name: string, age: number }。这种特性在扩展第三方库时非常有用。

4. 你如何处理TypeScript中的类型推断?有何限制?

TypeScript会根据变量的赋值来推断类型。例如,let x = 10;,TypeScript会推断出x是number类型。类型推断在静态检查中提供了便利,但如果类型不明确或者赋值是动态的,推断可能不准确,此时需要显式声明类型。

5. TypeScript中的“never”和“void”有什么区别?

  • never 表示函数永远不会正常返回,它通常用于抛出异常或死循环的函数。
  • void 表示没有返回值,通常用于那些没有返回值的函数(如 function logMessage(): void { console.log('Hello'); })。

6. 你如何处理TypeScript中的类型推断?有何限制?

TypeScript会根据变量的赋值来推断类型。例如,let x = 10;,TypeScript会推断出x是number类型。类型推断在静态检查中提供了便利,但如果类型不明确或者赋值是动态的,推断可能不准确,此时需要显式声明类型。

7. 解释下js中的异步方法实现, 并举例说明promise的优势/回调地狱

在JavaScript中,异步操作是指不阻塞主线程(UI线程)执行的任务,例如网络请求、定时器、文件读取等。为了处理这些异步任务,JavaScript提供了几种机制,如回调函数(Callback)、Promise和async/await

回调函数是最早的一种异步处理方式。回调函数在异步操作完成后执行。例如,处理文件读取时,可以传入一个回调函数,读取完成后回调函数被执行。

function fetchData(callback) {
  setTimeout(() => {
    console.log('Data fetched');
    callback('Data');
  }, 1000);
}

fetchData((data) => {
  console.log('Received:', data);
});

问题: 回调地狱(Callback Hell),即嵌套多个回调函数,会导致代码层级过深,难以维护和理解,尤其是在多重异步操作的场景下。

Promise是ES6引入的一种更为现代化的异步编程方式。它是一个代表未来可能完成或失败的操作的对象,提供了链式调用的能力。Promise有三种状态:

  • Pending(等待中):异步操作尚未完成。
  • Resolved(已完成):异步操作成功完成。
  • Rejected(已拒绝):异步操作失败。
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true; // 模拟成功与失败
      if (success) {
        resolve('Data fetched');
      } else {
        reject('Error fetching data');
      }
    }, 1000);
  });
}

fetchData()
  .then((data) => {
    console.log('Received:', data);
  })
  .catch((error) => {
    console.log('Error:', error);
  });

Promise的优势:

  1. 链式调用: Promise提供了.then()方法,可以将异步操作串联在一起,避免回调嵌套。例如,可以对多个异步操作进行链式调用,使得代码更清晰易懂。

    fetchData()
      .then((data) => {
        console.log('First step:', data);
        return fetchData();  // 返回一个新的 Promise
      })
      .then((data) => {
        console.log('Second step:', data);
      })
      .catch((error) => {
        console.log('Error:', error);
      });
    
  2. 错误处理: 使用Promise时,错误处理更加集中和统一。你可以通过catch()捕获链式调用中的任何错误,而不需要为每个异步操作编写错误回调。

  3. 防止回调地狱: 比较明显的优势是能够避免回调地狱。回调地狱会导致嵌套层级过深,影响代码的可读性和可维护性,而Promise通过链式调用使代码结构更加清晰。

  4. 并行异步操作: Promise.all()可以让你并行执行多个异步操作,并在所有操作完成时进行处理。这样可以提高代码的执行效率。

    const fetchData1 = () => new Promise((resolve) => setTimeout(() => resolve('Data 1'), 1000));
    const fetchData2 = () => new Promise((resolve) => setTimeout(() => resolve('Data 2'), 1500));
    
    Promise.all([fetchData1(), fetchData2()])
      .then(([data1, data2]) => {
        console.log('Received:', data1, data2);
      })
      .catch((error) => {
        console.log('Error:', error);
      });
    

async/await(基于Promise) async/await是ES2017引入的语法糖,它基于Promise,但提供了更加简洁的异步代码写法。async标记函数为异步函数,await只能在async函数内部使用,它会等待Promise的结果返回。

async function fetchData() {
  const result = await new Promise((resolve, reject) => {
    setTimeout(() => resolve('Data fetched'), 1000);
  });
  console.log(result);
}

fetchData();

8. 如何在Node.js中处理错误?

  1. 对于同步代码,错误通常通过try...catch语句来捕获。

    try {
      let result = someFunction();  // 调用可能抛出错误的函数
    } catch (err) {
      console.error('Error occurred:', err);  // 捕获并处理错误
    }
    
  2. 回调函数中的错误处理, 在Node.js的回调风格中,错误通常作为第一个参数传递给回调函数。这是一种约定俗成的做法,也称为”错误优先”回调。

    const fs = require('fs');
    
    fs.readFile('nonexistentFile.txt', 'utf8', (err, data) => {
      if (err) {
        console.error('File read failed:', err);
        return;
      }
      console.log(data);
    });
    
  3. Promise中的错误处理, 错误可以通过.catch()方法或try...catch语句来捕获。

  4. 未捕获的Promise拒绝(Unhandled Rejection), 从Node.js 15开始,未捕获的Promise拒绝会导致进程退出。为了避免进程退出,通常应该显式处理每个Promise的错误。

    const fs = require('fs').promises;
    
    fs.readFile('nonexistentFile.txt', 'utf8')
      .then(data => console.log(data))
      .catch(err => console.error('Error occurred:', err));
    
    // 如果没有.catch(),你可以监听unhandledRejection事件来处理未捕获的Promise拒绝:
    process.on('unhandledRejection', (err, promise) => {
      console.error('Unhandled promise rejection:', err);
      // 可以根据需要决定是否退出进程
    });
    
  5. 全局错误处理

    process.on('uncaughtException', (err) => {
      console.error('Uncaught exception:', err);
      // 进行必要的清理工作,并退出进程(推荐退出进程)
      process.exit(1);
    });
    throw new Error('This will be caught');
    
    process.on('unhandledRejection', (reason, promise) => {
      console.error('Unhandled rejection at:', promise, 'reason:', reason);
      // 可以根据需要决定是否退出进程
    });
    

9. 解释流(Streams)的概念,以及如何在Node.js中使用它们

**流(Streams)**是处理数据的一种抽象方式。Node.js中有四种流类型:

  • Readable(可读流):用于从源(如文件、网络等)读取数据。

    const fs = require('fs');
    const readStream = fs.createReadStream('file.txt');
    readStream.on('data', (chunk) => {
      console.log(chunk.toString());
    });
    
  • Writable(可写流):用于向目标(如文件、网络等)写入数据。

    const fs = require('fs');
    
    const writeStream = fs.createWriteStream('output.txt');
    
    writeStream.write('Hello, Node.js!');
    writeStream.end();  // 关闭流,确保写入完成
    
  • Duplex(双工流):可读可写流。

    const fs = require('fs');
    
    const readStream = fs.createReadStream('example.txt');
    const writeStream = fs.createWriteStream('copy.txt');
    
    readStream.pipe(writeStream);
    
    writeStream.on('finish', () => {
      console.log('文件复制完成');
    });
    
  • Transform(转换流):可读可写流,数据经过处理后转换。

    const { Transform } = require('stream');
    
    const upperCaseTransform = new Transform({
      transform(chunk, encoding, callback) {
        this.push(chunk.toString().toUpperCase());
        callback();
      }
    });
    
    const readStream = fs.createReadStream('example.txt');
    const writeStream = fs.createWriteStream('output.txt');
    
    readStream.pipe(upperCaseTransform).pipe(writeStream);
    

为什么使用流?

  • 内存效率:流按需读取和写入数据,而不是将数据一次性加载到内存中。
  • 性能:通过流,数据可以在流动过程中进行处理,而不需要等待所有数据加载完。
  • 适合大文件或实时数据:流非常适合处理大文件、实时数据或网络请求等场景。

10. Node.js的EventEmitter是如何工作的?

EventEmitter是Node.js中的一个类,用于处理事件和监听器。你可以创建自己的事件,并通过on方法监听,emit方法触发事件。

const EventEmitter = require('events');
const emitter = new EventEmitter();

// 监听事件
emitter.on('event', (message) => {
  console.log(message);
});

// 触发事件
emitter.emit('event', 'Hello, world!');

11. Node.js的process对象包含哪些有用的属性和方法?

process对象是Node.js提供的一个全局对象,用于与当前Node.js进程交互。常见属性和方法包括:

  • process.env:获取环境变量。
  • process.argv:获取命令行参数。
  • process.exit():退出Node.js进程。
  • process.cwd():获取当前工作目录。
  • process.pid:获取进程ID。

12. 你如何优化Node.js应用程序的性能

  • 异步编程: 使用异步I/O和非阻塞操作来提高应用的响应能力。
  • 负载均衡: 使用cluster模块来创建多个进程,利用多核CPU提升性能。
  • 内存管理: 使用内存分析工具(如node --inspect)来识别和修复内存泄漏。
  • 缓存: 使用Redis等缓存机制减少数据库查询的压力。
  • 压缩: 使用Gzip或Brotli压缩响应数据,减少网络传输时间。

13. Node.js中如何实现身份验证和授权?

常用的身份验证方法包括:

  • JWT(JSON Web Token):使用JWT来实现无状态的身份验证,前端通过JWT令牌发送请求,后端验证JWT的有效性。
  • Session:通过会话存储用户登录状态,用户每次请求时携带session ID。
  • OAuth2:通过第三方认证服务(如Google、Facebook)来授权用户。
const jwt = require('jsonwebtoken');

// 登录,生成JWT
app.post('/login', (req, res) => {
  const token = jwt.sign({ userId: 123 }, 'your-secret-key');
  res.json({ token });
});

// 认证中间件
const authenticate = (req, res, next) => {
  const token = req.headers['authorization'];
  if (!token) {
    return res.status(403).json({ message: 'No token provided' });
  }
  jwt.verify(token, 'your-secret-key', (err, decoded) => {
    if (err) {
      return res.status(403).json({ message: 'Invalid token' });
    }
    req.user = decoded;
    next();
  });
};

14. 闭包(Closure)

闭包(Closure)是JavaScript中的一个非常重要的概念,它描述了函数如何访问其外部作用域中的变量,即使这个函数在其外部作用域之外执行时,依然能够“记住”并访问这些变量。

  • 弊端:闭包可能导致一些问题,尤其是在异步编程中,容易引发 状态泄漏 或 数据共享 问题。特别是 var 声明的变量会导致异步回调共享同一个引用。
  • 解决方案:
    • 使用 let 或 const 替代 var,避免共享同一作用域。
    • 使用 IIFE(立即调用函数表达式)来创建独立的作用域,确保闭包中的变量不会冲突。

15. Node.js 事件循环是如何工作的?

事件循环是Node.js的核心机制,它通过异步非阻塞I/O实现高效的并发操作。事件循环有多个阶段,每个阶段会处理不同的任务,例如执行计时器回调、I/O回调等。

16. Node.js 中的 process.nextTick() 和 setImmediate() 有什么区别?

process.nextTick() 会将回调推入当前执行栈的顶部,优先执行;setImmediate() 会在当前事件循环结束时执行回调,优先级低于 process.nextTick()。

17. 如何使用 Node.js 的 cluster 模块实现多核 CPU 的利用?

cluster 模块允许你创建多个进程来充分利用多核 CPU,每个进程都可以监听同一个端口,增强应用的并发处理能力。

注意事项:

  • 共享状态:工作进程之间是相互独立的,它们无法共享内存状态。如果需要共享数据,可以考虑使用 worker 进程间的 IPC(进程间通信),或使用外部的存储(如 Redis)。
  • 进程重启:当一个工作进程崩溃时,主进程会通过 cluster.on('exit') 监听事件来重启它。

18. Node.js 中如何实现文件操作的异步和同步方式?

使用 fs 模块,异步操作通过 fs.readFile() 和 fs.writeFile() 实现,同步操作通过 fs.readFileSync() 和 fs.writeFileSync() 实现。

19 如何在 TypeScript 中进行类型断言?

类型断言是通过 as 或尖括号语法来告诉编译器某个值的类型

20. TypeScript 中如何使用声明合并?

TypeScript 允许多个相同名称的接口或模块进行声明合并,通常用于扩展类型或模块功能。

// 假设有一个外部库 `thirdPartyLib` 和其声明
declare module 'thirdPartyLib' {
  interface User {
    name: string;
  }
}

// 在你的项目中扩展这个接口
declare module 'thirdPartyLib' {
  interface User {
    age: number;
  }
}

// 使用合并后的接口
import { User } from 'thirdPartyLib';

const user: User = {
  name: 'Alice',
  age: 25
};

21. TypeScript 中如何使用命名空间?

命名空间用于将一组相关功能组合在一起,避免命名冲突。例如:

namespace MyNamespace {
  export function greet() {
    console.log("Hello");
  }
}

22. TypeScript 中如何定义类和接口的关系?

interface Person {
  name: string;
  age: number;
}
class Employee implements Person {
  constructor(public name: string, public age: number) {}
}

23. 如何在 Node.js 中实现定时任务?

  1. setTimeout

    // 在 2000 毫秒后执行一次任务
    setTimeout(() => {
      console.log('定时任务执行了!');
    }, 2000);
    
  2. setInterval

    // 每隔 2000 毫秒执行一次任务
    const intervalId = setInterval(() => {
      console.log('定时任务执行了!');
    }, 2000);
    
    // 停止定时任务
    setTimeout(() => {
      clearInterval(intervalId);
      console.log('定时任务已停止!');
    }, 10000);  // 10秒后停止定时任务
    
  3. node-cron

24. 描述 RESTful API 和 GraphQL 的区别

  1. 数据获取方式 • RESTful API: • REST 是基于资源的,每个资源有一个唯一的 URL 地址,客户端通过 HTTP 请求访问这些资源。 • 每个 HTTP 请求代表一个操作(如 GET、POST、PUT、DELETE 等),每种操作对应于对某个资源的不同操作。 • 数据是由服务器返回的,客户端通常无法控制返回的字段。每次请求都可能获取完整的资源,导致可能的数据过多或过少。 示例: • GET /users/1 返回用户的完整信息。 • 如果你只需要用户的名字和邮箱,你仍然需要获取完整的用户对象。 • GraphQL: • GraphQL 是一种查询语言,客户端可以精确地指定需要哪些字段,而不需要返回所有数据。 • 客户端通过构建查询请求,选择自己需要的资源字段,并可以一次请求多个资源。 • 服务器响应是按需的,仅返回客户端请求的字段,避免了数据过多或过少的问题。

  2. 端点设计

    • ESTful API 的设计简单,易于理解和实现。
    • GraphQL 使用单一的端点来处理所有请求。客户端通过构建查询来定义需要哪些资源或字段。
  3. 请求与响应的灵活性

25. 如何处理和优化 Node.js 的错误处理和日志记录?

  1. 错误处理

    • 使用 try-catch 处理同步错误
    • 异步处理 .catch
    • async try
    • 未捕获异常, 使用process.on('uncaughtException', (error) => {
  2. 日志记录

    • console

    • winston

    • 日志结构化与持久化

      const express = require('express');
      const morgan = require('morgan');
      const fs = require('fs');
      const path = require('path');
      
      const app = express();
      
      // 创建一个写入流,日志将写入到 'access.log' 文件
      const logStream = fs.createWriteStream(path.join(__dirname, 'access.log'), { flags: 'a' });
      
      // 使用 morgan 中间件并将日志输出到文件
      app.use(morgan('combined', { stream: logStream }));
      
      // 示例路由
      app.get('/', (req, res) => {
        res.send('Hello, world!');
      });
      
      // 启动服务器
      app.listen(3000, () => {
        console.log('Server running on <http://localhost:3000>');
      });
      

26. node.js如何传BigInt给前端

  1. JSON 序列化问题:

    • JSON 的 stringify() 方法不支持直接序列化 BigInt 类型。当尝试将 BigInt 序列化为 JSON 时,JSON.stringify() 会抛出错误。

    • 为了解决这个问题,你可以将 BigInt 转换为字符串,然后在前端将其转换回 BigInt。

      const bigIntValue = BigInt("1234567890123456789012345678901234567890");
      res.json({ value: bigIntValue.toString() });
      
      // 自定义 BigInt 序列化
      BigInt.prototype.toJSON = function () {
        return this.toString();  // 将 BigInt 转换为字符串
      };
      const bigIntValue = BigInt("1234567890123456789012345678901234567890");
      res.json({ value: bigIntValue });  // 使用自定义的 toJSON
      
  2. 使用 BigInt 在前端:

    • 前端 JavaScript 也支持 BigInt(从 ECMAScript 2020 开始),但需要确保传递的 BigInt 数据能够正确地被解析和处理。

      <script>
        fetch('<http://localhost:3000/>')
          .then(response => response.json())
          .then(data => {
            // 将接收到的字符串转换回 BigInt
            const bigIntValue = BigInt(data.value);
            console.log(bigIntValue);  // 在控制台输出 BigInt
          })
          .catch(error => console.error('Error:', error));
      </script>
      

React

1. React的生命周期函数都有哪些?你如何在函数组件中使用它们?

React的生命周期函数分为三个阶段:挂载、更新和卸载。在类组件中有如 componentDidMount, componentDidUpdate, componentWillUnmount 等方法。在函数组件中,可以通过 useEffect 来模拟这些生命周期方法。例如,useEffect(() => , [])。

2. React中的“虚拟DOM”是什么?它是如何提高性能的?

虚拟DOM是React对DOM的抽象表示。每次组件更新时,React会先在虚拟DOM中进行比较(称为diffing算法),然后只对比发生变化的部分进行更新,而不是重新渲染整个DOM,这样就显著提高了性能。

3. 什么是React中的“hooks”?请解释一下useState、useEffect、useContext、useReducer的使用场景

  • useState: 用于在函数组件中管理状态。const [count, setCount] = useState(0);
  • useEffect: 用于副作用操作,如数据获取、订阅等。它相当于类组件的 componentDidMount 和 componentDidUpdate。
  • useContext: 用于访问上下文值,它让多个组件能够共享状态或功能。
  • useReducer: 当状态逻辑比较复杂时,使用 useReducer 比 useState 更合适,它通过“reducer”函数来管理状态。

4. 什么是“高阶组件”(HOC)?举一个例子说明如何使用它

高阶组件(HOC)是一个接受组件作为输入并返回一个新的组件的函数。它通常用于组件的逻辑复用。例如,withLoading HOC可以包装一个组件,使它在加载数据时显示loading状态。

5. React中的“key”属性有什么作用?为什么它对列表渲染性能很重要?

key用于帮助React识别哪些元素改变、添加或删除,从而高效地更新DOM。如果没有key,React会根据索引重新渲染整个列表,这会影响性能。key的值应该是唯一且稳定的。

6. 当有复杂数据请求, 排序和用户操作时, 使用react如何组织组件?

  1. 分层组件结构

    将复杂的组件分解成多个子组件,每个组件负责不同的功能。通常的分层组件结构包括:

    • Container (容器组件):负责获取数据,处理排序、过滤、分页等逻辑,并传递数据和回调函数给展示组件。
    • Presentational (展示组件):负责渲染UI,不关心数据如何获取或处理。接收数据和回调作为 props。
    • State Management (状态管理):对于全局或共享状态的管理,可以使用 React Context 或第三方库如 Redux、Recoil 等。
  2. 数据请求和排序逻辑

    • 使用 useEffect 来进行数据请求。
    • 使用 useState 来管理数据和排序状态。
    • 用户操作(如点击排序按钮)时,更新 useState 状态,从而触发重新渲染。
    • 如果数据量非常大,考虑使用分页或懒加载。
  3. 控制排序和过滤 排序和过滤通常是用户操作中的常见需求。我们可以通过以下方式来管理:

    • 排序:可以通过改变排序状态(升序或降序),并重新排序数据。
    • 过滤:根据用户的输入过滤数据,通常与搜索框结合。
  4. 总结

    • 组件拆分: 将容器组件和展示组件分开,确保逻辑和UI分离。
    • 状态管理: 用 useState 和 useEffect 管理数据请求和用户交互。
    • 性能优化: 考虑数据量较大时的分页和懒加载机制,避免一次性加载过多数据导致性能问题。

7. 乐观操作

乐观操作(Optimistic UI) 是一种用户界面设计模式,允许用户在等待服务器响应的过程中看到操作的结果。简而言之,乐观操作会“提前假设”操作成功,从而更新UI,提升用户体验,避免因等待响应而产生的延迟感。

实现乐观操作的思路

  1. 更新 UI: 在发起请求之前,立即在前端应用中更新界面,让用户看到操作已生效。
  2. 服务器请求: 向服务器发送请求,进行实际的操作(例如创建、更新、删除等)。
  3. 恢复操作: 如果请求成功,保留前端的更新。如果请求失败,恢复到操作前的状态,或者显示失败信息。

Express

1. Express中的中间件是什么?如何使用它来处理请求和响应?

中间件是处理请求和响应的函数,可以在请求到达路由处理之前或之后执行。它可以用于日志记录、身份验证、请求体解析等。示例:app.use(express.json()); 来解析JSON请求体。

2. 如何在Express中处理错误?请举例说明如何使用错误处理中间件

错误处理中间件需要放在路由和其他中间件之后,通过next(err)将错误传递给它。示例:

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).send('Something went wrong!');
});

3. 如何在Express中设置路由,支持GET和POST请求?

可以使用 app.get() 和 app.post() 来处理不同类型的请求。例如:

app.get('/home', (req, res) => {
  res.send('Home Page');
});

app.post('/submit', (req, res) => {
  res.send('Form Submitted');
});

4. Express中的请求体(Request Body)如何处理,如何解析JSON和表单数据?

使用express.json()来解析JSON数据,express.urlencoded()来解析表单数据

5. 如何在Express中进行认证和授权?你如何使用JWT实现身份验证?

JWT(JSON Web Token)可以在Express中用于认证。通过中间件验证请求头中的token,来授权用户访问受保护的资源。

6. 如何优化Express应用的性能?

可以使用缓存、压缩响应、异步处理、负载均衡等方式来优化Express应用的性能。