普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月10日首页

第11章 LangChain

作者 XiaoYu2002
2026年1月10日 16:28

LangChain 是一个用于开发基于大语言模型(LLM)应用程序的开源框架,它通过提供模块化的抽象组件和链式调用工具,将 LLM 与外部数据源(如文档、数据库)和计算工具(如搜索引擎、代码解释器)智能连接,从而构建出具备记忆、推理和行动能力的增强型 AI 应用,典型场景包括智能问答、内容生成和智能体(Agent)系统。

LangChain官方文档:docs.langchain.com/。网址的组成逻辑和Ne…

LangChain支持Python和TypeScript两种编程语言,如图11-1所示。

image-20251219041533100

图11-1 LangChain开源代理框架-语言选项

LangChain开源代理框架有3种选择方案:

(1)LangChain:一些普通对话,音频识别、文字生成,图片生成等等与AIGC相关的,用这选择方案就够了。

(2)LangGraph:想做工作流,类似Dify,Coze,那么就需要使用该方案。

(3)Deep Agents:想做一些大型的AI相关高级应用,就需要使用该方案,Deep Agents是深度集成的意思。

LangChain 作为基础框架,适合构建常规的AIGC应用(如对话、文生图);LangGraph 专注于通过有状态、可循环的图结构来编排复杂、多步骤的智能体工作流,是开发类Dify/Coze平台或自动化业务流程的核心选择;而 Deep Agents 则代表了一种更深度集成、能处理高复杂度任务与自主决策的高级智能体架构,常用于需要多智能体协作或模拟人类工作流的大型企业级AI应用。

我们这里学习的话,使用第一个LangChain就完全够用。LangChain下载使用说明如图11-2所示。我们点击如图11-1所示的第一个选项后,会跳转到如图11-2所示的界面,需要点击左侧边栏的install选项。官方文档有对应的使用说明。

image-20251219042243761

图11-2 LangChain下载使用说明

## 11.1 初始化项目

接下来,我们要开始初始化这次的项目。会沿用第10章 SSE魔改的代码,在该基础上,需要补充以下安装步骤:

(1)@langchain/core:LangChain 的核心基础库,包含链、提示模板、检索器等核心抽象。

(2)@langchain/deepseek: LangChain 为DeepSeek 模型专门提供的集成包,让我们能在 LangChain 框架中直接调用 DeepSeek 的 API。安装规则是@langchain/所需AI大模型,例如@langchain/openai。

(3)langchain:LangChain 的主包,提供了高级、易于使用的接口来组合和使用 LLM。

安装LangChain之后,我们需要一个AI大模型来支持,在这次示例中,选择DeepSeek,因为它的API非常便宜。

// 终端执行命令
npm install @langchain/core @langchain/deepseek langchain

安装之后的package.json文件如下所示。

{
  "type": "module",
  "dependencies": {
    "@langchain/core": "^1.1.6",
    "@langchain/deepseek": "^1.0.3",
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "cors": "^2.8.5",
    "express": "^5.2.1",
    "langchain": "^1.2.1"
  }
}

由于我们在7.1小节已经升级过Node.js版本,因此可以直接用Node.js去运行ts后缀文件,满足Node.js版本大于23的都可以这么做,如果无法运行ts后缀文件,需要检查一下Node.js版本或者采用ts-node。

接下来到index.ts文件中初始化后端服务。

// index.ts
import express from 'express'
import cors from 'cors'

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => { })

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

11.2 接入大模型

接着接入我们的AI大模型DeepSeek,还是在index.ts文件中。

在这里引入了一个key.ts文件,该文件存放着DeepSeek API的Key。

import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
const deepseek = new ChatDeepSeek({
    apiKey: key,
    model: 'deepseek-chat',
    temperature: 1.3,
    maxTokens: 1000, //500-600个汉字
    topP: 1, //设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
    frequencyPenalty: 0,//防复读机诉 AI:"你别老重复同一个词!"-2   2
    presencePenalty: 0,//鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

获取DeepSeek的key,如下4步骤:

(1)打开DeepSeek的API开放平台:platform.deepseek.com/

(2)微信扫码登录,去实名认证。

(3)点击左侧边栏的用量信息选择去充值选项,有在线充值和对公汇款两个选项,选择在线充值,自定义输入1块钱(够用了),然后自己选择支付宝或者微信支付去付款。

(4)付款成功后,点击左侧边栏的API keys选项,创建API key,随便输入一个名称(中英文都可以),然后会弹出key值,此时复制key值再关闭弹窗,因为当你关闭后,就再也拿不到这个key值了。忘记就只能重新再建一个,DeepSeek会提醒你的,如图11-3所示。

友情提示:保护好你的key值,别暴露在公网中,在开源项目上传GitHub中,可以让git忽略key.ts文件。或者如果你的DeepSeek API就只充了1块钱,然后用得剩几毛钱,并且以后都不怎么打算充,那想不想保护key值,就看你心情了。

image.png

图11-3 创建API key注意事项

通过以上步骤获取到DeepSeek的key值后,在项目创建key.ts文件,创建常量key,填入你的key值并导出。

export const key = '你DeepSeek的key值'

回到index.ts文件,接入DeepSeek大模型之后,ChatDeepSeek有一个model字段,这是用于选择我们模型的。已有的模型类型需要从DeepSeek官方文档中获取:模型 & 价格 | DeepSeek API Docs

// DeepSeek字段
apiKey: key,
model: 'deepseek-chat',
temperature: 1.3,
maxTokens: 1000, // 500-600个汉字
topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
frequencyPenalty: 0,// 防复读机诉 AI:"你别老重复同一个词!"-2   2
presencePenalty: 0,// 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2

目前可选的模型有deepseek-chat和deepseek-reasoner。DeepSeek官网价格计算是以百万Token为单位,其中1 个英文字符 ≈ 0.3 个 token;1 个中文字符 ≈ 0.6 个 token。只要大概知道很便宜就足够了。

image-20251219050542592

图11-4 DeepSeek模型选择

temperature字段是温度的含义,在DeepSeek官方文档中有直接给出对应的建议,我们的示例是打算用于对话,因此设置1.3就足够了,Temperature参数设置如图11-5所示。

从应用场景,我们可以理解为temperature字段大概是理性与感性的权衡度,逻辑性越强的场景,温度越低;越感性的场景温度越高。所有AI大模型都是类似的,从他们对应的官方文档去获取对应信息就可以了。

image-20251219050952588

图11-5 DeepSeek模型-Temperature参数设置

其余4个参数maxTokens、topP,frequencyPenalty和presencePenalty如下:

(1)maxTokens 直接决定了 AI 回复的最大长度,限制了单次响应的文本量;

(2)topP(核采样参数)通过控制候选词的概率分布来影响文本的创造性与稳定性——值越低则 AI 的选词越集中和可预测,输出趋于“死板”,值越高则选词范围越宽,输出越“放飞自我”并富有创意。

(3)而 frequencyPenalty 与 presencePenalty 则分别从词频和话题层面抑制重复:frequencyPenalty 正值会惩罚在当前回复中已经频繁出现的词语,促使用词更加多样;presencePenalty 正值则会惩罚在已生成的上下文中出现过的所有主题,鼓励 AI 主动切换到新的话题或角度,从而共同确保生成内容的多样性和连贯性,避免陷入单调或循环重复的表达。

这些值具体设置多少,则需要根据具体场景的经验以及自身的理解,推荐看我写的AI使用手册,开头有讲解到这一部分注意力机制:AI精准提问手册:从模糊需求到精准输出的核心技能(上)

11.3 AI对话

接下来需要从langchain引入createAgent方法,并使用我们设置好的deepseek实例对象。我们调用agent身上的invoke()方法,该方法更适合单次输出(一次性直接返回),即非流式返回。

通过createAgent方法除了可以设置接入的大模型,还可以通过systemPrompt字段去设置Prompt提示词。

通过LangChain代理的stream()方法调用DeepSeek模型处理用户请求:将客户端发送的req.body.message作为用户消息输入,并设置streamMode为 "messages" 来获取结构化的消息流响应;在等待代理完成流式生成后,将整个结果集作为JSON数据一次性返回给客户端。

import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/json')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.invoke({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  })
  res.json(result)
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

接下来回到index.html文件一下,我们需要设置客户端返回给后端的问题,也就是往req.body.message里塞一下咨询AI的问题。

<script>
    fetch('http://localhost:3000/api/chat', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ message: '请问你是什么AI大模型' })
    }).then(async res=>{
        const reader = res.body.getReader()
        const decoder = new TextDecoder()
        while (true) {
            const { done, value } = await reader.read()
            if (done) {
                break
            }
            const text = decoder.decode(value, { stream: true })
            console.log(text)
        }
    })
</script>

我们问了一个:“你是什么AI大模型”的问题,浏览器返回AI对话信息如图11-6所示。

image-20251219054656481

图11-6 DeepSeek模型-Temperature参数设置

到这为止,我们就正式打通了AI对话的环节。并且如果我们打开网络选项卡,可以发现AI对话返回的内容是post请求的。如果我们想改成流式输出也是post请求,在第10.3小节所学习的SSE设置post请求就可以用上了。

11.4 流式输出AI对话

如果我们想修改成流式输出对话的话,需要修改3个地方:

(1)后端设置的Content-Type类型改成事件流类型。

(2)agent不使用invoke()方法,该换专门的agent.stream()流输出方法,并调整对应参数。

(3)agent.stream()流输出方法返回迭代器,针对迭代器去调整输出形式。

import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "messages" })
  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

agent.stream()方法有第二个参数,用于指定流式输出的数据格式和粒度,决定了从流中接收到的是原始令牌、结构化消息还是其他中间结果。agent.stream()方法第二个参数的选项如表11-1所示。我们选择messages就可以了。如果想要真正存粹的打字效果并且节约token,可以使用values选项。

表11-1 agent.stream()方法第二个参数的选项

流模式 返回的数据类型 典型用途 示例输出(逐块)
"messages" 完整的消息对象 需要处理结构化对话(如获取AI回复的完整消息) {"role": "assistant", "content": "你好"}
"values" 底层值(如原始token) 需要实现逐字打印效果或最低级控制 "你" "好"
"stream" 混合事件流 需要同时获取token和消息等多样信息 {"type": "token", "value": "你"}
const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "values" })

其次,由于agent.stream()方法的返回值类型是IterableReadableStream<StreamMessageOutput>,说明返回值就是一个迭代器。因此可以使用for await of语法糖来流式输出内容,不用手动的去调用迭代器的next()方法。

  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()

AI对话-流式输出如图11-7所示。会按顺序返回非常多的JSON格式数据,通过data字段下的kwargs的content可以看到AI返回内容以三两字的形式不断输出。并且在前端的接收流式输出,不会因post请求而出现问题。

image-20251219060557208

图11-7 AI对话-流式输出

流式输出AI对话的完整代码如下:

// index.ts
import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "messages" })
  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})
// index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <script>
    fetch('http://localhost:3000/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ message: '请问你是什么AI大模型' })
    }).then(async res => {
      const reader = res.body.getReader()
      const decoder = new TextDecoder()
      while (true) {
        const { done, value } = await reader.read()
        if (done) {
          break
        }
        const text = decoder.decode(value, { stream: true })
        console.log(text)
      }
    })
  </script>
</body>

</html>

以上就是使用LangChain接入DeepSeek大模型并实现AI对话和基础提示词的案例。

昨天以前首页

第10章 SSE魔改

作者 XiaoYu2002
2026年1月9日 15:57

SSE(Server-Sent Events,服务器推送事件) 是一种基于标准HTTP协议的服务器到客户端的单向数据流技术。它允许服务器在建立初始连接后,通过一个持久的HTTP连接主动、连续地向客户端推送数据更新,而无需客户端重复发起请求。其核心机制是客户端使用 EventSource API 连接到指定端点后,服务器以 text/event-stream 格式持续发送事件流,每个事件由标识类型(event:)、数据(data:)和可选ID组成,客户端通过监听事件类型来实时处理数据,连接中断时还会借助最后接收的ID自动尝试重连。

与需要双向通信的WebSocket相比,SSE的典型优势在于协议轻量、天然支持自动重连与断点续传,且无需额外协议升级。它非常适合服务器主导的实时数据分发场景,如股市行情推送、新闻直播、社交媒体动态、任务进度通知等,浏览器兼容性广泛。但需要注意的是,SSE是单向通道(服务器→客户端),且主流实现中传输格式限于文本(二进制数据需编码),若需双向实时交互则仍需选择WebSocket。

通过以上对SSE的解释,我们可以想到现如今非常经典的例子,AI网站中输出文字的打字机效果(例如DeepSeek),一个字一个字的往外输出,这也是一种SSE。前端给后端发送一次消息,而后端可以给前端一直发消息。

10.1 初始化项目

我们采用Express来模拟SSE,因此需要如下3个步骤来初始化项目:

(1)创建index.html和index.ts文件。

(2)安装express和对应的声明文件,并引入index.ts文件中。

(3)安装cors和对应的声明文件,并引入index.ts文件中。

两个index文件用于展示效果以及编写SSE逻辑。

// 安装express
npm i express
// 安装 CORS(跨域资源共享中间件)
npm install cors
// 安装 Express 的 TypeScript 类型定义
npm install --save-dev @types/express
// 安装 CORS 的 TypeScript 类型定义
npm install --save-dev @types/cors

// 一次性安装所有依赖
npm install express cors @types/express @types/cors

对应的package.json文件中,将type字段设置为module,从而可以使用import引入写法。

// package.json
{
  "type": "module",
  "dependencies": {
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "cors": "^2.8.5",
    "express": "^5.2.1"
  }
}

在index.ts文件使用ES模块语法引入依赖模块express和cors后,创建Express应用实例的app对象,然后在app对象中,通过use()方法挂载必要的全局中间件cors()和express.json(),用于处理跨域资源共享以及解析请求体中格式为JSON的数据。

// index.ts
import express from "express";
import cors from 'cors'

const app = express()
// 处理跨域
app.use(cors())
// 解析请求体中格式为JSON的数据
app.use(express.json())

最后,启动Express服务器,监听3000端口,完成SSE的项目初始化。

app.listen(3000, () => {
    console.log("Server is running on port 3000");
});

10.2 SSE逻辑实现

SSE要求接口必须是一个get请求,因此我们来定义一个get请求。

SSE的核心代码只有一行,将Content-Type设置为text/event-stream。通过将HTTP响应的内容类型明确声明为事件流格式,通知客户端(通常是浏览器)本次连接并非普通的请求-响应交互,而是一个需要保持开启、持续接收服务器推送事件的长连接通道。浏览器接收到这个特定头部后,会启动其内建的SSE处理机制(EventSource API),自动保持连接活性并准备以流式方式解析后续传入的数据。

返回数据的格式一定要遵循data: {实际数据}\n\n的形式。

// index.ts
app.get('/chat', (req, res) => {
  res.setHeader("Content-Type", "text/event-stream"); // 返回SSE
  // 不缓存
  res.setHeader("Cache-Control", "no-cache");
  // 持久化连接
  res.setHeader("Connection", "keep-alive");
  // 定时器,每秒返回一次时间,模拟后端连续地向客户端推送数据更新
  setInterval(() => {
    res.write(`data: ${new Date().toISOString()}\n\n`);
  }, 1000);
});

完成后端SSE的逻辑之后,前端需要如何接受后端传递过来的数据?通过浏览器内置的 EventSource API 来建立连接并接收后端SSE事件流数据就可以。

// index.html
const sse = new EventSource("http://localhost:3000/chat");
sse.onmessage = (event) => {
console.log(event.data);
};

此时启动后端服务器,打开index.html页面的控制台看流式输出时间,即前端可以实时接收后端返回的数据,如图10-1所示。

image-20251219033233783

图10-1 流式输出时间

此时打开网络选项卡,输出效果如图10-2所示。chat接口的EventStream会不断的接收message类型的消息,并展现对应的数据。

image-20251219033452742

图10-2 网络选项卡展示输出效果

10.3 SSE设置post请求

但一般在实际的项目中,是不会使用EventSource的,因为它必须是一个get请求,而在工作中经常使用的是post请求。那面对这种冲突的情况,应该如何去做?

如果我们只是在后端简单的将get请求直接改成post请求,然后重启服务去看效果的话,是无法生效的。

// index.ts
app.post('/chat', (req, res) => {
  //  省略...
});

chat接口修改成post请求如图10-3所示。请求方法依然为GET,网络状态则是404。

image-20251219034035386

图10-3 chat接口修改成post请求

面对后端chat接口修改为post请求不起效果的情况,我们只能在前端去魔改方案,不使用EventSource API来建立连接并接收后端SSE事件流数据。

我们在前端使用fetch()去接收chat接口返回的数据,此时从浏览器的的网络选项卡可以看到接通了,并且从响应选项中会不断打印出时间数据。

fetch("http://localhost:3000/chat", {
  headers: {
    "Content-Type": "application/json",
  },
  method: "POST",
  body: JSON.stringify({ message: "Hello, world!" }),
})
  .then(async response => {
    const reader = response.body.getReader(); // 获取流
    const decoder = new TextDecoder(); // 解码ASCII码值
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      console.log(value) // value是ASCII码
      const text = decoder.decode(value, { stream: true });
      console.log(text);
    }
  })
let a = [1]
let b = a[Symbol.iterator]();
console.log(b.next());
console.log(b.next());

现如今基本上都是通过fetch去魔改实现,将get请求修改成post请求也能传输数据。目前为止,没有更好的解决方法了。

在这段魔改的代码中,我们做了什么?

首先是设置请求头接收的内容类型以及请求方式。当接收到数据时,通过getReader()方法获取流,获取流得到的是一个Promise,因此需要通过await操作符去等待Promise兑现并获取它兑现之后值,通过read()方法去读取数据中的每一个流。

此时每一个流返回的是一个迭代器,迭代器是一个对象,内部有一个next()方法,该方法返回具有两个属性的对象:

(1)value:迭代序列的下一个值。

(2)done:如果已经迭代到序列中的最后一个值,则它为 true。如果 value 和 done 一起出现,则它就是迭代器的返回值。

所以迭代器对象可以通过重复调用next()方法显式地迭代。在while循环中持续调用reader.read()方法,这个方法返回的Promise在每次兑现时都提供一个类似迭代器next()方法的对象——包含value(当前数据块)和done(流是否结束)两个属性。通过循环判断done是否为false,我们可以持续读取Uint8Array格式的数据块,然后使用TextDecoder将其解码为可读文本,实现了对服务器推送数据流的实时逐块处理。

这种显式迭代的核心优势在于按需、增量地处理数据,避免了等待整个响应体完全到达才能开始处理。每次调用reader.read()都明确请求下一个数据块,直到done变为true表示流已结束。这与传统的一次性接收完整响应形成对比,特别适合处理SSE这种持续、长时间的数据流连接,确保了在处理服务器实时推送时内存使用的高效性和响应的即时性。

第9章 Three.js载入模型GLTF

作者 XiaoYu2002
2026年1月8日 15:49

9.1 初始化载入模型

GLTF(GL Transmission Format)是一种专为Web和实时应用设计的高效3D模型文件格式,被称为“3D领域的JPEG”。它采用JSON结构描述场景层级、材质、动画等元数据,并将纹理、几何体等资源以二进制或独立文件形式存储,从而实现小体积、快速解析和完整功能支持。

在Three.js中,通常使用GLTFLoader加载GLTF文件,加载后的模型以场景图形式呈现,可直接融入现有三维场景并进行动画控制、交互调整等操作,成为连接专业建模工具(如Blender)与WebGL应用的关键桥梁。

我们载入一个车的模型,需要对应的文件scene.gltf可以找我获取。

Three.js使用模型依旧需要通过场景,步骤如下3步:

(1)创建场景。

(2)加载GLTF文件(模型)。

(3)将加载后的模型放入场景中。

载入的GLTF模型,可以简单的理解为是一个物品,即被观察对象。因此像相机、渲染器以及轨道控制器依旧是需要的。GLTF模型只是场景中的一部分,而非全部。以下示例是载入一辆汽车,如果需要的话,可以载入更多的模型或者与网格混合去使用。

import * as THREE from "three"
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'

// 1. 创建场景
const scene = new THREE.Scene()

// 2. 加载GLTF文件(模型)
const loader = new GLTFLoader();
loader.load('./car/scene.gltf', (gltf) => {
  // 3. 将加载后的模型放入场景中
  scene.add(gltf.scene)
})

//创建相机
const camera = new THREE.PerspectiveCamera(30, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 20);
scene.add(camera);

//创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
renderer.render(scene, camera);

//创建轨道控制器

const controls = new OrbitControls(camera, renderer.domElement);

const animate = () => {
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, camera);
}
animate();

GLTF模型-车模型展示如图9-1所示,可见的只有车灯部分,车模型其余位置乌漆嘛黑的,这意味着所载入的模型的材质也是受光照影响的。

image-20251219013349868

图9-1 GLTF模型-车模型展示

9.2 添加灯光

受光照影响其实对于车模型来说是一件正常的事情,因为光照对GLTF车模的塑造优势非常明显,当平行光以低角度掠过车身时,钣金件上连续的高光会沿特征线精确流动;而聚光灯从侧前方投射时,不仅能在轮毂辐条间雕琢出复杂的镂空阴影,更能让前格栅的立体造型与保险杠的进气口结构产生戏剧性的深浅对比,将静态模型转化为具有视觉叙率的动态展示。

从技术实现层面,专业导出的GLTF模型通常内嵌基于物理的渲染材质,能与Three.js的光照系统深度互动。金属漆材质在环境光遮蔽下会呈现细腻的颗粒层次,车窗玻璃的菲涅尔反射会随观察角度动态变化;通过搭配HDR环境贴图,车体表面不仅能反射模拟天空盒的柔光,更能精准呈现周围虚拟环境的倒影,使烤漆表面产生深邃的镜面质感与色彩饱和度。这种光照与材质的协同作用,让模型摆脱了“玩具感”,在网页中还原出接近影视级的真实材质表现与空间沉浸感。

//添加环境光
const light = new THREE.DirectionalLight(0xffffff, 100);    
light.position.set(100, 100, 100);
scene.add(light);

我们在相机创建之前添加环境光,然后加入场景中。车模型光照展示如图9-2所示。可以看见车的完整结构,随着鼠标的拖拽,可以看到不同角度下的车模型和对应反光,通过鼠标滚轮更是可以拉近或者远离视角。

image-20251219014028187

图9-2 GLTF模型-车模型光照展示

当我们通过loader.load()加载GLTF模型时,可以拿到回调参数gltf,回调参数gltf是一个结构化的对象,它是我们操作整个加载模型的入口。最常用的是gltf.scene,这是整个加载模型的根场景对象,类型为 THREE.Group 或 THREE.Scene。需要将它加入主场景中才能显示。

除此之外还有gltf.scenes和gltf.animations,分别如下:

(1)gltf.scenes:多场景情况,一个包含所有场景的数组。大多数GLTF文件只有一个场景,此时 gltf.scenes[0] 等价于 gltf.scene。如图9-3的scenes为[Group]就等同于scene字段里的内容。

(2)gltf.animations:包含模型所有动画剪辑的数组。若模型有动画(如角色行走、机械运动),需配合THREE.AnimationMixer 使用。

其他的是额外信息,例如asset元信息中,包含了该模型从哪个网站下载的以及该GLTF模型的版本。cameras是内置相机,这个模型没有,所以是空数组。parser是GLTF的解析器,不需要去管,调用oader.load()方法时,方法内部会自动调用解析器去解析。userData是可以自定义的部分。回调参数gltf信息如图9-3所示。

image-20251219015023225

图9-3 回调参数gltf信息

目前我们的车模型是银白色的,如果想把车染成绿的,也是可以做到的。车模型也是通过各种材质去拼接而成的,所以只要找到车模型的材质,就可以调整车外表的样式。

而网格等于几何体加材质,这意味着需要我们去找到车模型的网格,找到网格就基本上找到材质了。从回调参数gltf中去找。位置如下:scenes[0].children[0].children[0].children(直接从scene开始也可以)。回调参数gltf信息的网格参数如图9-4所示。可以看到一共有59个网格,因此车模型实际是由59个网格拼接起来的。

image-20251219020232471

图9-4 回调参数gltf信息的网格参数

9.3 修改车模型颜色

找到了网格,我们就可以拿网格里面的材质去修改颜色。但网格有那么多,怎么知道要修改哪一个网格里的材质颜色?

想要精细化的去修改颜色需要对车模型是怎么拼起来的,知道每一个网格对应的位置才行。通常情况下,我们可以读取网格的名称(child.name)就能知道对应的哪一部分,但我们这个案例的车模型网格不太规范,网格的名称是Object_1~59,所以并不清楚哪一块网格对应车的哪个位置。这比较麻烦,我们如果只是单纯想给车变个色,可以直接遍历childs变量,拿到所有的网格,然后从网格中拿到material材质,直接修改颜色为0x00ff00(绿色)。

const loader = new GLTFLoader();
loader.load('./car/scene.gltf', (gltf) => {
  console.log(gltf)
  const childs = gltf.scene.children[0].children[0].children
  console.log(childs)
  for (const child of childs) {
    console.log(child.name)
    if (child instanceof THREE.Mesh) {
      child.material.color.set(0x00ff00);
    }
  }
  scene.add(gltf.scene);
});

车模型-绿色如图9-5所示。可以看到几乎所有的颜色除了车窗之外全变绿色的了。说明是可以拿到车模型的网格,而拿到网格就可以对几何体或者材质去针对性的修改,从而使车模型变成我们真正想要的效果。

image-20251219021105035

图9-5 车模型-绿色

第8章 Three.js入门

作者 XiaoYu2002
2026年1月7日 14:06

8.1 初始化项目

three.js 是一个基于JavaScript 的WebGL 引擎,可直接在浏览器中运行GPU 驱动的游戏与图形驱动的应用。 three.js 的库提供了大量用于在浏览器中绘制3D场景的特性与API。我们的入门就基于three.js库去调用对应的API。

需要完成的前置条件如下4点:

(1)创建一个空项目THREEJS。

(2)创建index.html和main.ts文件,用于后续编写示例代码。

(3)安装three.js库和对应声明文件。

(4)安装Vite用于启动项目。

// 安装three.js库和对应声明文件
npm i three
npm i --save-dev @types/three
// 安装Vite用于启动项目
npm i vite -D

其中index.html是作为展示3D场景界面的文件,然后需要导入main.ts文件,main.ts文件是作为编写three.js的代码文件。

// index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html,
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
    }
  </style>
</head>

<body>
  <script src="./main.ts" type="module"></script>
</body>

</html>

接着到package.json文件中配置vite的启动命令,然后启动项目。

// package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "three": "^0.182.0"
  },
  "devDependencies": {
    "@types/three": "^0.182.0",
    "vite": "^7.3.0"
  }
}

8.2 案例搭建

完成项目的初始化并启动项目后,项目界面是一片空白,因为我们还没有编写对应代码,接下来回到main.ts文件中,完成以下3点操作:

(1)引入three库。

(2)创建场景

(3)创建网格。

创建场景永远是第一件要做的事情,它是所有3D物体的容器,就像在2D Canvas中必须先获取画布上下文一样基础。接着是创建网格,three.js中的所有可见的3D物体都是基于网格去组成,网格可以有多个,网格包含几何体和材质。

几何体(Geometry):定义了物体的形状,即顶点、面等结构信息。

材质(Material):定义了物体表面的外观,例如颜色、纹理、光滑度等。

创建网格也意味着需要创建几何体和材质,然后放入网格中。几何体有多种形状,对应不同方法,填入不同参数;材质也有多种材质选择,通过不同方法去操作。

import * as THREE from 'three';

//创建场景
const scene = new THREE.Scene();

//创建几何体
const geometry = new THREE.BoxGeometry(100, 100, 100); // x,y,z三轴

//创建材质
//MeshBasicMaterial 这个材质是不受光照影响
//MeshLambertMaterial 这个材质是受光照影响 漫反射材质
//MeshPhongMaterial 这个材质是受光照影响 镜面高光
const material = new THREE.MeshBasicMaterial({color: 0x00ff00});

//网格 几何体 + 材质 可以有多个
const mesh = new THREE.Mesh(geometry, material);

//将网格添加到场景中
scene.add(mesh);

网格需要包含几何体和材质是很好理解的,几何体是物品的形状,而材质是物品的表面,网格就是将两者结合起来的“完整物体”,类似于3D建模。

创建网格属于场景的部分,而一个最简的Three.js代码结构需要包含三个核心组件:

(1)Scene(场景):是舞台。所有演员、道具、灯光都必须放在这个舞台上。

(2)Camera(相机):是摄像机。它决定了你从哪个角度、以何种视野去观看舞台。

(3)Renderer(渲染器):是负责把摄像机拍到的画面,实际绘制到屏幕画布上的“渲染引擎”。没有它,一切准备都只是数据,看不到图像。

因此创建网格并填充对应的几何体和材质意味着我们拥有了一个最简单的物品填入场景中作为被观察对象(网格需要添加到场景中),在这之后还需要创建相机和渲染器。接下来我们开始创建相机。

相机通过THREE.PerspectiveCamera()创建,需要四个参数分别是:视野角度(fov)、宽高比(aspect)、近裁剪面(near)、远裁剪面(far)。它们一起构成了一个视锥体,决定了相机能看到什么。视野角度控制可见范围的垂直开合程度,类似摄像机的镜头焦距;宽高比确保渲染不变形,通常直接使用窗口比例;远近裁剪面则定义了相机能看清的最小和最大距离,就像人眼的最近视点和最远视点。相机视角如图8-1所示。

image.png

图8-1 相机视角

在我们以下代码示例中,第一个参数 75 是垂直视野角度,类似人眼睁开的角度,值越大看到的场景越广;第二个参数 window.innerWidth / window.innerHeight 是宽高比,通常设置为渲染区域的宽除以高,以确保物体不被拉伸变形;第三个参数 0.1 是近裁剪面,表示相机能看清的最短距离,比这更近的物体将被裁剪不可见;第四个参数 1000 是远裁剪面,表示相机能看清的最远距离,比这更远的物体同样不可见,这四个参数共同划定了相机在三维空间中实际能观察到的范围。

接着我们需要设置相机放置的位置,就和现实一样,拍摄所在的位置决定了画面的叙事视角、视觉重点和情感基调。将相机靠近物体并采用低角度,能像电影特写一样赋予主体压迫感和权威性,常用于突出核心元素或营造紧张氛围;反之,将相机拉远并提升高度,则形成俯瞰式的宏观视角,适合展现场景全貌、空间关系或个体的渺小感。通过精确控制相机与主体的距离、高度和角度,能够决定整个场景是通过一个“第一人称”的沉浸式窗口呈现,还是作为一个“上帝视角”的客观全景被观察。

最后,我们需要将相机加入场景中,正如前面所说的所有演员、道具、灯光都必须放在这个舞台(场景)上。

// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 设置相机放置的位置
camera.position.set(0, 0, 400);
// 将相机加入场景
scene.add(camera);

最后,我们需要创建渲染器,将摄像机拍到的画面,实际绘制到屏幕画布。

// 创建WebGL渲染器实例,这是Three.js用来绘制3D场景的核心工具
const renderer = new THREE.WebGLRenderer();
// 设置渲染器输出画布的尺寸为整个浏览器窗口的宽度和高度
renderer.setSize(window.innerWidth, window.innerHeight);
// 将渲染器自动生成的<canvas>画布DOM元素添加到网页的<body>中,这样画面才能显示出来
document.body.appendChild(renderer.domElement);
// 执行一次性的渲染操作:命令渲染器从指定相机(camera)的视角,将场景(scene)中的所有物体绘制到画布上
renderer.render(scene, camera);

实际完整Demo代码如下:

如果创建材质选择MeshPhongMaterial这种受光照影响的要素,那么需要添加光照,否则看不见。如果你的画面看不到物体的话,你需要考虑去看下代码部分中的材质是否受光照影响。

import * as THREE from 'three';

//创建场景
const scene = new THREE.Scene();

//创建几何体
const geometry = new THREE.BoxGeometry(100, 100, 100); // x,y,z三轴

//创建材质
//MeshBasicMaterial 这个材质是不受光照影响
//MeshLambertMaterial 这个材质是受光照影响 漫反射材质
//MeshPhongMaterial 这个材质是受光照影响 镜面高光
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });

//网格 几何体 + 材质 可以有多个
const mesh = new THREE.Mesh(geometry, material);

//将网格添加到场景中
scene.add(mesh);


// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 设置相机放置的位置
camera.position.set(0, 0, 400);
// 将相机加入场景
scene.add(camera);

// 创建WebGL渲染器实例,这是Three.js用来绘制3D场景的核心工具
const renderer = new THREE.WebGLRenderer();
// 设置渲染器输出画布的尺寸为整个浏览器窗口的宽度和高度
renderer.setSize(window.innerWidth, window.innerHeight);
// 将渲染器自动生成的<canvas>画布DOM元素添加到网页的<body>中,这样画面才能显示出来
document.body.appendChild(renderer.domElement);
// 执行一次性的渲染操作:命令渲染器从指定相机(camera)的视角,将场景(scene)中的所有物体绘制到画布上
renderer.render(scene, camera);

Three.js创建的Demo画面如图8-2所示。

image-20251218205238843

图8-2 Three.js创建Demo画面

目前我们场景内只有一个正方块物体,正被相机拍摄着,但看着就像2D的画面。因此我们可以通过引入OrbitControls(轨道控制器)来实现拖动效果,从而实现3D效果。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
//创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);

const animate = () => {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
}
animate();

轨道控制器拖动效果如图8-3所示。由于目前正方体是没有边缘线的,因此静止的时候看起来更像是不规则的平面物体,我稍微添加了几条红色线条(不太规范)来辅助理解,大致能看出这是一个正方体。

image-20251218205731942

图8-3 轨道控制器拖动效果

以上是Three.js入门的一个简单案例。在创建轨道控制器的时候,我们添加了一个定时器,并且使用了递归,但不会出现死循环导致爆栈的情况。因为使用的是浏览器原生API requestAnimationFrame 实现的动画循环,它并不是传统意义上的递归死循环。

代码步骤思路为以下2步:

(1)requestAnimationFrame(animate):向浏览器“预约”下一帧,告诉浏览器:“在下次屏幕刷新绘制时,请调用animate函数”。它不会立即、连续地调用自身。

(2)浏览器控制节奏:浏览器会以屏幕刷新率(通常是60FPS,即每秒约60次) 的节奏来回调animate函数。当页面隐藏或最小化时,浏览器会自动暂停这些回调以节省资源。

所以通过requestAnimationFrame执行循环,每帧执行完后会释放主线程,等待浏览器下一次绘制时机(执行时机在DOM回流和重绘之前),浏览器牢牢掌控住绘制的运行时间间隔,甚至决定了什么时候会暂停,所以自然不会出现死循环的情况。

这种非阻塞的协作式循环在性能优化(与屏幕刷新同步,避免不必要的重复渲染),节能(页面不可见时自动暂停)和流畅动画(动画更新与屏幕刷新率一致)方面都很不错。这种技术被称为RAF技术。

const animate = () => {
  // 递归调用animate
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, camera);
}
animate();

通过以上思路,需要先有整体的场景,然后往场景里添加被观察对象(演员、道具、灯光等),接着是观察对象(相机),最后用渲染器将相机拍到的画面渲染出来。在这里场景是我们(导演)的视角,而摄像机才是观众的视角。相机所拍摄的部分才是我们想展现的部分。我们应该从实际摄影所带来的经验中去思考如何拍摄。

8.3 添加灯光

接下来,我们修改材质,将其设置为MeshPhongMaterial这种受光照影响的材质,然后加入灯光,看效果如何。

const material = new THREE.MeshPhongMaterial({ color: 0x00ff00 });

// 添加平行光源(模拟太阳光)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 1, 1).normalize();
scene.add(directionalLight);

模拟太阳光对材质进行照射如图8-4所示。可见物体呈现了不一样的质感。像太阳光属于平行光的一种,只能照射到正方体的正面,因此正方体背面还是不可见的。

image-20251218211611489

图8-4 模拟太阳光

在Three.js中,灯光是塑造三维空间体积感、材质属性和场景氛围的核心工具,本质上是通过模拟光线与物体材质的交互来定义视觉层次。主要分为四种基本类型:

(1)环境光提供均匀无方向的基底照明,如同阴天的漫射光,用于消除纯黑阴影;

(2)平行光模拟无限远处的光源(如太阳),发出平行光线,产生方向明确的阴影,适合户外场景;

(3)点光源从一个点向所有方向均匀辐射光线,像灯泡或蜡烛,能营造真实的衰减和柔和的明暗过渡;

(4)聚光灯则形成锥形的光束,像手电筒或舞台追光,带有清晰的照射范围和边缘衰减,常用于突出特定物体或制造戏剧性焦点。

实际应用中,通常需要组合多种灯光——例如用环境光奠定基调,再用平行光或点光源刻画主次和投影——才能构建出既有层次又自然可信的三维视觉空间。

❌
❌