普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月7日首页

前端跨页面通讯终极指南⑤:window.name 用法全解析

2025年12月7日 20:20

前言

在之前的文章里,介绍了 BroadcastChannel 的广播、postMessage 的灵活以及 MessageChannel 的精准。这些现代 API 为我们提供了标准的通信能力。

今天我们要介绍下——window.name。它不是一个为通信而生的 API,但是有其独特的“跨页面持久性”特性,成为解决跨域数据传输问题的方案之一。

1. window.name是什么?

window.name 是一个极其简单的属性,它的原始设计目的是用来设置或获取窗口(浏览器标签页)的名称。

// 设置窗口名称
window.name = 'My Awesome Window';

// 获取窗口名称
console.log(window.name); // 'My Awesome Window'

它有一个重要的特性:

只要是在同一个浏览器标签页中,即使页面发生了跳转(从一个域名到另一个域名),window.name 的值也不会被重置,它会一直保留。 更惊人的是,它的容量非常大,通常可以达到 2MB 左右!

2. window.name 的作用域

window.name 属性是属于每一个独立的窗口上下文(Window Context的。

  1. 父页面是一个窗口上下文,它有自己的 window.name
  2. iframe 子页面是另一个完全独立的窗口上下文,它也有自己window.name

它们是两个完全不同的变量,互不影响。当你在子页面中直接调用 window.name 时,你访问的是子页面自己name,而不是父页面的。

2.1 如何正确访问父页面的 window.name

虽然不能直接读取,但 iframe 提供了访问父窗口的路径:window.parent,可以通过iframe.contentWindow.name设置子页面的name。

关键点: iframe 的刷新只会重置它自己window.name,对父页面的 window.name 毫无影响。并且如果需要设置子页面的name,必须是同源。

只能读取同源子页面的name,否则会报错:

image.png

3. 实战案例

通过一个简单的例子来说明,具体看代码:

  1. 父页面 (parent.html)
<!DOCTYPE html>
<html lang="en">
<head>
    <title>父页面</title>
</head>
<body>
    <h1>这是父页面</h1>
    <p>父页面的 window.name: <strong id="parentName"></strong></p>

    <hr>
    <iframe src="child.html" id="myIframe" style="width: 100%; height: 300px; border: 1px solid black;"></iframe>

    <script>
        // 1. 设置父页面的 name
        window.name = "我是父页面的秘密数据";
        document.getElementById('parentName').textContent = window.name;

        console.log('父页面: 已设置 window.name =', window.name);
    </script>
</body>
</html>
  1. 子页面 (child.html)
<!DOCTYPE html>
<html lang="en">
<head>
    <title>子页面</title>
</head>
<body>
    <h2>这是 iframe 子页面</h2>
    <button onclick="location.reload()">刷新本页面</button>
    <hr>
    <p><strong>子页面自己的 window.name:</strong> <span id="childName"></span></p>
    <p><strong>通过 window.parent.name 访问父页面:</strong> <span id="parentNameFromChild"></span></p>

    <script>
        function updateDisplay() {
            // 读取子页面自己的 name
            document.getElementById('childName').textContent = window.name || '(空)';

            // 通过 window.parent.name 访问父页面的 name
            try {
                const parentName = window.parent.name;
                document.getElementById('parentNameFromChild').textContent = parentName;
            } catch (e) {
                document.getElementById('parentNameFromChild').textContent = '访问失败 (跨域?)';
            }
        }
        updateDisplay();

        // 为了演示,我们也可以设置一下子页面自己的 name
        window.name = "我是子页面自己的数据";
    </script>
</body>
</html>

子页面会显示:子页面自己的 window.name: 我是子页面自己的数据,子页面会显示:通过 window.parent.name 访问父页面: 我是父页面的秘密数据点击“刷新本页面”按钮后:子页面会重新加载,其自己window.name 会被重置为空字符串。但是,window.parent.name 的值依然是 我是父页面的秘密数据,完全不受影响。

4. 总结

最后总结一下:window.name作为浏览器的一个老古董方案,简单了解介绍下,如果需要通讯,还是推荐postMessage等通讯方式。

前端跨页面通讯终极指南④:MessageChannel 用法全解析

2025年12月7日 20:18

前言

上一篇介绍了Localstorage跨页面通讯的方式。在前面的文章中,介绍了多种跨页面通信方式,从适用于同源页面的 BroadcastChannel,到解决跨域的 postMessage。当多个通信进行混杂在一起,使用全局的message事件监听时,会通过各种类型判断消息来源进行处理。

那有没有一种方法,既能实现跨上下文通信,又能像打电话一样,建立起一条专属的、双向的、点对点的私密通道呢?

今天介绍一个方案——MessageChannel API。提供了一种更为优雅和私密的方式,建立起一条点对点的“专线电话”,让通信双方可以清晰地、无干扰地对话。

1. MessageChannel 是什么?

Channel Messaging API 的 MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。

核心是创建一个双向通讯的管道,这个管道包含两个相互关联的端口——port1port2。数据从 port1 发送,就只能由 port2 接收;反之,port2 发送的数据也只能被 port1 捕获,这种“点对点”的通讯模式,从根源上避免了数据被无关页面拦截的风险。

举个通俗的例子: BroadcastChannel 是“小区广播”,所有人都能听到;而 MessageChannel 就是“专线电话”,只有两个端口对应的设备能接通。

2. 如何使用

使用 MessageChannel 流程如下:

  1. 创建通道: 创建一个 MessageChannel 实例,包含两个端口属性:port1port2
const channel = new MessageChannel();

// channel.port1 和 channel.port2 都是 MessagePort 对象

image.png

  1. 监听消息: 在其中一个端口上设置 onmessage 事件处理器,用于接收来自另一个端口的消息。
channel.port1.onmessage = (event) => {
    console.log('收到消息:', event.data);
};
  1. 发送消息: 通过一个端口的 postMessage方法向另一个端口发送数据。
channel.port2.postMessage('Hello from port2!');
  1. 转移端口所有权MessageChannel 的威力在于可以将一个端口发送到另一个浏览上下文(例如 iframe)。这需要通过 window.postMessage 的第三个参数 transfer 来实现, 可以直接将端口下发到两个子iframe,直接进行通讯。
// 假设 iframe 是我们想要通信的目标
const iframe = document.querySelector('iframe').contentWindow;

// 将 port2 的所有权转移给 iframe
// 转移后,当前页面就不再拥有 port2,只有 iframe 能使用它

iframe.postMessage('init', '*', [channel.port2]);

使用postMessage发送端口会出现ports:

image.png

接收方(iframe)可以在其 message 事件中获取到这个端口,开始通信。

3. 实践场景

下面使用MessageChannel进行父子双向、兄弟通讯进行说明。

3.1 父子双向通讯

父页面引入一个 iframe,创建MessageChannel,初始化时将其中一个port1使用postMessage传递,后面直接通过port进行双向通讯。

步骤1:父页面(发送端口+通讯逻辑)

// 1. 创建 MessageChannel 实例,生成两个端口
const channel = new MessageChannel();
const { port1, port2 } = channel;

// 2. 获取 iframe 元素,监听加载完成事件
const iframe = document.getElementById('myIframe');
iframe.onload = () => {
  // 3. 向 iframe 传递 port2(关键:只有传递端口后才能通讯)
  iframe.contentWindow.postMessage('init', '*', [port2]);
};

// 4. 监听 port1 接收的消息(来自 iframe)
port1.onmessage = (e) => {
  console.log('父页面收到 iframe 消息:', e.data);
  // 收到消息后回复
  if (e.data === 'hello from iframe') {
    port1.postMessage('hi iframe, I am parent');
  }
};

// 5. 可选:监听错误事件
port1.onerror = (error) => {
  console.error('通讯错误:', error);
  port1.close(); // 出错后关闭端口
};

步骤2:iframe 页面(接收端口+响应逻辑)

// 1. 监听父页面发送的初始化消息
window.onmessage = (e) => {
  // 2. 验证消息类型,获取传递的 port2
  if (e.data === 'init' && e.ports.length) {
    const port2 = e.ports[0];

    // 3. 监听 port2 接收的消息(来自父页面)
    port2.onmessage = (msg) => {
      console.log('iframe 收到父页面消息:', msg.data);
    };

    // 4. 向父页面发送消息
    port2.postMessage('hello from iframe');
  }
};

需要注意的是:

  • 父页面通过 postMessage 传递 port2 时,必须将 port2 放在第三个参数(transferList)中,这是 “端口传递”的固定写法;
  • 端口一旦传递,父页面的 port2 就会失效,只能通过 iframe 中的 port2 通讯。

3.2 兄弟通讯

实现思路是: 父页面作为“总机”,负责创建 MessageChannel,并将两个端口分别分配给两个 iframe,让它们之间建立起直连专线。

步骤1:父页面代码

const channel = new MessageChannel();
const frame1 = document.getElementById('frame1').contentWindow;
const frame2 = document.getElementById('frame2').contentWindow;
// 等待两个 iframe 加载完成
let loadCount = 0;
const onLoad = () => {
  loadCount++;
  if (loadCount === 2) {
    // 将 port1 发送给 iframe1
    frame1.postMessage('init', '*', [channel.port1]);
    // 将 port2 发送给 iframe2
    frame2.postMessage('init', '*', [channel.port2]);
    console.log('父页面:专线已建立,端口已分发。');
  }
};
document.getElementById('frame1').onload = onLoad;
document.getElementById('frame2').onload = onLoad;

步骤2:子页面接收port


let port;
// 1. 接口端口
window.addEventListener('message', (event) => {
  // 确认是父页面发来的初始化消息,并接收端口
  if (event.data === 'init') {
    port = event.ports[0];
    console.log('iframe1:已接收 port1,准备发送消息。');
  }
});
// 2. 发送消息
document.getElementById('sendBtn').onclick = () => {
  if (port) {
    const message = `来自 iframe1 的问候,时间:${new Date().toLocaleTimeString()}`;
    port.postMessage(message);
    console.log('iframe1:消息已发送 ->', message);
  }
};
// 3. 监听 port 消息
port.onmessage = (e) => {
  console.log('页面B收到消息:', e.data);
};

实际效果:

image.pngimage.png

4. 注意事项

  1. 端口传递必须用 transferList

传递端口时,必须将 port 放在 postMessage 的第三个参数(transferList)中,而不是作为第一个参数(data)。错误写法会导致端口无法正常绑定,通讯失效。

  1. 通讯完成后及时关闭端口

不需要通讯时,调用 port.close() 关闭端口,避免内存泄漏。

5. 总结

最后总结一下:MessageChannel 通过创建一个专属的双向通道,解决了点对点通信的需求,唯一不足的是无论是父子还是兄弟通讯都是需要使用postMessage进行传递端口。

聊聊前端容易翻车的“环境管理”

2025年12月7日 19:56

想获取更多2025年最新前端场景题可以看这里fe.ecool.fun

大家好,我是刘布斯。

周末在家整理以前的老硬盘,翻到了 10 年前做的一个外包项目源码。打开一看,入口文件里赫然写着一行被注释掉的代码:

// var API_HOST = "http://localhost:8080"; var API_HOST = "http://192.168.1.100:8080"; // 只有内网能访问

看到这行代码,那种被“发布上线后页面一片白屏”支配的恐惧瞬间攻击了我。

入行十几年,前端工程化变了好几轮,但“环境管理”这个问题,反而是很多团队(甚至大厂内部的小组)最容易翻车的地方。今天不讲什么高大上的微前端或 Serverless,单纯聊聊在 Dev、Test、UAT、Prod 这一堆环境里打滚摸出来的路子。

最开始的“硬编码”时代

最早那会,根本没有 process.env

那时候发版简直是玄学。周五晚上要上线,整个流程大概是这样的:我在本地把 API 地址改成线上的,Ctrl+S 保存,然后用 FTP 传到服务器。

最要命的不是忘了改地址,而是改错了没改回来

我有次在生产环境调试一个 bug,顺手把 URL 改成了测试环境的模拟接口,验证完觉得没问题,就直接关机下班……结果第二天客服群炸了,老板当时看我的眼神,感觉已经在琢磨开除我对项目有没有影响了。

这个阶段的痛苦在于:代码和配置不分家。代码逻辑是同一套,但因为环境不同,竟然需要每次手动去改源码。这本身就是个巨大的风险源。

后来有了 Webpack 和 DefinePlugin

后来构建工具起来了,Webpack 简直是救世主。

大家开始习惯用 DefinePlugin,或者后来的 dotenv。在根目录下建几个文件:.env.development.env.production

// webpack.config.js
new webpack.DefinePlugin({
  'process.env.API_URL': JSON.stringify('https://api.prod.com')
})

这时候最常见的一个坑是:以为写了 process.env 就万事大吉了

记得有次带新人,小伙子把阿里云的 OSS AccessKeySecret 直接写到了 .env 文件里,并且还被 Webpack 打包进了 bundle.js。

他觉得 .env 在服务器上,很安全。但他忘了前端代码是跑在用户浏览器里的。我随手打开 Chrome 控制台,切到 Network 面板,搜一下源码,那个 Key 就赤裸裸地躺在那儿。

从那以后,我在 Code Review 里加了一条铁律:凡是打进前端包里的变量,默认就是公开的。 涉及私密的配置,一律要在 Nginx 层或者 BFF 层(Node.js 中间件)处理,绝对不能进 Webpack。

Docker 时代的“一次构建,到处运行”

这几年容器化成了标配,问题又升级了。

用 .env 文件最大的问题是:配置是构建时(Build-time)注入的。

测试那边的老哥不止一次跟我抱怨:“你们前端真麻烦,我这只是想把测试环境的镜像推到预发环境验证一下,结果因为 API 地址变了,我还得重新跑一遍 npm run build?这镜像还能叫不可变交付吗?”

这确实是个硬伤。理想的 Docker 流程是:镜像 build 出来后,这是一个死的包。我在测试环境跑它,它连测试库;我在生产环境跑它,它连生产库。镜像本身不应该变,变的是启动容器时传进去的环境变量。

如果用 Webpack 把 API 地址写死在 JS 里,这镜像就废了,只能在那一个环境用。

终极方案:运行时注入

为了解决这个问题,也为了少被测试大佬的抱怨,我们现在的很多项目都切到了运行时注入的方案。

原理其实也很简单,就是“回光返照”到了 jQuery 时代:把配置挂在 window 上

核心逻辑就这两步:

  1. 代码里不读 process.env: 前端代码里所有需要区分环境的地方,全部改成读取 window.__APP_CONFIG__.API_URL

  2. HTML 模板里留个坑: 在 index.html 的 <head> 里,放一个空的 script 标签,或者特殊的占位符。

  3. 容器启动时填坑: 这是最关键的一步。容器启动的时候(或者 Nginx 启动时),通过写一个简单的 Shell 脚本,去读取机器的环境变量,然后生成一个 config.js 文件,内容就是:

    window.__APP_CONFIG__ = {
      API_URL: "https://api.real-prod.com",
      THEME_COLOR: "blue"
    };
    

    然后把这个文件塞进 Nginx 的静态资源目录里。

这样一来,前端打出来的包是完全干净的,不带任何环境信息。镜像推到哪里,只要那个环境的 Docker 启动参数配对了,页面就能正常跑。

# docker-compose 示例
environment:
  - API_URL=https://api.staging.com

这个方案不仅解决了“一次构建”的问题,还有一个隐藏的好处:回滚极快

以前发版如果配置错了,要重新打包发布,起码 10 分钟。现在只要改一下容器的环境变量重启一下,30 秒搞定。对于那种高压力的线上故障修复,这几分钟就是命。

几个小 Tips

最后再唠叨几个细节,都是踩坑踩出来的:

  • 不要信任 .gitignore:总有人会手抖把 .env.local 提交上去。我们在 CI/CD 流程里加了扫描,一旦发现这就没法 merge。

  • 版本号要在控制台打印出来:每次打包,我会把当前的 git commit hash 和打包时间注入到 window 对象里,并在 console 里打印出来。

  • 以前测试提 bug,我问“是最新版吗?”,对方说“是”。结果查半天是缓存。

  • 现在我让他们截图控制台,我看一眼 hash 也就知道是不是最新版,省了太多扯皮时间。

  • Feature Flag 也可以用环境配置:不要傻傻地用注释代码的方式来开关功能,而是直接把它做成配置项。万一上线后这功能有 bug,改个配置就能关掉,不用重新发版,这在在大促期间简直是保命符。

环境管理看着不起眼,也没什么高深的算法,但它决定了一个团队开发的“下限”。

下限越稳,大家才越敢在上面折腾新东西。毕竟,谁也不想半夜三点爬起来因为少改了一个 URL 而回滚代码,对吧?

如果你觉得现在的项目构建太慢,或者经常因为环境配置问题和各方大佬扯皮,可以去检查一下构建脚本,是不是还在针对每个环境单独打包?

如果是,试着把那部分配置抽离出来,哪怕先不用 Docker,先试着写一个 config.js 加载一下,你会发现世界瞬间清静了很多。

梳理SPA项目Router原理和运行机制 [共2500字-阅读时长10min]

2025年12月7日 17:59

背景

SPA单页面应用实际是只有一个HTML文件,路由的切换都是通过JS动态切换组件的显示与隐藏,MPA应用拥有多个HTML文件。

Vue-Router和React-Router的原理类似,本文通过React-Router举例

路由模式分类

  1. history模式
  1. hash模式

前置知识history

History提供了浏览器会话历史的管理能力。通过history对象,开发者可以:

  1. 通过使用go, backforward在会话历史中导航,目标会话历史条目如果是通过传统方式跳转的,如直接修改window.location.href则会刷新页面。如果是通过pushStatereplaceState修改的则不会修改刷新页面。
  1. 通过使用pushStatereplaceState添加和替换当前的会话历史,不会刷新页面。但是新url和旧url必须是同源的。

pushStatereplaceState是HTML5新特性。

API 定义
history.pushState(data, title [, url]) pushState主要用于往历史记录堆栈顶部添加一条记录。各参数解析如下:①data会在onpopstate事件触发时作为参数传递过去;②title为页面标题,当前所有浏览器都会忽略此参数;③url为页面地址,可选,缺少时表示为当前页地址
history.replaceState(data, title [, url]) 更改当前的历史记录,参数同上; 上面的pushState是添加,这个更改
history.state 用于存储以上方法的data数据,不同浏览器的读写权限不一样
window.onpopstate 修改当前会话历史条目时候,都会触发这个回调函数

注意⚠️的是:用history.pushState() 或者 history.replaceState() 不会触发popstate事件。(区分出监听路由和改变路由的区别)

hash模式原理

一句话总结:监听url中hash的变化,来做页面组件的显示与隐藏。特点是在url中有#美观程度低。

关于hash需要知道的三点是:

  1. url中的hash变化不会导致页面重新加载。
  1. url中的hash变化会被window.history记录下来,当使用浏览器的页面的前进后退功能,是可以看到url中hash的变化的。
  1. 当通过url向服务端请求资源的时候,url中hash是不会传递给服务端的。hash路由模式是纯前端实现的路由跳转。

改变路由

通过window.location.hash可以获取到urlhash值,对应着项目的页面路径。

监听路由

通过hashchange事件监听urlhash的变化,也就是项目页面路径的变化。

window.addEventListener("hashchange",callback)

history模式原理

一句话总结:利用HTML5推出的pushStatereplaceState改变url路径,而不会触发页面的刷新,通过监听popState事件来实现项目的页面组件切换。

改变路由

1.pushState:向会话历史中压入一个新的会话历史记录,相当于入栈。

2.replaceState:更改当前的会话记录历史 ,相当于更新栈顶元素。

监听路由

popState事件 这个名字起的不好,他是一个监听事件,当会话历史记录发生变化就会回调出发(前进和后退都会触发),单看名字好像是一个可以主动调用的方法,极具迷惑性。

window.addEventListener("popstate",callback);

改变路由和监听路由的作用

  1. 改变路由,开发者通过React-Router提供的组件或者API主动的进行页面切换时使用,强调的是开发者主动。
  1. 监听路由,当用户使用手动修改浏览器地址栏中URL,手动点击浏览器的前进后退按钮的方式改变    URL的时候,项目需要监听到这些URL路径改变的操作,从而做出响应,强调的是外部变化,被动响应 。

React-Router基础的路由结构

<Router>

  <Switch>

    <Route exact path="/" component={Home} />

    <Route path="/about" component={About} />

    <Redirect from="/old" to="/new" />

    <Route path="/new" component={NewPage} />

    <Route component={NotFound} />

  </Switch>

</Router>

Router组件

// 在 Router 组件内部:

componentDidMount() {

  // 1. 监听 history 变化

  this.unlisten = props.history.listen(location => {

    // 2. URL 变了!更新状态

    this.setState({ location: newLocation });

  });

}

Router组件的主要作用:

  1. 根据选择的路由模式,添加改变路由的监听事件hash采用hashchangehistory模式采用popState事件。
  1. 将变化后的location通过Context传递给子组件

总结:监听路径的变化,然后将变化的location传递给子组件,相当于做了一层【事件委托】,避免子组件都要进行监听。

Switch组件

// Switch 内部逻辑:

// 1. 从 Router 获取最新的 location

const location = context.location; // { pathname: '/about' }



// 2. 按顺序检查每个子组件

React.Children.forEach(children, child => {

  // 检查顺序:

  // 1. <Route exact path="/" />       ❌ 不匹配(不是 exact /)

  // 2. <Route path="/about" />        ✅ 匹配!停止检查

  // 3. <Redirect from="/old" to="/new" />  不检查

  // 4. <Route path="/new" />         不检查

  // 5. <Route component={NotFound} />不检查

});

Switch组件的主要作用是:

  1. 所有的Route组件都要直接放在Switch组件的children
  1. 遍历所有的Route组件,根据从Router组件接收到的新location信息,和Route组件配置的path进行匹配,找到当前URL对应的Route组件

总结:根据当前的URL找到匹配的Route组件。

Route组件

// Route 内部逻辑:

// 1. 从 Switch 收到 computedMatch(匹配信息)

const match = this.props.computedMatch;



// 2. 匹配成功,准备渲染

if (match) {

  // 根据配置决定渲染方式:

  if (this.props.component) {

    // 使用 component 方式:创建 About 组件

    return React.createElement(About, {

      history: context.history,

      location: context.location,

      match: match  // { path: '/about', url: '/about', isExact: true }

    });

  }

  // 如果是 render 或 children 方式,也类似

}

Route组件的作用:

根据配置在组件上的参数来决定最终需要渲染的组件。

// 场景1:只想在匹配时显示组件
<Route path="/home" component={Home} />


// 场景2:匹配时要显示,但需要传额外参数
<Route 
  path="/user/:id" 
  render={(props) => <User userId={extraId} {...props} />} 
/>

组件结构作用分层

代码层面解析路由跳转

背景示例:

// 应用结构

<Router>

  <Switch>

    <Route path="/home" component={Home} />

    <Route path="/about" component={About} />

  </Switch>

</Router>

初始状态,显示Home页面

URL:/home

用户点击链接切换到About

<Link to="/about">About</Link> // 也可以使用redirect和useNavigate

// 点击后调用 history.push('/about')

详细步骤分析

第1步:history.push 改变 URL

// history.push 内部简化代码:

function push(path) {

  // 1. 改变浏览器 URL(不刷新页面)

  window.history.pushState({}, '', path);

  

  // 2. 创建新的 location 对象

  const location = createLocation(path);

  

  // 3. ✅ 关键:调用 setState

  setState({

    location: location,

    action: 'PUSH'

  });

}

第2步:setState 的内部操作

// setState 函数内部:

function setState(nextState) {

  // 1. 更新 history 内部的状态

  Object.assign(history, nextState);

  

  // 2. ✅ 核心:通知所有监听器

  listeners.forEach(listener => {

    // 每个 listener 都是一个回调函数

    listener(history.location, history.action);

  });

}

第3步:Router 监听器被调用

// 在 Router 组件构造函数中:

this.unlisten = props.history.listen(location => {

  // ✅ 当 setState 通知监听器时,这个函数被调用

  this.setState({ location: location });

});

第4步:Router 的 setState 触发重新渲染

// React 内部:当调用 this.setState 时

class Router extends React.Component {

  this.setState({ location: newLocation }, () => {

    // setState 完成后,React 会自动调用 render 方法

    this.render();

  });

  

  render() {

    // ✅ Router 重新渲染,使用新的 location

    return (

      <RouterContext.Provider value={{

        history: this.props.history,

        location: this.state.location, // ✅ 这里是新的 location

        match: /* ... */

      }}>

        {this.props.children}

      </RouterContext.Provider>

    );

  }

}

第5步:Context 值变化触发子组件更新

// Switch 组件内部:

<RouterContext.Consumer>

  {(context) => {

    // ✅ context.location 现在是新的 location

    const location = context.location; // { pathname: '/about' }

    

    // Switch 重新计算匹配

    let match = null;

    React.Children.forEach(this.props.children, child => {

      if (match == null) {

        // 检查每个 Route 是否匹配

        const path = child.props.path;

        if (path === '/about') {

          match = true; // ✅ 匹配!

        }

      }

    });

    

    // 渲染匹配的 Route

    if (match) {

      return React.cloneElement(foundChild, {

        location,

        computedMatch: match

      });

    }

  }}

</RouterContext.Consumer>

第6步:Route 组件渲染新页面

// Route 组件收到新的 match 后:

render() {

  if (this.props.computedMatch) {

    // ✅ 匹配成功,渲染对应的组件

    return React.createElement(this.props.component, {

      history: context.history,

      location: context.location,

      match: this.props.computedMatch

    });

  }

  return null; // 不匹配的 Route 不渲染

}

JavaScript

****从 setState 到 DOM 更新的完整链条

// 完整的调用链条:

history.setState()


调用 Router 的监听函数 (listener)


Router.setState({ location })


React 调度 Router 重新渲染


Router.render() 调用


Context.Provider value 更新


Switch (Consumer) 检测到变化


Switch.render() 重新计算匹配


匹配的 Route 重新渲染


Route 创建/更新组件实例


组件的 render 方法调用


React 更新 Virtual DOM


ReactDOM 更新实际 DOM


页面显示新内容

一句话总结:setState 通过改变状态,触发 React 的重新渲染机制,配合 Context 将变化传播到整个组件树,最终实现页面的无缝切换。

为什么History模式需要服务端的支持,而Hash模式不需要

在页面刷新的时候,相当于使用浏览器地址栏中的URL发送了一次GET请求,请求项目的HTML文件。Hash模式下,路由跳转是通过Hash值变化来实现的,而在请求的时候Hash是不会传递给服务端的,所以使用www.baidu.com#/123向服务端请求资源和www.baidu.com 是等价的。所以能被nginx配置的静态资源代理拦截并正常匹配返回HTML文件

而History模式,则是通过改变URL的路径来实现路由跳转的,使用www.baidu.com/abc和使用www.baidu.com/123 向服务端请求资源是完全不同的。此时nginx配置的静态资源代理拦截到这个请求后会去服务器找/abc/123路径中的资源,但是肯定是找不到的,所以会返回404给浏览器,要解决这个404的问题就在nginx加一个404找不到,重定向到默认HTML文件的配置。

本文参考:

  1. 「源码解析 」这一次彻底弄懂react-router路由原理 个人理解,单页面应用是使用一个html下,一次性加载js, - 掘金
  2. 浅谈前端路由原理hash和history🎹序言 众所周知, hash 和 history 在前端面试中是很常考的一道题 - 掘金

彻底搞定大模型流式输出:从二进制碎块到“嘚嘚嘚”打字机效果,让底层逻辑飞起来

作者 不会js
2025年12月7日 16:34

彻底搞定大模型流式输出:从二进制碎块到“嘚嘚嘚”打字机效果,让底层逻辑飞起来


你有没有遇到过这种场景:

用户点击「发送」,页面死气沉沉地转圈圈 5 秒,然后「啪」一下整段 500 字答案全部吐出来。
用户体验 = 灾难。

而真正丝滑的 ChatGPT、Claude、DeepSeek Web 版是怎么做的?

答案就是:流式输出(Streaming)

今天我们就用最硬核的方式,把流式输出的底层原理、字节流处理、SSE 协议、Vue3 响应式结合、常见坑与终极优化全部讲透

前三段,我们来了解一下流式输出所涉及到的知识点,到第四段我们直接让流式输出底层逻辑直接飞起来!

7498560dcaf6f397b1405522836210af.jpg

一、为什么流式输出能让用户「爽到飞起」?

普通请求(stream: false):

用户点击 → 前端等待 → LLM 思考 8 秒 → 完整返回 500 字 → 前端一次性渲染
感知延迟 = 8 秒 + 网络

流式请求(stream: true):

用户点击 → LLM 每生成 1~3 个 token 立刻返回 → 前端实时追加显示
感知延迟 ≈ 300~800ms(第一个 token 到达的时间)

这就是为什么 ChatGPT 打字像真人一样「一字一字冒出来」

结论:流式不是「锦上添花」,而是现代 AI 聊天界面「雪中送炭」的标配。

二、流式输出的真实数据长什么样?

DeepSeek、OpenAI、通用的 Server-Sent Events(SSE)格式:

45672f90391347be29a14c3da5d44e3c.png

关键点:

  • 每行以 data: 开头
  • 每一行都是一个完整的 JSON(除了最后一行 [DONE]
  • delta.content 就是本次新增的文字片段
  • 网络传输的是 二进制 Chunk,前端需要自己拼接、解码、解析

这也是为什么很多人写流式会出错——没处理好残缺的 JSON 行


三、底层:从二进制 Buffer 到文字的全过程(最硬核的部分)

我们用最直白的方式还原浏览器收到数据的真实过程:

graph TD
    A[TCP 二进制流] --> B(ArrayBuffer Chunk)
    B --> C{TextDecoder 解码}
    C --> D[UTF-8 字符串]
    D --> E[按\n拆分成多行]
    E --> F[过滤 data: 开头]
    F --> G[JSON.parse]
    G --> H[取出 delta.content]
    H --> I[追加到 Vue ref]
    I --> J[页面实时更新]
关键 API 一览(现代浏览器原生支持)
API 作用 备注
fetch() + stream: true 开启流式请求 必须设置
response.body.getReader() 获取二进制流读取器 返回 ReadableStreamDefaultReader
reader.read() 每次读取一个 chunk(Uint8Array) 返回 { value, done }
new TextDecoder() 把 Uint8Array → 字符串 支持 UTF-8,默认就是
new TextEncoder() 字符串 → Uint8Array(编码时用) 发请求时用不到,但面试常考
经典 Demo:手动玩转 Buffer
<script>
  const encoder = new TextEncoder();
  const buf = encoder.encode("你好 HTML5"); // Uint8Array(12)
  
  const buffer = new ArrayBuffer(12);
  const view = new Uint8Array(buffer);
  view.set(buf); // 复制进去

  const decoder = new TextDecoder();
  console.log(decoder.decode(buffer)); // "你好 HTML5"
</script>

这个例子说明:所有网络传输底层都是字节,中文一个字 = 3 字节,所以「你好」占 6 字节。


四、 流式输出终极解析

先来上一段完整代码,方便后面打飞他

03998dfb2be956b19c909a672ec27e78.jpg

<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref("") // 单向绑定  主要的
// 调用LLM
const askLLM = async () => { 
  // question 可以省.value  getter
  if (!question.value) {
    console.log('question 不能为空');
    return 
  }
  // 用户体验
  content.value = '思考中...';
  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream:stream.value,
      messages: [
        {
          role: 'user',
          content: question.value
        }
      ]
    })
  })
  if(stream.value){
    //流式输出
    content.value='';//把上次的清空
    //html5 流式响应对象
    //响应体的读对象
    const reader = response.body?.getReader();
    //流出来的是二进制流  buffer
    const decoder = new TextDecoder();
    let done = false;//流是否结束 没有结束
    let buffer = '';
    while(!done){//只要没有完成就一直去拼接buffer
      //解构的同时 重命名
      const {value,done:doneReaing}=await reader?.read()
      console.log(value,doneReaing);
      done = doneReaing;
      //chunk 内容块 包含多行data  有多少行不确定
      //data:{} 能不能传完也不知道
      const chunkValue = buffer +decoder.decode(value,{ stream: true });//decode完之后就是文本字符串
      console.log(chunkValue);
      buffer='';
      const lines = chunkValue.split('\n').filter(line=>line.startsWith('data: '))
      for(const line of lines){
        const incoming = line.slice(6)//干掉数据标志
        if(incoming==='[DONE]'){
          done=true;
          break;
        }
        try{
          //llm 流式生成  tokens 长度不定的
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if(delta){
            content.value+=delta;
          }
        }catch(err){
          //JSON.parse解析失败 
          buffer+=`data: ${incoming}`
        }
      }
    }
  }else{
  const data = await response.json();
  console.log(data);
  content.value = data.choices[0].message.content;
 
  }
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div>{{content}}</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
* {
  margin: 0;
  padding: 0;
}
.container {
  display: flex;
  flex-direction: column;
  /* 主轴、次轴 */
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
</style>
1、网络传的永远只有 0 和 1

“电脑上传输的都是二进制的方式,网络底层永远只有 0 和 1”

无论你是发“你好”两个字,还是发 4K 视频,本质上都是下面这玩意儿在网线里飞:

01001000 01100101 01101100 01101100 01101111

浏览器收到后,先把它们塞进一个叫 ArrayBuffer 的盒子,再给你一个 Uint8Array 的“视图”去操作它。

看一段简单代码:

const myBuffer = encoder.encode("你好 HTML5"); // → Uint8Array(12)
const buffer = new ArrayBuffer(12);
const view = new Uint8Array(buffer);
view.set(myBuffer);

结论:
流式输出从出生那一刻起,就是一堆碎掉的二进制垃圾。
你要的“丝滑打字”?对不起,先自己捡垃圾。

2、两大神器:水龙头 + 翻译官


| 角色       | API                        | 作用                             | 对应你文件里的代码                             |
|------------|----------------------------|----------------------------------|------------------------------------------------|
| 水龙头     | response.body.getReader()  | 把网络流变成可控的“水管”         | const reader = response.body?.getReader()      |
| 翻译官     | new TextDecoder()          | 把二进制水翻译成人类能看的汉字   | const decoder = new TextDecoder()              |

缺一不可。  
没有水龙头 → 拿不到数据  
没有翻译官 → 拿到一堆数字垃圾

3、读数据时的“解构+重命名”黑魔法

const { value, done: doneReading } = await reader.read()
done = doneReading
为什么不直接写 const { value, done }?

因为外层 while 循环要靠一个叫 done 的变量控制死活:

```js
let done = false;
while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;   // 这一步才真正结束循环
}

这就是代码里“解构重命名的终极原因——避免变量名冲突,保持逻辑清晰

4、最重要的一环:chunk 为什么是“狗啃过的”?

chunk(内容块)是浏览器网络层每次 read() 吐给你的二进制包,常见大小 16KB~64KB,完全随机。

可能出现的三种惨状:

  1. 一个完整的 data: 行被切成两半
    chunk1: data: {"choices":[{"delta":{"content":"你
    chunk2: 好啊"}}}]

  2. 一个 chunk 塞了 8 条完整行 + 半条残缺行

  3. 最后一个 chunk 只有 data: [DONE]

这就是为什么 大部分 的人写流式会寄——他们天真地以为一次 read() 就等于一条完整的 JSON。

5、 处理数据

再看到这张图

45672f90391347be29a14c3da5d44e3c.png

流式响应中,每一行理论上以 data: 开头,后面跟着一个完整的 JSON 对象。但实际情况经常会出现:

  1. 多个 data: 粘在一起(网络分块边界刚好切在中间)
  2. 最后一行 JSON 不完整(只收到一半就被截断)

所以我们先拆分再解析

const lines = chunkValue.split('\n').filter(line=>line.startsWith('data: '))

先把多个粘连的data给分成单个的

 const incoming =line.slice(6)

再把data: 前缀给削掉,这样就得到了我们需要的JSON对象

最后在进行解析

 const data = JSON.parse(incoming);

如果JOSN是不完整的,parse解析就会报错,这就是我们为什么要用buffer拼接

6、buffer:流式输出的灵魂(垃圾桶理论)

let buffer = ''  // 残缺 JSON 临时停车场

这是整套方案的灵魂——buffer 蓄水池机制

因为网络可能把一行 JSON 切成两半、三半、甚至十半,我们必须准备一个“垃圾桶”先存着:

JavaScript

let buffer = '';  // 全局的残缺字符串蓄水池

每次读到新 chunk,都要先拼到 buffer 里:

JavaScript

let chunkText = buffer + decoder.decode(value, { stream: true });
// 注意这里的 { stream: true }!告诉 decoder “我可能还没完”
buffer = ''; // 先清空,准备重新装垃圾

7. delta.content 追加,ref 一动页面舞

Vue3 的 ref 是响应式的,只要改 .value,页面就自动更新:

JavaScript

content.value += delta;

这就是你看到文字一个一个蹦出来的根本原因。

不需要 setTimeout,不需要 requestAnimationFrame,Vue 自己搞定。


五、总结

流式输出底层逻辑汇总:

电脑上传输的都是二进制的方式
网络底层永远只有 0 和 1

想要拿到我们看得懂的文本,首先需要两个工具:
getReader() 和 TextDecoder()
一个取“水龙头”,一个把“二进制水”翻译成汉字

一个负责拿到二进制流 Buffer,一个负责把拿到的这个二进制流解码成我们看得懂的字符串
value 就是 Uint8Array(专业叫法就是 Buffer)

reader读取的时候默认为{value, done},为了不影响外层while循环,解构的时候选择重命名
这就是为什么写 done: doneReading 的终极原因

接下来进入流式输出的最重要一环:
因为 token 是按序生成、随时发送的,网络每次也只能打包固定大小的数据,
所以我们实际拿到的二进制 chunk 可能是残缺的,也可能是多条完整的混在一起

这个时候需要我们手动进行字符串拼接,使用一个空字符串 buffer 做“蓄水池”
buffer 就是“残缺 JSON 临时停车场”

得到的二进制流解码后叫做 chunk 内容块

我们需要进行“过滤 + 拆行”操作:

  1. 因为可能一次 chunk 包含多个 data: 行
  2. 也可能一个 data: 行被拆成两个 chunk
    所以必须:buffer += 新chunk → 按 \n 拆成数组 → 把最后一行(可能不完整)重新塞回 buffer

然后将每行完整的 data: 去掉前缀,得到真正的 JSON 字符串

如果这行 JSON 不完整 → JSON.parse 会报错 → 被 catch 抓住 → 自动留到 buffer 等下次拼接

最后成功解析 → 取出 choices[0].delta.content → 追加到 Vue 的 ref → 页面实时刷新 → 流式输出达成!


流式输出的整条命脉就一句话:

“网络不负责给你整行 JSON,它只管扔二进制垃圾给你,你得自己捡垃圾、拼成完整的 JSON 才能吃。”


彩蛋

stream.value = falsetrue 切换对比,你会立刻感受到「从石器时代到现代文明」的体验差。

现在,你已经完全掌握了大模型流式输出的底层原理与最佳实践。

Vue3计算属性如何通过缓存特性优化表单验证与数据过滤?

作者 kknone
2025年12月7日 16:03

在日常开发中,表单验证和动态数据过滤几乎是每个项目都会遇到的需求——比如用户注册时要检查输入是否合法,商品列表要根据关键词实时筛选。这两个场景看似简单,但处理不好容易写出冗余、低效的代码。而Vue3的**计算属性(Computed Properties)**正好是解决这类问题的“神器”,今天我们就通过实战案例,看看它如何简化状态管理和逻辑复用。

一、表单验证:用计算属性简化状态管理

1.1 为什么用计算属性做表单验证?

做表单验证时,我们需要判断“所有字段是否合法”——比如用户名不能为空、密码至少6位、确认密码要一致。如果用methods写,每次模板渲染都会重新调用方法,哪怕字段没变化;而计算属性会缓存结果,只有当依赖的响应式数据(比如form.username)变化时,才会重新计算。这样既省性能,又让代码更简洁。

Vue官网是这么说的:“计算属性基于它们的依赖进行缓存。只在相关依赖发生改变时才会重新求值。”(参考:vuejs.org/guide/essen…

1.2 实战:用户注册表单验证

我们来写一个用户注册表单,用计算属性判断表单是否可以提交。代码如下:

<template>
  <form class="register-form" @submit.prevent="handleSubmit">
    <!-- 用户名输入框 -->
    <div class="form-group">
      <label>用户名:</label>
      <input 
        v-model.trim="form.username" 
        placeholder="请输入3-10位字符" 
        class="form-input"
      />
      <!-- 错误提示 -->
      <p v-if="!form.username" class="error-msg">用户名不能为空</p>
    </div>

    <!-- 密码输入框 -->
    <div class="form-group">
      <label>密码:</label>
      <input 
        type="password" 
        v-model="form.password" 
        placeholder="请输入6-16位密码" 
        class="form-input"
      />
      <p v-if="form.password.length < 6" class="error-msg">密码至少6位</p>
    </div>

    <!-- 确认密码 -->
    <div class="form-group">
      <label>确认密码:</label>
      <input 
        type="password" 
        v-model="form.confirmPassword" 
        placeholder="请再次输入密码" 
        class="form-input"
      />
      <p v-if="form.confirmPassword !== form.password" class="error-msg">两次密码不一致</p>
    </div>

    <!-- 提交按钮:禁用状态由计算属性控制 -->
    <button 
      type="submit" 
      class="submit-btn" 
      :disabled="!formIsValid"
    >
      提交注册
    </button>
  </form>
</template>

<script setup>
import { ref, computed } from 'vue'

// 1. 响应式表单数据
const form = ref({
  username: '',   // 用户名
  password: '',   // 密码
  confirmPassword: ''  // 确认密码
})

// 2. 计算属性:判断表单是否合法
const formIsValid = computed(() => {
  // 解构form数据,简化代码
  const { username, password, confirmPassword } = form.value
  // 验证逻辑:用户名非空 + 密码≥6位 + 两次密码一致
  return (
    username.trim() !== '' &&  // 去掉空格后非空
    password.length >= 6 &&    // 密码长度足够
    confirmPassword === password  // 确认密码一致
  )
})

// 3. 提交事件处理
const handleSubmit = () => {
  if (formIsValid.value) {
    alert('注册成功!');
    // 这里可以加向后端提交数据的逻辑,比如axios.post('/api/register', form.value)
  }
}
</script>

<style scoped>
.register-form { max-width: 400px; margin: 20px auto; }
.form-group { margin-bottom: 15px; }
.form-input { width: 100%; padding: 8px; margin-top: 5px; }
.error-msg { color: red; font-size: 12px; margin: 5px 0 0 0; }
.submit-btn { width: 100%; padding: 10px; background: #42b983; color: white; border: none; border-radius: 4px; cursor: pointer; }
.submit-btn:disabled { background: #ccc; cursor: not-allowed; }
</style>

代码解释:

  • 响应式数据:用ref包裹表单对象form,这样输入时form的属性会自动更新。
  • 计算属性formIsValid:依赖form的三个属性,当其中任何一个变化时,自动重新计算“表单是否合法”。
  • 禁用按钮:用:disabled="!formIsValid"绑定按钮状态——只有formIsValidtrue时,按钮才能点击。
  • 提交逻辑handleSubmit里先判断formIsValid.value,确保提交的是合法数据。

1.3 流程图:表单验证的计算属性逻辑

为了更直观,我们用流程图展示计算属性的工作流程:

flowchart LR
A[用户输入表单内容] --> B[form数据响应式更新]
B --> C[计算属性formIsValid重新计算]
C --> D{formIsValid是否为true?}
D -->|是| E[提交按钮可用,允许提交]
D -->|否| F[提交按钮禁用,提示错误]

二、动态数据过滤:计算属性的缓存魔法

2.1 为什么用计算属性做动态过滤?

另一个常见场景是动态数据过滤——比如商品列表,用户输入关键词后,实时显示包含关键词的商品。这时计算属性的缓存特性就很有用:只有当搜索关键词(searchQuery)或商品列表(products)变化时,才会重新过滤,避免不必要的重复计算。

2.2 实战:商品列表动态过滤

我们来写一个商品列表,用计算属性实现实时过滤:

<template>
  <div class="product-filter">
    <!-- 搜索输入框 -->
    <input 
      v-model.trim="searchQuery" 
      placeholder="搜索商品名称" 
      class="search-input"
    />

    <!-- 过滤后的商品列表 -->
    <ul class="product-list">
      <li 
        v-for="product in filteredProducts" 
        :key="product.id" 
        class="product-item"
      >
        {{ product.name }} - {{ product.price }}元
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

// 1. 模拟后端获取的商品数据(响应式)
const products = ref([
  { id: 1, name: 'Vue3实战教程', price: 99 },
  { id: 2, name: 'React入门指南', price: 79 },
  { id: 3, name: 'JavaScript进阶', price: 129 },
  { id: 4, name: 'Vue3组件库', price: 59 }
])

// 2. 搜索关键词(响应式)
const searchQuery = ref('')

// 3. 计算属性:过滤后的商品列表
const filteredProducts = computed(() => {
  // 统一转为小写,避免大小写问题
  const query = searchQuery.value.toLowerCase()
  // 过滤逻辑:商品名称包含关键词
  return products.value.filter(product => {
    return product.name.toLowerCase().includes(query)
  })
})
</script>

<style scoped>
.product-filter { max-width: 600px; margin: 20px auto; }
.search-input { width: 100%; padding: 10px; margin-bottom: 15px; }
.product-list { list-style: none; padding: 0; }
.product-item { padding: 10px; border-bottom: 1px solid #eee; }
</style>

代码解释:

  • 响应式数据products是商品列表(模拟后端数据),searchQuery是用户输入的关键词。
  • 计算属性filteredProducts:依赖searchQueryproducts,当其中任何一个变化时,重新过滤商品列表。
  • 过滤逻辑:用filter方法筛选出名称包含关键词的商品,toLowerCase()统一大小写,避免“Vue”和“vue”不匹配的问题。

2.3 流程图:动态过滤的计算属性流程

flowchart LR
A[用户输入搜索关键词] --> B[searchQuery响应式更新]
B --> C[计算属性filteredProducts重新计算]
C --> D[过滤products列表]
D --> E[渲染过滤后的商品列表]

三、计算属性的进阶技巧:组合逻辑复用

3.1 抽取可复用的验证逻辑

在表单验证中,我们可能需要复用逻辑——比如“密码强度检查”,多个表单都需要判断密码是否包含大小写字母和数字。这时可以把逻辑抽成可组合函数(Composable),让代码更简洁、可复用。

往期文章归档
免费好用的热门在线工具

示例:抽取密码强度验证

我们创建一个usePasswordStrength.js文件,封装密码强度检查逻辑:

// composables/usePasswordStrength.js
import { computed } from 'vue'

/**
 * 密码强度检查的可组合函数
 * @param {Ref<string>} passwordRef - 密码的响应式引用
 * @returns {Object} 包含密码强度的计算属性
 */
export function usePasswordStrength(passwordRef) {
  // 计算属性:密码强度
  const passwordStrength = computed(() => {
    const password = passwordRef.value
    if (password.length === 0) return '请输入密码'
    if (password.length < 6) return '弱(至少6位)'
    // 检查是否包含小写、大写、数字
    const hasLower = /[a-z]/.test(password)
    const hasUpper = /[A-Z]/.test(password)
    const hasNumber = /\d/.test(password)
    // 强度等级:3项都满足→强,2项→中,1项→弱
    const strengthCount = [hasLower, hasUpper, hasNumber].filter(Boolean).length
    if (strengthCount === 3) return '强'
    if (strengthCount === 2) return '中'
    return '弱'
  })

  return { passwordStrength }
}

然后在注册表单中使用这个函数:

<template>
  <!-- 密码输入框 -->
  <div class="form-group">
    <label>密码:</label>
    <input type="password" v-model="form.password" class="form-input" />
    <p class="strength-msg">密码强度:{{ passwordStrength }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { usePasswordStrength } from '@/composables/usePasswordStrength'

const form = ref({ password: '' })
// 使用可组合函数,传入密码的响应式引用
const { passwordStrength } = usePasswordStrength(() => form.value.password)
</script>

<style scoped>
.strength-msg { font-size: 12px; margin: 5px 0 0 0; }
.strength-msg:contains('弱') { color: #f56c6c; }
.strength-msg:contains('中') { color: #e6a23c; }
.strength-msg:contains('强') { color: #67c23a; }
</style>

这样,不管多少个表单需要密码强度检查,只需引入usePasswordStrength即可,大大提高了代码的复用性!

四、课后Quiz:巩固所学知识

Quiz1:在动态数据过滤的示例中,如果把computed换成methods,会有什么区别?为什么?

答案解析

  • 区别computed会缓存结果,只有依赖的响应式数据(searchQueryproducts)变化时才重新计算;methods每次组件渲染都会重新调用,即使依赖的数据没变化。
  • 原因:比如用户输入关键词后,searchQuery变化,computed会重新过滤一次;但如果页面上有其他变化(比如时间戳更新),methods会再次调用filter方法,而computed不会——因为它的依赖没变化。
  • 结论computed更适合“衍生状态”(由其他数据推导而来),methods更适合“执行动作”(比如点击事件)。参考:vuejs.org/guide/essen…

Quiz2:表单验证中的formIsValid,为什么不用watch来实现?

答案解析

  • watch是“观察数据变化并执行副作用”(比如异步请求、DOM操作),而computed是“推导新的响应式数据”。
  • 如果用watch实现formIsValid,需要手动维护一个isValid变量:
    const formIsValid = ref(false)
    watch([() => form.value.username, () => form.value.password, () => form.value.confirmPassword], () => {
      formIsValid.value = /* 验证逻辑 */
    })
    
  • 相比之下,computed更简洁:const formIsValid = computed(() => /* 验证逻辑 */),而且自动缓存结果。
  • 结论:computed是“声明式”的(告诉Vue“我要什么”),watch是“命令式”的(告诉Vue“要做什么”)。参考:vuejs.org/guide/essen…

五、常见报错及解决方案

1. 报错:“Computed property "formIsValid" was assigned to but it has no setter.”

  • 原因:试图给computed属性赋值(比如formIsValid = true),但computed默认是只读的(除非定义setter)。
  • 解决:不要直接修改computed属性,而是修改它依赖的响应式数据(比如form.value.username = 'admin')。如果需要可写的computed,可以定义gettersetter
    const fullName = computed({
      get() { return this.firstName + ' ' + this.lastName },
      set(value) {
        [this.firstName, this.lastName] = value.split(' ')
      }
    })
    
  • 预防:记住computed是“衍生状态”,修改依赖的数据即可,不要直接赋值。

2. 报错:“Property "formIsValid" was accessed during render but is not defined on instance.”

  • 原因:模板中用了formIsValid,但script中没有定义,或者定义错误(比如写成了methods里的函数)。
  • 解决:检查script中是否正确定义了computed属性:const formIsValid = computed(...),并且script setup会自动导出顶层变量(不需要export)。
  • 预防:写模板时同步修改script,确保变量名一致。

3. 报错:“Invalid watch source: 5 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these.”

  • 原因watch的源不是响应式数据或函数(比如watch(5, () => {}))。
  • 解决:确保watch的源是响应式的,比如:
    watch(() => form.value.username, () => { /* 逻辑 */ }) // getter函数
    watch(searchQuery, () => { /* 逻辑 */ }) // ref变量
    
  • 预防:使用watch时,源要指向响应式数据的getter函数或ref/reactive对象。

参考链接

深入解析:基于 Vue 3 与 DeepSeek API 构建流式大模型聊天应用的完整实现

作者 Yira
2025年12月7日 15:54

深入解析:基于 Vue 3 与 DeepSeek API 构建流式大模型聊天应用的完整实现

在人工智能技术日新月异的今天,大语言模型(Large Language Models, LLMs)已从实验室走向大众开发者手中。从前端视角看,我们不再只是静态页面的构建者,而是可以轻松集成智能对话能力、打造具备“思考”功能的交互式 Web 应用。本文将对一段使用 Vue 3 组合式 APIDeepSeek 大模型 API 实现的简易聊天界面代码进行逐行深度剖析,不仅讲解其表面逻辑,更深入探讨流式响应(Streaming)、SSE(Server-Sent Events)协议解析、前端安全实践、性能优化策略等核心概念,帮助你全面掌握现代 AI 应用的前端架构。


一、项目背景与目标

该应用的目标非常明确:

用户在输入框中输入自然语言问题 → 点击“提交”按钮 → 调用 DeepSeek 的 deepseek-chat 模型 → 将模型生成的回答实时或一次性显示在页面上。

其中,“实时显示”即流式输出(streaming) ,是提升用户体验的关键特性——用户无需等待数秒才能看到完整回答,而是像观看真人打字一样,逐字逐句地接收内容。


二、整体架构:Vue 3 单文件组件(SFC)

该应用采用 Vue 3 的 <script setup> 语法糖,这是一种编译时优化的组合式 API 写法,代码简洁且性能优异。整个组件分为三部分:

  • <script setup> :逻辑层,定义响应式状态、封装 API 调用函数。
  • <template> :视图层,声明式描述 UI 结构与数据绑定。
  • <style scoped> :样式层,使用 Flex 布局实现自适应垂直排列。

这种结构高度内聚,非常适合快速原型开发或教学演示。


三、响应式状态设计

import { ref } from 'vue'

const question = ref('讲一个喜洋洋和灰太狼的故事不低于20字')
const stream = ref(true)
const content = ref("")

1. ref 的作用机制

ref 是 Vue 3 提供的基础响应式 API,它将原始值包装在一个带有 .value 属性的对象中。例如:

let count = ref(0)
console.log(count.value) // 0
count.value++ // 触发依赖更新

在模板中,Vue 会自动解包 .value,因此可以直接写 {{ question }} 而非 {{ question.value }}

2. 状态含义

  • question:用户输入的问题文本。默认值设为一个具体示例,便于测试,避免空请求。
  • stream:布尔开关,控制是否启用流式响应。默认开启,体现“实时性”优势。
  • content:LLM 返回的内容容器。初始为空,调用前设为“思考中...”,调用后逐步填充。

这三个状态共同构成了“输入-处理-输出”的闭环。


四、API 调用逻辑详解:askLLM 函数

这是整个应用的核心函数,负责与 DeepSeek 后端通信。

1. 输入校验与 UX 优化

if (!question.value) {
  console.log('question 不能为空');
  return 
}
content.value = '思考中...';
  • 防御性编程:防止空字符串触发无效请求,节省资源。
  • 即时反馈:设置 content 为提示语,让用户知道系统正在工作,避免“无响应”错觉。

2. 请求构建

const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  'Content-Type': 'application/json'
}
  • 环境变量安全VITE_DEEPSEEK_API_KEY 是 Vite 特有的客户端环境变量前缀。Vite 会在构建时将其注入到 import.meta.env 中。
  • 认证方式:采用 Bearer Token,符合 RESTful API 最佳实践。
  • 内容类型:指定为 application/json,确保后端正确解析请求体。

⚠️ 重要安全警告:此方式将 API 密钥暴露在前端 JavaScript 中,任何用户均可通过浏览器开发者工具查看。仅适用于本地开发或演示环境。生产环境必须通过后端代理(如 Express、Nginx)转发请求,密钥应存储在服务器环境变量中。

3. 发送请求

const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-chat',
    stream: stream.value,
    messages: [{ role: 'user', content: question.value }]
  })
})
  • OpenAI 兼容格式:DeepSeek API 遵循 OpenAI 的消息格式,messages 数组包含角色(user/assistant)和内容。
  • 动态流式控制stream 参数决定返回格式——流式(text/event-stream)或非流式(application/json)。

五、响应处理:流式 vs 非流式

A. 非流式模式(简单直接)

const data = await response.json();
content.value = data.choices[0].message.content;

适用于:

  • 对实时性要求不高的场景
  • 调试阶段快速验证 API 是否正常
  • 网络环境不稳定,流式连接易中断

B. 流式模式(复杂但体验更佳)

1. 初始化流读取器
content.value = "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
  • getReader() 返回一个 ReadableStreamDefaultReader,用于逐块读取响应体。
  • TextDecoder 将二进制数据(Uint8Array)解码为 UTF-8 字符串。
2. 循环读取与解析
let done = false;
let buffer = '';

while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;
  const chunkValue = buffer + decoder.decode(value);
  buffer = ''; // ← 此处存在严重缺陷!
🔍 问题分析:缓冲区(Buffer)处理错误

网络传输的 TCP 包大小不确定,一个完整的 SSE 行可能被拆分到多个 chunk 中。例如:

  • Chunk 1: "data: {"choices": [{"delta": {"cont"
  • Chunk 2: "ent": "今天灰太狼又失败了..."}}]}\n"

若在每次循环开始时清空 buffer,第二块数据将丢失前半部分,导致 JSON 解析失败。

正确实现

const chunkValue = buffer + decoder.decode(value, { stream: true });
const lines = chunkValue.split('\n');
buffer = lines.pop() || ''; // 保留不完整的最后一行
const validLines = lines.filter(line => line.trim().startsWith('data:'));
  • 使用 { stream: true } 选项,确保跨 chunk 的 UTF-8 字符(如 emoji)能正确解码。
  • lines.pop() 取出可能不完整的尾行,留待下次拼接。
3. SSE 行解析
for (const line of validLines) {
  const payload = line.slice(5).trim(); // "data: xxx" → "xxx"
  if (payload === '[DONE]') {
    done = true;
    break;
  }
  try {
    const parsed = JSON.parse(payload);
    const delta = parsed.choices?.[0]?.delta?.content;
    if (delta) content.value += delta;
  } catch (e) {
    console.warn('Failed to parse SSE line:', payload);
  }
}
  • 跳过非 data 行:SSE 协议还支持 event:id: 等字段,此处仅处理 data:
  • 安全访问嵌套属性:使用可选链(?.)避免因结构变化导致崩溃。
  • 忽略解析错误:部分 chunk 可能包含空行或注释,应静默跳过。

六、模板与交互设计

<input v-model="question" />
<button @click="askLLM">提交</button>
<input type="checkbox" v-model="stream" />
<div>{{ content }}</div>
  • 双向绑定v-model 自动同步输入框与 question,复选框与 stream
  • 响应式更新content 的任何变化都会触发 DOM 更新,实现“打字机”效果。
  • 无障碍基础:可通过添加 label for、ARIA 属性进一步优化。

七、安全、性能与扩展建议

1. 安全加固

  • 后端代理:创建 /api/proxy-deepseek 接口,前端只调用本地路径。
  • CORS 限制:后端设置 Access-Control-Allow-Origin 为可信域名。
  • 请求频率限制:防止恶意刷接口。

2. 错误处理增强

try {
  const response = await fetch(...);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  // ...处理响应
} catch (err) {
  content.value = `请求失败: ${err.message}`;
}

3. 加载状态管理

const loading = ref(false);
const askLLM = async () => {
  if (loading.value) return;
  loading.value = true;
  try { /* ... */ } finally {
    loading.value = false;
  }
}

并在按钮上绑定 :disabled="loading"

4. 多轮对话支持

维护一个 messages 数组:

const messages = ref([
  { role: 'user', content: '你好' },
  { role: 'assistant', content: '你好!有什么我可以帮你的吗?' }
]);

每次提问后追加用户消息,收到回答后追加助手消息。


八、总结

这段看似简单的代码,实则融合了前端响应式编程、异步流处理、AI API 集成、用户体验设计四大维度。通过深入理解其每一行背后的原理——尤其是流式响应的缓冲区管理与 SSE 协议解析——你不仅能复现此功能,更能在此基础上构建企业级的智能对话应用。

未来,随着 Web 标准的演进(如 ReadableStream 的普及)和 AI 模型能力的增强,前端开发者将在人机交互中扮演越来越重要的角色。而扎实掌握这些底层机制,正是迈向高阶开发的关键一步。

最后忠告:技术探索值得鼓励,但请永远将安全性放在首位。不要让 API 密钥成为你项目的“定时炸弹”。

AI打字机的秘密:一个 buffer 如何让机器学会“慢慢说话”

作者 xhxxx
2025年12月7日 14:52

当AI像打字机一样说话:揭秘流式输出背后的魔法

你有没有过这样的体验:和ChatGPT对话时,它不是突然蹦出整段文字,而是像真人一样,一个字一个字地敲出来?这种"打字机效果"不仅让交互更自然,还大大提升了用户体验——你不再需要焦虑地等待,而是能实时感受到AI的"思考过程"。


话不多说,先奉上代码效果

lovegif_1765087971567.gif

今天,我们将一起探索这个神奇效果背后的原理,并亲手实现一个Vue 3 + DeepSeek API的流式对话界面。在开始代码之前,先让我们理解一个关键概念:缓冲区(Buffer)

🌊 为什么需要"缓冲区"?—— 流式输出的基础

LLM 流式接口返回的是 Server-Sent Events (SSE) 格式的数据流,例如

image.png 但底层传输使用的是 HTTP/1.1 Chunked Transfer Encoding 或 HTTP/2 流,数据被切成任意大小的 二进制块(chunks) 发送。

所以每一行的数据可能是不完整的,这就有可能造成数据的丢失从而无法解析完整的数据

这时,缓冲区(Buffer) 就登场了!它像一个临时存储区,把不完整的数据先存起来,等到拼出完整的一行(如data: {"choices":[{"delta":{"content":"好"}}]})再处理。

Buffer的核心作用:解决网络分包导致的JSON解析失败问题,确保每个字都能正确显示。

HTML5中的Buffer实现

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML5 Buffer</title>
</head>
<body>
    <h1>HTML5 Buffer</h1>
    <div id="output"></div>
    <script>
        //JS 二进制、数组缓存
        //html5 编码对象
        const encoder = new TextEncoder();
        console.log(encoder);
        const myBuffer =encoder.encode("你好 HTML5");
        console.log(myBuffer);
        // 数组缓存 12 字节
        // 创建一个缓冲区
        const buffer = new ArrayBuffer(12);
        // 创建一个视图(View)来操作这个缓冲区 
        const view = new Uint8Array(buffer);
        for(let i=0;i<myBuffer.length;i++){
           //   console.log(myBuffer[i]);
           view[i] = myBuffer[i];   
        }
        const decoder = new TextDecoder();
        const originalText = decoder.decode(buffer);
        console.log(originalText);
        const outputDiv = document.getElementById("output");
        outputDiv.innerHTML =`
        完整数据:[${view}]<br>
        第一个字节:${view[0]}<br>
        缓冲区的字节长度${buffer.byteLength}<br>
        原始文本:${originalText}<br>

        `
    </script>
</html>

请看这样一张图

我们输入的文本是你好 HTML5但通过二进制传输就变成了这样的Uint8Array —— 无符号 8 位整数数组 image.png 实现的原理?
关键点解析:

  1. TextEncoder:文本->字节的转换器通过调用encode方法将文本(字符串)编码成计算机能读懂的二进制字节序列(Uint8Array),这就是网络传输中的原始数据
  2. TextDecoder:字节->文本,他是encode的逆向过程,把二进制的数据解读为文本
  3. Uint8Array的底层内存块:ArrayBuffer:- ArrayBuffer 是底层的内存区域,存储实际的二进制数据,而你可以认为Uint8Array是对ArrayBuffer一种解读方式(UTF-8)

🌐 为什么这对流式输出很重要?

当你调用 LLM 接口时:

  1. 服务器发送的是 二进制流(chunked transfer encoding)
  2. 浏览器收到的是 Uint8Array 形式的 chunk
  3. 你需要用 TextDecoder 将其解码为字符串
  4. 再用 buffer 拼接不完整的行(如 data: {"delta":...}

🚨 所以:TextEncoder/TextDecoder 是连接“文本世界”和“字节世界”的桥梁

现在你可以明白:当 AI 一个字一个字地输出时,背后正是这些 Uint8ArrayTextDecoder 在默默工作


🧩 现在,让我们用代码实现这个"打字机"效果

下面,我们将从零开始构建一个Vue 3应用,实现LLM流式输出。


1. 响应式数据定义:Vue 3的"心脏"

<script setup>
import { ref } from 'vue'

const question = ref('讲一个喜洋洋和灰太狼的故事,200字')
const stream = ref(true)   // 默认开启流式
const content = ref("")    // 用于显示模型回答
</script>

关键点解析:使用ref响应式数据,能够更方便的快速绑定数据,当变量改变时能够实时更新页面内容,这也是我们选择vue框架的原因


2. 调用LLM的核心函数:askLLM

const askLLM = async () => { 
  if (!question.value) {
    console.log('question 不能为空');
    return 
  }
  content.value = '思考中...';  // 提前反馈

  // 构造API请求
  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
  })

关键点解析:

  1. 使用.env文件存储apikey
  2. 当调用大模型时,初始化content的值为“思考中”,优化用户体验
  3. 使用stream控制大模型的流式输出
  4. 通过messsges给出大模型清晰的上下文

3. 非流式模式:简单但不够"丝滑"

  if (!stream.value) {
    const data = await response.json();
    content.value = data.choices[0].message.content;
  }

产品设计的理念:
非流式模型的实现很简单,等待大模型完成所有的输出后,一次性将其输出到页面,但是这对用户来说是一个糟糕的体验,对于一个产品来说,能更快的显示出页面数据,减少用户的等待时间就能留住更多的用户,没有人喜欢看不见进度条的一直等待!!!

4. 流式模式:核心魔法所在(重点!)

  if (stream.value) {
    content.value = "";  // 清空上一次的输出
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';

    while (!done) {
      const { value, done: doneReading } = await reader?.read();
      done = doneReading;
      if (!value) continue;

      // 关键:用buffer拼接不完整的JSON行
      const chunkValue = buffer + decoder.decode(value);
      buffer = '';

      // 按行分割,只处理有效的data:行
      const lines = chunkValue.split('\n')
        .filter(line => line.startsWith('data: '));

      for (const line of lines) {
        const incoming = line.slice(6); // 移除"data: "

        if (incoming === '[DONE]') {
          done = true;
          break;
        }

        try {
          // 解析JSON,获取新增的文本片段(delta)
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) {
            content.value += delta; // 响应式更新!
          }
        } catch (err) {
          // JSON解析失败?存入buffer等待下一次拼接
          buffer += `data: ${incoming}`;
        }
      }
    }
  }

关键点解析:

  1. reder-->读取二进制流,decoder将二进制块解码为字符串
  2. const reader = response.body?.getReader();这是一条可选链。如果body不为空,则调用getReader();
  3. 这个 reader 有一个关键方法:read(),它返回一个 Promise,解析为 { value: Uint8Array, done: boolean }:我们通过const { value, done: doneReading } = await reader?.read();将value和done解构出来,同时为了避免done和我们上面定义的done冲突,我们采用重命名的方式解构,将他重命名为doneReading
  4. 什么是chunk?在计算机网络中,数据不是一次性全部发送的,而是被切成一个个小段,逐个发送。这些小段就叫 chunks(数据块) 。而在浏览器的fetchAPI中,chunk就是通过reader.read()读取到的一个一个对象中的value
  5. 数据过滤,通过filter和startWith筛选出以data:开头的有效数据,然后通过slice()方法,将所有筛选出的数据切割掉data: 部分,方便后续解析JSON
  6. 使用try/catch防止丢字:因为大模型一次给出的token是不确定的,而我们的data{}一次能不能传完也不确定,所以一行data{}可能会被拆成两部分,这就会导致这一段解析失败,那么解析失败的数据并不是我们不需要的,只是它不小心被分成了两部分,所以我们需要对它进行存储,你能想到什么?没错就是buffer,我们将能够解析的部分先拼接到content中显示到页面,解析失败的我们则倒退的到它的“初态”,将data:拼接回去,然后存入bufer,在读取下一行时,把它拼接到最前面,与自己丢失的部分匹配,然后进行新一轮的流式处理,当我们完成拼接后,需要把buffer清空,不然会影响到下一次的拼接
  7. 流式输出结束的标志[DONE]:当我们对字符串进行处理时,如果剩余部分是[DONE]则代表所有内容已经输出完毕,我们就设置done为true来结束读取;

image.png


5. 模板与交互:让UI活起来

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input v-model="question" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>Streaming</label>
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>

image.png

关键点解析:

  1. v-model:双向绑定表单数据,无论我们修改表单数据还是直接修改变量,另外一边也能同时进行更新
  2. @click ="":vue中不再需要像JS中一样机械的流程式去监听DOM元素,我们直接可以为DOM元素绑定事件,当触发事件时自动调用方法

✨ 为什么这个"打字机"效果如此重要?

传统模式 流式模式
等待完整回答(2-5秒) 逐字显示(0.1-0.5秒/字)
用户焦虑等待 实时反馈,感觉AI在"思考"
体验生硬 交互自然,像真人对话

💡 关键洞察:流式输出不是技术炫技,而是用户心理的深度优化——它让AI从"工具"变成了"对话伙伴"。 代码虽短,但蕴含了现代AI交互的核心思想。真正的技术不是写代码,而是理解用户在等待时的心理
最后的思考:当AI能"打字"时,它不再是冰冷的机器,而成了你对话中的伙伴。而你,已经掌握了创造这种对话的魔法。

递归 VS 动态规划:从 “无限套娃计算器” 到 “积木式解题神器”

2025年12月7日 13:49

就占用你 5 分钟,让你搞懂递归和 DP 算法!

前言

你有没有试过:对着计算器按 “× 上一个数”,按到手指酸?或者自己照镜子,镜子里面有个自己,镜子的镜子里面还有个自己?这就是递归 —— 像台 “无限套娃计算器”,算大数能把自己 “算死机”;而动态规划是 “积木式解题神器”,从最小块开始拼,再复杂的题都能稳准狠搞定。

一、递归:“套娃计算器” 的用法

递归就俩关键:找 “套娃公式”(规律)+ 找 “最小娃”(出口)

例 1:算阶乘 —— 套娃停不下来?

如果要你计算5的阶乘,你会怎么做?大部分人第一想法应该都是:直接一个for循环不就完事了吗?没错:

function mul(n) {
    let num = 1;
    for(let i = n; i >= 1; i--){
        num *= i;
    }
    return num;
}
console.log(mul(5));

image.png

但是你用你聪明的脑袋想了又想,阶乘是 “5! = 5×4×3×2×1”,套娃公式是 “n! = n × (n-1)!”(大娃里塞小娃);最小娃是 “1! = 1”(塞到最小的娃就停)。好像这样也能写出来!

看代码(递归版阶乘):

function mul(n) {
    // 最小娃:1!直接返回 1
    if(n == 1){
        return 1;
    }
    // 套娃公式:大娃=自己×小娃
    return n * mul(n - 1);
}
console.log(mul(5));

image.png

我嘞个豆,答案✅️了,这就是我们今天的主角--大名鼎鼎的递归。但这计算器有 bug:例如算mul(50000)会 “套娃太多死机”(栈溢出)。在我写的浅拷贝 VS 深拷贝这篇文章中有掘u问到了这个问题。所以递归一般在非必要情况下使用,因为数据一大就会爆栈。

例 2:斐波那契数列 —— 套两层娃?

斐波那契是 “1,1,2,3,5……”,套娃公式是 “第 n 项 = 第 n-1 项 + 第 n-2 项”(一个娃里塞俩小娃);最小娃是 “第 1、2 项 = 1”。

function fb(n) {
    // 最小娃:前两个直接返回 1
    if(n == 1 || n == 2){
        return 1;
    } 
    // 套娃公式:自己=左边娃+右边娃
    return fb(n - 1) + fb(n - 2);
}
console.log(fb(5));

image.png

这计算器更费电:算fb(100)要重复掏同一批娃,卡到你怀疑人生~,这就是递归,好理解但难用,接下来我们开始经典算法--动态规划

二、动态规划:“积木神器” 怎么拼?

动态规划是 “反着来”—— 不用拆娃,直接从 最小积木块(已知结果) 开始,一块一块拼出最终答案,像搭乐高一样稳。

例:爬楼梯 - 力扣(LeetCode)

有一个思路,那就是直接暴力通项公式,看看官方题解:

image.png

var climbStairs = function(n) {
    const sqrt5 = Math.sqrt(5);
    const fibn = Math.pow((1 + sqrt5) / 2, n + 1) - Math.pow((1 - sqrt5) / 2, n + 1);
    return Math.round(fibn / sqrt5);
};

但是这里我们用算法思想,也就是动态规划。积木公式:第 n 级的拼法 = 第 n-1 级拼法(最后加 1 块)+ 第 n-2 级拼法(最后加 2 块)。

看代码(动态规划版爬楼梯):

var climbStairs = function (n) {
    // 积木盒dp:存每级台阶的拼法数
    let dp = [];
    // 基础积木:0级(起点)和1级各1种拼法
    dp[0] = 1;
    dp[1] = 1;
    // 从2级开始,用现有积木拼新台阶
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    // 第n级的拼法就是dp[n]
    return dp[n];
};
console.log(climbStairs(5)); 

image.png

这神器不卡不崩,再大的 n 都能轻松搞定~

总结:套娃计算器 vs 积木神器

  • 递归(套娃计算器):上手快,但算大数容易 “死机”;
  • 动态规划(积木神器):从基础块开拼,稳、准、快,复杂题克星。

我最后总结这样一份表格,希望能够帮到各位:

维度 递归(Recursion) 动态规划(Dynamic Programming, DP)
核心思想 把大问题拆解为规模更小的子问题,通过调用自身解决子问题,最终合并子问题结果得到原问题解 将大问题拆解为重叠子问题,通过存储子问题的解(记忆化 / 表格)避免重复计算,自底向上或自顶向下求解
问题特征 1. 问题可自然拆解为独立子问题(无大量重复计算)2. 问题具有递归结构(如树形结构、分治场景)3. 子问题规模递减且无重叠 1. 问题具有重叠子问题(子问题被重复求解)2. 问题具有最优子结构(原问题最优解由子问题最优解组成)3. 存在状态转移关系
适用场景 1. 分治算法(如归并排序、快速排序)2. 树形结构遍历 / 操作(如二叉树的遍历、求深度、路径和)3. 排列组合枚举(如全排列、子集生成)4. 回溯算法(如 N 皇后、迷宫问题)5. 问题子问题无重叠,递归深度可控 1. 优化类问题(如最短路径、最大收益、最小代价)2. 计数类问题(如不同路径数、解码方式数)3. 子问题大量重复的场景(如斐波那契数列、背包问题)4. 状态可清晰定义且存在转移关系的问题
实现方式 1. 纯递归(无记忆化,直接调用自身)2. 递归 + 记忆化(Top-Down DP,属于 DP 的一种) 1. 自顶向下(记忆化递归)2. 自底向上(迭代 + 表格,如二维 / 一维 DP 数组)
时间复杂度 纯递归:若存在重叠子问题,时间复杂度指数级(如斐波那契纯递归为 O (2ⁿ));无重叠子问题时为 O (n) 或 O (nlogn)(如归并排序) 消除重复计算,时间复杂度通常为 O (n)、O (nm) 等多项式级别(如斐波那契 DP 为 O (n),01 背包为 O (nm))
空间复杂度 纯递归:递归调用栈深度(如二叉树递归遍历为 O (h),h 为树高);若递归深度过大可能栈溢出 自底向上 DP:存储状态的数组 / 表格空间(如 O (n) 或 O (nm));可优化空间(如滚动数组),无栈溢出风险
代码风格 代码简洁、直观,符合问题的自然拆解逻辑,易编写和理解 需手动定义状态和转移方程,代码相对复杂,但效率更高
典型例子 1. 二叉树的前 / 中 / 后序遍历2. 归并排序 / 快速排序3. 全排列 / 组合总和4. 汉诺塔问题5. 回溯法解 N 皇后 1. 斐波那契数列(优化版)2. 01 背包 / 完全背包问题3. 最长公共子序列(LCS)4. 最短路径(Floyd-Warshall/Dijkstra 的 DP 思想)5. 最大子数组和(Kadane 算法)
局限性 1. 重叠子问题导致重复计算,效率低2. 递归深度过大易引发栈溢出(如 n=10000 的斐波那契递归)3. 函数调用开销 1. 需明确状态定义和转移方程,对问题建模要求高2. 不适用于子问题无重叠的场景(反而增加空间开销)

使用 Vue 3 实现大模型流式输出:从零搭建一个简易对话 Demo

作者 ohyeah
2025年12月7日 13:39

在当前 AI 应用快速发展的背景下,前端开发者越来越多地需要与大语言模型(LLM)进行交互。本文将基于你提供的 App.vue 代码和学习笔记,带你一步步理解如何使用 Vue 3 + Composition API 构建一个支持 流式输出(Streaming) 的 LLM 对话界面。我们将重点解析代码结构、响应式原理、流式数据处理逻辑,并确保内容通俗易懂,适合初学者或希望快速上手的开发者。


一、项目初始化与技术选型

项目是通过 Vite 初始化的:

npm init vite

选择 Vue 3 + JavaScript 模板。Vite 作为新一代前端构建工具,以其极速的冷启动和热更新能力,成为现代 Vue 项目的首选脚手架。

生成的项目结构简洁清晰,核心开发文件位于 src/ 目录下,而 App.vue 就是整个应用的根组件。


二、Vue 3 的“三明治”结构

.vue 文件由三部分组成:

  • <script setup>:逻辑层(使用 Composition API)
  • <template>:模板层(声明式 UI)
  • <style scoped>:样式层(作用域 CSS)

这种结构让代码职责分明,也便于维护。


三、响应式数据:ref 的核心作用

<script setup> 中,你使用了 ref 来创建响应式变量:

import { ref } from 'vue'

const question = ref('讲一个喜羊羊和灰太狼的小故事,不低于20字')
//控制是否启用流式输出(streaming) 默认开启
const stream = ref(true)
//声明content 单向绑定 用于呈现LLM输出的值
const content = ref('')

什么是 ref

  • ref 是 Vue 3 Composition API 提供的一个函数,用于创建响应式引用对象
  • 它内部包裹一个值(如字符串、数字等),并通过 .value 访问或修改。
  • 在模板中使用时,Vue 会自动解包 .value,所以你只需写 {{ content }} 而非 {{ content.value }}

关键点:当 ref 的值发生变化时,模板会自动重新渲染——这就是“响应式”的核心。

例如:

let count = ref(111)//此时count就为响应式对象
setTimeout(() => {
  count.value = 222 // 模板中绑定 {{ count }} 会自动更新为 222
}, 2000)

这避免了传统 DOM 操作(如 getElementById().innerText = ...),让开发者更专注于业务逻辑。


四、双向绑定:v-model 的妙用

在输入框中,你使用了 v-model

<input type="input" v-model="question" />

v-model 是什么?

  • 它是 Vue 提供的双向数据绑定指令。
  • 输入框的值与 question.value 实时同步:用户输入 → question 更新;question 变化 → 输入框内容更新。
  • 如果改用 :value="question",则只能单向绑定(数据 → 视图),无法实现用户输入自动更新数据。

这使得表单处理变得极其简单。


五、调用大模型 API:异步请求与流式处理

核心功能在 askLLM 函数中实现:

//调用大模型 async await 异步任务同步化
const askLLM = async () => {
  if (!question.value) {
    console.log('question 不能为空!')
    return
    //校验question.value 为空直接return 避免无意义地进行下一步操作
  }
  //提升用户体验 先显示'思考中...' 表示正在处理
  content.value = '思考中...'
  
  //发生请求的时候 首先发送 请求行(方法 url 版本)
  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,//启用流式输出
      messages: [{ role: 'user', content: question.value }]
    })
  })

关键细节:

  1. 环境变量安全:API Key 通过 import.meta.env.VITE_DEEPSEEK_API_KEY 引入。Vite 要求以 VITE_ 开头的环境变量才能在客户端暴露,这是一种安全实践。
  2. 请求体结构:符合 OpenAI 兼容 API 标准,指定模型、是否流式、消息历史。
  3. 用户体验优化:请求发起后立即显示“思考中...”,避免界面卡顿感。

六、流式输出(Streaming)的实现原理

这是本文的重点。当 stream.value === true 时,采用流式处理:

if (stream.value) {
  content.value = ''//先把上一次的输出清空
  //html5 流式响应体 getReader() 响应体的读对象
  const reader = response.body?.getReader()
  const decoder = new TextDecoder()
  let done = false //用来判断流是否结束
  let buffer = ''

  while (!done) {
  //只要流还未结束 就一直拼接buffer
  //解构的同时  重命名 done-> doneReading
    const { value, done: doneReading } = await reader?.read()
    done = doneReading //当数据流结束后 赋值给外部的done 结束while
    const chunkValue = buffer + decoder.decode(value, { stream: true })
    buffer = ''

    const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
    for (const line of lines) {
      const incoming = line.slice(6) // 去掉 "data: "  去除数据标签
      if (incoming === '[DONE]') {
        done = true //将外部done改为true 结束循环
        break
      }
      try {
        const data = JSON.parse(incoming)
        const delta = data.choices[0].delta.content
        if (delta) {
          content.value += delta
        }
      } catch (err) {
      //JSON.parse解析失败  拿给下一次去解析
        buffer += `data: ${incoming}`
      }
    }
  }
}

流式输出的工作流程:

  1. 获取可读流response.body.getReader() 返回一个 ReadableStreamDefaultReader

  2. 逐块读取:每次 reader.read() 返回一个 { value, done } 对象,valueUint8Array(二进制数据)。

  3. 解码为字符串:使用 TextDecoder 将二进制转为文本。注意传入 { stream: true } 避免 UTF-8 截断问题。

  4. 按行解析 SSE(Server-Sent Events)

    • 服务端返回格式为多行 data: {...}\n
    • 每行以 data: 开头,末尾可能有 \n\n
    • 遇到 [DONE] 表示流结束。
  5. 拼接增量内容delta.content 是当前 token 的文本片段,不断追加到 content.value,实现“打字机”效果。

💡 为什么需要 buffer?
因为网络传输的 chunk 可能不完整(比如一个 JSON 被切成两半),所以未解析成功的部分暂存到 buffer,下次循环再拼接处理。


七、非流式模式的简化处理

如果不启用流式(stream = false),则直接等待完整响应:

else {
  const data = await response.json()
  content.value = data.choices[0].message.content
}

这种方式简单直接,但用户体验较差——用户需等待全部内容生成完毕才能看到结果。


八、模板与样式:简洁直观的 UI

<template>
  <div class="container">
    <div>
      <label>输入: </label>
      <input v-model="question" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>Streaming</label>
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>
  • 用户可切换流式/非流式模式。
  • 输出区域实时展示 LLM 的回复。

样式使用 flex 布局,确保在不同屏幕下良好显示。


九、总结与延伸

通过这个 Demo,我们实现了:

✅ 使用 ref 管理响应式状态
✅ 利用 v-model 实现表单双向绑定
✅ 调用 DeepSeek API 发起聊天请求
✅ 支持流式与非流式两种输出模式
✅ 处理 SSE 流式响应,实现逐字输出效果


结语

这个项目虽小,却涵盖了 Vue 3 响应式、异步请求、流式处理等核心概念。正如笔记所说:“我们就可以聚焦于业务,不用写 DOM API 了”。这正是现代前端框架的价值所在——让我们从繁琐的 DOM 操作中解放出来,专注于创造更好的用户体验。

希望这篇解析能帮助你在稀土掘金的读者快速理解代码逻辑,并激发更多关于 AI + 前端的创意!

React Native vs React Web:深度对比与架构解析

作者 北辰alk
2025年12月7日 12:34

一、引言:同一个理念,不同的实现

React 技术栈以其"Learn Once, Write Anywhere"的理念改变了前端开发格局。然而,许多开发者常混淆 React Native 和 React Web(通常简称 React)之间的区别。虽然它们共享相同的设计哲学,但在实现、架构和应用场景上存在本质差异。本文将深入探讨两者的核心区别,并通过代码示例、架构图展示实际差异。

二、核心理念对比

1. 设计哲学的异同

mindmap
  root(React 技术栈)
    核心理念
      组件化
      声明式UI
      单向数据流
    技术实现
      React Web
        :DOM操作
        :CSS样式
        :浏览器API
      React Native
        :原生组件
        :平台API
        :原生渲染

相同点:

  • 组件化开发模式
  • 虚拟DOM概念
  • JSX语法
  • 单向数据流
  • 生命周期管理(在函数组件中为Hooks)

不同点:

  • 渲染目标:React Web 渲染到浏览器DOM,React Native 渲染到原生UI组件
  • 样式系统:React Web 使用CSS,React Native 使用JavaScript对象
  • 生态体系:完全不同的第三方库生态系统
  • 平台能力:访问的平台API完全不同

三、架构深度解析

1. React Web 架构

// React Web 渲染流程
import React from 'react';
import ReactDOM from 'react-dom';

const App = () => {
  return (
    <div className="container">
      <h1>Hello React Web</h1>
      <p>This renders to DOM</p>
    </div>
  );
};

// 渲染到浏览器DOM
ReactDOM.render(<App />, document.getElementById('root'));

React Web 架构流程图:

flowchart TD
    A[JSX/组件] --> B[React.createElement<br>创建虚拟DOM]
    B --> C[Reconciliation<br>对比虚拟DOM差异]
    C --> D[DOM操作<br>更新实际DOM]
    D --> E[浏览器渲染<br>布局与绘制]
    E --> F[用户界面<br>HTML/CSS渲染]
    
    G[用户交互] --> H[事件处理]
    H --> A

2. React Native 架构

// React Native 渲染流程
import React from 'react';
import { 
  View, 
  Text, 
  StyleSheet,
  AppRegistry 
} from 'react-native';

const App = () => {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Hello React Native</Text>
      <Text>This renders to native components</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  text: {
    fontSize: 20,
    fontWeight: 'bold',
  },
});

// 注册并启动应用
AppRegistry.registerComponent('MyApp', () => App);

React Native 架构流程图:

flowchart TD
    A[JSX/组件] --> B[JavaScript Core<br>执行React代码]
    B --> C[React Native Bridge<br>跨平台通信]
    C --> D{iOS/Android<br>原生模块}
    D --> E[iOS UIKit<br>Objective-C/Swift]
    D --> F[Android Views<br>Java/Kotlin]
    E --> G[原生UI渲染<br>iOS屏幕]
    F --> H[原生UI渲染<br>Android屏幕]
    
    I[用户交互] --> J[原生事件]
    J --> C
    C --> B

四、组件系统对比

1. 基础组件差异对比表

组件类型 React Web (HTML) React Native (原生) 功能说明
容器 <div> <View> 布局容器
文本 <span>, <p> <Text> 文本显示
图片 <img> <Image> 图片显示
按钮 <button> <Button>, <TouchableOpacity> 交互按钮
输入 <input> <TextInput> 文本输入
列表 <ul>, <table> <FlatList>, <ScrollView> 列表展示
滚动 <div style="overflow:auto"> <ScrollView> 滚动容器

2. 实际代码对比

// ============ REACT WEB ============
import React, { useState } from 'react';
import './styles.css'; // 引入CSS文件

const WebComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div className="container">
      <header className="header">
        <h1 className="title">React Web App</h1>
      </header>
      <main className="content">
        <p className="count-text">Count: {count}</p>
        <button 
          className="button" 
          onClick={() => setCount(count + 1)}
        >
          Increment
        </button>
        <input 
          type="text" 
          className="input" 
          placeholder="Enter text..."
        />
        <img 
          src="/logo.png" 
          alt="Logo" 
          className="logo"
        />
      </main>
    </div>
  );
};

// ============ REACT NATIVE ============
import React, { useState } from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  TextInput,
  Image,
  StyleSheet,
  SafeAreaView,
} from 'react-native';

const NativeComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <SafeAreaView style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.title}>React Native App</Text>
      </View>
      <View style={styles.content}>
        <Text style={styles.countText}>Count: {count}</Text>
        <TouchableOpacity
          style={styles.button}
          onPress={() => setCount(count + 1)}
        >
          <Text style={styles.buttonText}>Increment</Text>
        </TouchableOpacity>
        <TextInput
          style={styles.input}
          placeholder="Enter text..."
          placeholderTextColor="#999"
        />
        <Image
          source={require('./logo.png')}
          style={styles.logo}
          resizeMode="contain"
        />
      </View>
    </SafeAreaView>
  );
};

// React Native 样式定义
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  header: {
    padding: 20,
    backgroundColor: '#007AFF',
    alignItems: 'center',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: 'white',
  },
  content: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  countText: {
    fontSize: 32,
    marginBottom: 20,
  },
  button: {
    backgroundColor: '#007AFF',
    paddingHorizontal: 30,
    paddingVertical: 15,
    borderRadius: 8,
    marginBottom: 20,
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: '600',
  },
  input: {
    width: '80%',
    height: 50,
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    paddingHorizontal: 15,
    marginBottom: 20,
    fontSize: 16,
  },
  logo: {
    width: 100,
    height: 100,
  },
});

五、样式系统深度对比

1. React Web 样式系统

/* styles.css - CSS Modules 示例 */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.button {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 12px 24px;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.button:hover {
  transform: translateY(-2px);
  box-shadow: 0 10px 20px rgba(0,0,0,0.2);
}

/* CSS-in-JS 示例 (styled-components) */
import styled from 'styled-components';

const StyledButton = styled.button`
  background: ${props => props.primary ? '#007AFF' : '#ccc'};
  color: white;
  padding: 12px 24px;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  
  &:hover {
    opacity: 0.9;
  }
  
  &:active {
    transform: scale(0.98);
  }
`;

2. React Native 样式系统

// StyleSheet 示例
import { StyleSheet, Dimensions } from 'react-native';

const { width, height } = Dimensions.get('window');

const styles = StyleSheet.create({
  container: {
    flex: 1,
    width: width, // 响应式宽度
    backgroundColor: '#ffffff',
  },
  card: {
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
    elevation: 5, // Android阴影
    borderRadius: 10,
    backgroundColor: 'white',
    margin: 10,
    padding: 15,
  },
  gradientButton: {
    // 注意:React Native 需要第三方库实现渐变
    backgroundColor: '#007AFF',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
  },
});

// 响应式布局示例
const responsiveStyles = StyleSheet.create({
  container: {
    flexDirection: width > 768 ? 'row' : 'column',
  },
  column: {
    flex: width > 768 ? 1 : undefined,
  },
});

// 平台特定样式
const platformStyles = StyleSheet.create({
  header: {
    paddingTop: Platform.OS === 'ios' ? 50 : 25, // iOS有安全区域
    ...Platform.select({
      ios: {
        backgroundColor: '#f8f8f8',
      },
      android: {
        backgroundColor: '#ffffff',
      },
    }),
  },
});

六、导航系统对比

1. React Web 导航

// React Router 示例
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';

const WebNavigation = () => (
  <BrowserRouter>
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      <Link to="/contact">Contact</Link>
    </nav>
    
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      <Route path="/contact" element={<Contact />} />
      <Route path="/user/:id" element={<UserProfile />} />
    </Routes>
  </BrowserRouter>
);

// 历史记录API访问
const navigateToAbout = () => {
  window.history.pushState({}, '', '/about');
  // 或使用react-router的useNavigate
};

2. React Native 导航

// React Navigation 示例
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';

const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();

// 栈式导航
const AppNavigator = () => (
  <NavigationContainer>
    <Stack.Navigator
      initialRouteName="Home"
      screenOptions={{
        headerStyle: {
          backgroundColor: '#007AFF',
        },
        headerTintColor: '#fff',
      }}
    >
      <Stack.Screen 
        name="Home" 
        component={HomeScreen}
        options={{ title: '首页' }}
      />
      <Stack.Screen 
        name="Details" 
        component={DetailsScreen}
        options={({ route }) => ({ 
          title: route.params?.title || '详情'
        })}
      />
    </Stack.Navigator>
  </NavigationContainer>
);

// 标签页导航
const TabNavigator = () => (
  <Tab.Navigator
    screenOptions={({ route }) => ({
      tabBarIcon: ({ focused, color, size }) => {
        let iconName;
        if (route.name === 'Home') {
          iconName = focused ? 'home' : 'home-outline';
        } else if (route.name === 'Settings') {
          iconName = focused ? 'settings' : 'settings-outline';
        }
        return <Icon name={iconName} size={size} color={color} />;
      },
    })}
  >
    <Tab.Screen name="Home" component={HomeScreen} />
    <Tab.Screen name="Settings" component={SettingsScreen} />
  </Tab.Navigator>
);

七、平台API访问对比

1. React Web API 访问

// 浏览器API访问示例
class WebAPIService {
  // 本地存储
  static saveData(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  }
  
  static getData(key) {
    const data = localStorage.getItem(key);
    return data ? JSON.parse(data) : null;
  }
  
  // 地理位置
  static async getLocation() {
    return new Promise((resolve, reject) => {
      if (!navigator.geolocation) {
        reject(new Error('Geolocation not supported'));
        return;
      }
      
      navigator.geolocation.getCurrentPosition(
        position => resolve(position.coords),
        error => reject(error),
        { enableHighAccuracy: true }
      );
    });
  }
  
  // 摄像头访问
  static async accessCamera() {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true,
    });
    return stream;
  }
  
  // 网络状态
  static getNetworkStatus() {
    return {
      online: navigator.onLine,
      connection: navigator.connection || {},
    };
  }
}

// 使用示例
WebAPIService.saveData('user', { name: 'John' });
const location = await WebAPIService.getLocation();

2. React Native API 访问

// React Native 原生模块访问
import {
  AsyncStorage,
  Geolocation,
  PermissionsAndroid,
  Platform,
} from 'react-native';
import CameraRoll from '@react-native-community/cameraroll';
import NetInfo from '@react-native-community/netinfo';

class NativeAPIService {
  // 本地存储(使用AsyncStorage)
  static async saveData(key, value) {
    try {
      await AsyncStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('保存数据失败:', error);
    }
  }
  
  static async getData(key) {
    try {
      const value = await AsyncStorage.getItem(key);
      return value ? JSON.parse(value) : null;
    } catch (error) {
      console.error('读取数据失败:', error);
      return null;
    }
  }
  
  // 地理位置(需要权限)
  static async getLocation() {
    if (Platform.OS === 'android') {
      const granted = await PermissionsAndroid.request(
        PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
      );
      if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
        throw new Error('Location permission denied');
      }
    }
    
    return new Promise((resolve, reject) => {
      Geolocation.getCurrentPosition(
        position => resolve(position.coords),
        error => reject(error),
        { enableHighAccuracy: true, timeout: 15000 }
      );
    });
  }
  
  // 访问相册
  static async getPhotos(params) {
    try {
      const photos = await CameraRoll.getPhotos(params);
      return photos;
    } catch (error) {
      console.error('获取照片失败:', error);
      throw error;
    }
  }
  
  // 网络状态监听
  static setupNetworkListener(callback) {
    return NetInfo.addEventListener(state => {
      callback(state);
    });
  }
  
  // 设备信息
  static getDeviceInfo() {
    return {
      platform: Platform.OS,
      version: Platform.Version,
      isPad: Platform.isPad,
      isTV: Platform.isTV,
    };
  }
}

// 使用示例
const location = await NativeAPIService.getLocation();
const unsubscribe = NativeAPIService.setupNetworkListener(state => {
  console.log('网络状态:', state.isConnected);
});

八、性能优化策略对比

性能优化对比表

优化维度 React Web React Native 说明
渲染优化 Virtual DOM Diff 原生组件更新 React Web 操作DOM,RN直接更新原生组件
图片优化 Lazy Loading FastImage RN需要特殊处理图片缓存
列表优化 Virtual Scrolling FlatList优化 两者都需要虚拟化长列表
代码分割 Webpack动态导入 Metro Bundle分块 RN需要原生配置支持
内存管理 自动垃圾回收 需注意原生模块内存 RN需要手动管理部分内存

1. React Web 性能优化

// 代码分割和懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

// 使用memo和useCallback
const MemoizedComponent = React.memo(({ data }) => (
  <div>{data}</div>
));

// 虚拟化长列表
import { FixedSizeList } from 'react-window';

const VirtualizedList = ({ items }) => (
  <FixedSizeList
    height={400}
    width={300}
    itemCount={items.length}
    itemSize={50}
  >
    {({ index, style }) => (
      <div style={style}>
        Item {items[index]}
      </div>
    )}
  </FixedSizeList>
);

// Web Workers 处理耗时任务
const worker = new Worker('./heavy-task.worker.js');
worker.postMessage(data);
worker.onmessage = (event) => {
  console.log('结果:', event.data);
};

2. React Native 性能优化

// 使用PureComponent或memo
class OptimizedComponent extends React.PureComponent {
  render() {
    return <Text>{this.props.data}</Text>;
  }
}

// 优化FlatList
const OptimizedList = ({ data }) => (
  <FlatList
    data={data}
    keyExtractor={item => item.id}
    renderItem={renderItem}
    initialNumToRender={10}
    maxToRenderPerBatch={5}
    windowSize={21}
    removeClippedSubviews={true}
    getItemLayout={(data, index) => ({
      length: 50,
      offset: 50 * index,
      index,
    })}
  />
);

// 使用InteractionManager处理动画
InteractionManager.runAfterInteractions(() => {
  // 耗时操作,避免阻塞动画
});

// 图片优化
import FastImage from 'react-native-fast-image';

<FastImage
  style={styles.image}
  source={{
    uri: 'https://example.com/image.jpg',
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable,
  }}
/>;

九、开发体验对比

开发环境配置差异

# React Web 开发环境
开发工具: VSCode/WebStorm
包管理器: npm/yarn
构建工具: Webpack/Vite
开发服务器: webpack-dev-server
热重载: 内置支持
调试工具: Chrome DevTools

# React Native 开发环境
开发工具: VSCode/WebStorm/Xcode/Android Studio
包管理器: npm/yarn
构建工具: Metro Bundler
模拟器: iOS Simulator/Android Emulator
真机调试: 需要USB连接
调试工具: React Native Debugger/Flipper

热重载机制对比

// React Web 热重载流程
1. 文件保存 → 2. Webpack检测变化 → 3. 重新编译模块
4. 通过WebSocket推送更新 → 5. 客户端接收更新
6. 替换模块 → 7. 保留应用状态

// React Native 热重载流程
1. 文件保存 → 2. Metro检测变化 → 3. 增量构建
4. 推送更新到设备 → 5. 原生容器重新渲染
6. 保持JavaScript状态

十、跨平台复用策略

1. 共享业务逻辑

// shared/ 目录结构
shared/
├── api/
│   └── apiClient.js      # 网络请求封装
├── utils/
│   ├── dateFormatter.js  # 日期格式化
│   ├── validator.js      # 表单验证
│   └── constants.js      # 常量定义
├── services/
│   └── authService.js    # 认证服务
└── hooks/
    └── useFetch.js       # 自定义Hook

// 示例:共享的API客户端
class ApiClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    const response = await fetch(url, {
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
      ...options,
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    return response.json();
  }

  // 可在Web和Native中复用的方法
  async getUser(id) {
    return this.request(`/users/${id}`);
  }

  async createPost(data) {
    return this.request('/posts', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }
}

2. 条件平台渲染

// PlatformSpecific.js
import React from 'react';
import { Platform } from 'react-native';

// 方法1: 平台特定文件扩展名
// MyComponent.ios.js 和 MyComponent.android.js

// 方法2: 平台检测
const PlatformSpecificComponent = () => {
  if (Platform.OS === 'web') {
    return (
      <div className="web-container">
        <p>This is web version</p>
      </div>
    );
  }

  return (
    <View style={styles.nativeContainer}>
      <Text>This is native version</Text>
    </View>
  );
};

// 方法3: 平台特定Hook
const usePlatform = () => {
  return {
    isWeb: Platform.OS === 'web',
    isIOS: Platform.OS === 'ios',
    isAndroid: Platform.OS === 'android',
    platform: Platform.OS,
  };
};

// 方法4: 共享组件适配器
const Button = ({ title, onPress }) => {
  const { isWeb } = usePlatform();
  
  if (isWeb) {
    return (
      <button 
        className="button"
        onClick={onPress}
      >
        {title}
      </button>
    );
  }
  
  return (
    <TouchableOpacity
      style={styles.button}
      onPress={onPress}
    >
      <Text style={styles.buttonText}>{title}</Text>
    </TouchableOpacity>
  );
};

十一、实际项目架构示例

跨平台项目结构

my-cross-platform-app/
├── packages/
│   ├── web/                    # React Web 应用
│   │   ├── public/
│   │   ├── src/
│   │   ├── package.json
│   │   └── webpack.config.js
│   ├── mobile/                 # React Native 应用
│   │   ├── ios/
│   │   ├── android/
│   │   ├── src/
│   │   └── package.json
│   └── shared/                 # 共享代码
│       ├── components/         # 跨平台组件
│       ├── utils/             # 工具函数
│       ├── services/          # API服务
│       └── hooks/             # 自定义Hooks
├── package.json
└── yarn.lock

跨平台组件实现

// shared/components/Button/index.js
import React from 'react';
import { Platform } from 'react-native';

// 平台特定的实现
import { ButtonWeb } from './Button.web';
import { ButtonNative } from './Button.native';

const Button = (props) => {
  if (Platform.OS === 'web') {
    return <ButtonWeb {...props} />;
  }
  
  return <ButtonNative {...props} />;
};

export default Button;

// shared/components/Button/Button.web.js
import React from 'react';
import PropTypes from 'prop-types';

export const ButtonWeb = ({ 
  title, 
  onPress, 
  variant = 'primary',
  disabled 
}) => {
  return (
    <button
      className={`button button-${variant}`}
      onClick={onPress}
      disabled={disabled}
      style={{
        padding: '12px 24px',
        borderRadius: '6px',
        border: 'none',
        cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.6 : 1,
      }}
    >
      {title}
    </button>
  );
};

// shared/components/Button/Button.native.js
import React from 'react';
import { 
  TouchableOpacity, 
  Text, 
  StyleSheet,
  ActivityIndicator 
} from 'react-native';

export const ButtonNative = ({ 
  title, 
  onPress, 
  variant = 'primary',
  disabled,
  loading 
}) => {
  const variantStyles = {
    primary: styles.primaryButton,
    secondary: styles.secondaryButton,
    outline: styles.outlineButton,
  };

  return (
    <TouchableOpacity
      style={[
        styles.button,
        variantStyles[variant],
        disabled && styles.disabled,
      ]}
      onPress={onPress}
      disabled={disabled || loading}
      activeOpacity={0.7}
    >
      {loading ? (
        <ActivityIndicator 
          color={variant === 'outline' ? '#007AFF' : 'white'} 
        />
      ) : (
        <Text style={[
          styles.buttonText,
          variant === 'outline' && styles.outlineText,
        ]}>
          {title}
        </Text>
      )}
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  button: {
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 6,
    alignItems: 'center',
    justifyContent: 'center',
    minHeight: 48,
  },
  primaryButton: {
    backgroundColor: '#007AFF',
  },
  secondaryButton: {
    backgroundColor: '#6c757d',
  },
  outlineButton: {
    backgroundColor: 'transparent',
    borderWidth: 1,
    borderColor: '#007AFF',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
  outlineText: {
    color: '#007AFF',
  },
  disabled: {
    opacity: 0.6,
  },
});

十二、总结与选择建议

关键差异总结表

方面 React Web React Native 建议
目标平台 浏览器 iOS/Android移动端 根据目标用户选择
渲染方式 DOM操作 原生组件调用 Web适合内容展示,RN适合应用体验
开发体验 浏览器DevTools 模拟器/真机调试 Web开发更直观
性能特点 受浏览器限制 接近原生性能 性能敏感选RN
热更新 即时生效 需要重新打包 快速迭代选Web
发布流程 直接部署 应用商店审核 频繁更新选Web

选择建议流程图

flowchart TD
    A[项目需求分析] --> B{目标平台?}
    B --> C[仅Web/桌面端]
    B --> D[仅移动端<br>iOS/Android]
    B --> E[全平台覆盖]
    
    C --> F[选择 React Web<br>最佳开发体验]
    D --> G[选择 React Native<br>原生体验]
    E --> H{项目类型?}
    
    H --> I[内容型应用<br>新闻/博客/电商]
    H --> J[交互型应用<br>社交/工具/游戏]
    
    I --> K[优先 React Web<br>考虑PWA]
    J --> L[优先 React Native<br>考虑跨平台组件]
    
    K --> M[评估用户需求<br>适时添加React Native]
    L --> N[复用业务逻辑<br>平台特定UI]
    
    M --> O[监控用户反馈<br>数据驱动决策]
    N --> P[持续优化<br>保持代码复用]

最佳实践建议

  1. 新项目启动

    • 明确目标平台和用户群体
    • 评估团队技术栈熟悉度
    • 考虑长期维护成本
  2. 现有项目扩展

    • React Web项目可逐步集成PWA
    • React Native项目可考虑Web支持
    • 优先复用业务逻辑,平台差异通过适配层处理
  3. 团队建设

    • React Web和React Native需要不同的专业知识
    • 建立共享代码规范和组件库
    • 培养全栈React开发工程师
  4. 技术选型

    • 内容为主的应用优先考虑Web + PWA
    • 需要设备功能的应用优先考虑React Native
    • 大型企业应用考虑微前端架构

结语

React Web和React Native虽然共享相同的设计理念,但在实现、架构和应用场景上有本质区别。理解这些差异不仅有助于选择正确的技术栈,还能在跨平台开发中做出更明智的架构决策。

随着React生态的不断发展,两个平台之间的界限逐渐模糊。React Native for Web等项目正在尝试弥合这个鸿沟,未来的开发可能会更加无缝。无论选择哪个平台,深入理解React的核心概念都是成功的关键。

JS 实现指定 UA 访问网站跳转弹窗提醒,解决夸克等浏览器兼容性问题

2025年12月7日 11:44

在近期的网站使用过程中,我们发现来自部分移动端浏览器(尤其是 夸克浏览器、UC 浏览器、百度 APP 内置浏览器、微信内置浏览器)的访问量虽然不低,但这些浏览器在解析网页脚本、CSS 动画、内嵌组件等方面存在一定兼容性问题,导致页面在这些环境中出现:

  • 布局错乱
  • 按钮点击无反应
  • JS 逻辑异常
  • 视频、音频组件无法正常加载

这些问题严重影响了用户体验。经过多次调试和对比测试,我们最终决定对 不兼容的浏览器进行识别,并给出友好的弹窗提醒或跳转提示页,以引导用户使用更标准、兼容性更好的浏览器,例如 手机自带浏览器或 Edge 浏览器


一、问题出现的原因分析

由于部分国产浏览器对 Web 标准的支持不够完整,或在系统内嵌中屏蔽了某些关键 API(例如微信屏蔽文件下载、百度 APP 限制外链等),网站在这些浏览器中运行时容易出现:

  • 资源加载失败
  • DOM 或事件机制被限制
  • JS 执行顺序异常
  • WebView 内核差异导致样式渲染不一致

即使对前端代码进行兼容性优化,也难以完全规避这些内核级别的限制。

因此,我们决定采用 前端 User-Agent 判断 + 跳转提示页或弹窗提示 的方式,让用户主动切换到更稳定的浏览器环境。


二、解决方案:使用 JS 判断 UA 并提示用户更换浏览器

相比通过 nginx 层面判断,前端 JS 方案具有更灵活、更易部署的优势:

  • 无需修改服务器配置,前端即可快速发布
  • 可自由定制弹窗样式与行为
  • 可根据业务需求选择跳转或仅弹窗提醒

核心思路是通过 navigator.userAgent 检测访问者的浏览器类型,并对不兼容浏览器执行跳转或弹窗逻辑。


三、JS 代码实现(跳转或弹窗两种方式)

1. 判断 UA 的核心代码

(function() {
  var ua = navigator.userAgent || '';

  // 不兼容浏览器关键词
  var isBadBrowser = /Quark|UCBrowser|UCWEB|baiduboxapp|baidu|MicroMessenger/i.test(ua);

  // 是否为移动端(可选)
  var isMobile = /Android|iPhone|iPad|iPod|Windows Phone/i.test(ua);

  if (isMobile && isBadBrowser) {
    // 跳转到提示页面
    window.location.href = 'https://gptmirror.pftedu.com/browser_notice.html';
  }
})();

该脚本可放在网站的公共 JS 中,也可以直接写入需要保护的页面内。


四、提示页面示例(browser_notice.html)

用户访问后会自动展示弹窗提示,内容可按需求调整:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>浏览器不兼容提示</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script>
  window.onload = function() {
    alert('当前浏览器不兼容,请使用手机自带浏览器或 Edge 浏览器访问网站。');
  };
  </script>
</head>
<body style="text-align:center;padding:40px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto">
  <h2>浏览器不兼容</h2>
  <p style="margin-top:20px;line-height:1.6;">
    检测到您正在使用:夸克 / UC / 百度APP / 微信内置浏览器。<br>
    为了保证良好的访问体验,请使用:
  </p>
  <p style="margin-top:10px;font-weight:bold;">
    手机自带浏览器 或 Microsoft Edge 浏览器
  </p>
</body>
</html>

五、方案效果与优点

实测效果表明:

  • 在夸克、UC、百度 APP、微信内置浏览器中均成功跳转提示页
  • 弹窗提醒清晰明确,用户理解成本低
  • 使用标准浏览器访问则完全不影响正常使用

最终实现了:

✔ 避免浏览器兼容性差导致页面异常 ✔ 提高整体访问稳定性与用户体验 ✔ 易于维护和扩展,可随时增加或修改 UA 规则


六、总结

由于某些浏览器(尤其是 APP 内置 WebView)对 Web 标准的支持不足,我们的网站在这些环境下出现了功能和显示问题。通过前端 JS 实现 指定 UA 自动跳转并弹窗提示,成功解决了用户反馈的兼容性错误。

这是一种简单、高效、可快速上线的浏览器兼容性解决方案。

俄副总理:准备迎接无限量印度工人

2025年12月7日 16:00
据“今日俄罗斯”(RT)当地时间12月6日报道,普京访印期间,俄印两国签署了劳动力流动协议。 当地时间12月5日,俄罗斯第一副总理丹尼斯·曼图罗夫(Denis Manturov)表示,俄罗斯已准备好接收“无限数量的”(an unlimited number)来自印度的技术工人,以帮助解决国内劳动力短缺问题。 他告诉塔斯社,俄罗斯制造业至少需要80万名额外工人,而贸易部门则面临约150万个职位空缺。他还补充说,服务业和建筑业也需要专业人才。“我认为我们有充足的合作空间。”他说道。 不过,曼图罗夫指出,进入俄罗斯的印度移民工人数量,“不会在一年内出现大幅增长”,因为这个过程“需要时间”。

CME数据中心运营商承认操作违规 致上周交易中断

2025年12月7日 15:45
上周五,全球第二大衍生品交易所——芝商所(CME)旗下多个市场因数据中心故障中断交易超过10小时。数据中心运营商CyrusOne本周六证实,此次重大中断源于人为操作失误。CyrusOne发言人表示,位于伊利诺伊州奥罗拉的数据中心现场工作人员及承包商未按标准在冷冻天气前对冷却塔进行排水,导致冷却系统结冰超压运行,设备温度失控。尽管CyrusOne称已采取全面且果断措施恢复冷却系统,但CME在声明中指出,数据中心最初的补救措施反而加剧了问题,最终导致多台冷却器的故障。此次事件凸显了CME对单一数据中心的高度依赖风险。该设施原为CME所有,于2016年出售给CyrusOne,并签订为期15年的回租协议。CME这周六表示:我们充分认识到此次事件对全球客户造成的严重影响。

字节即梦张楠:AI 时代,如何探索人的想象力?

2025年12月7日 15:33

整理|连冉

编辑| 郑玄

AI 正在变得越来越强,未来,人和 AI 会是什么样的关系?

在极客公园 IF 2026 创新大会上,字节跳动即梦Dreamina 张楠与极客公园创始人、总裁张鹏讨论了人与 AI 、AI 与创作、Sora 与抖音、AI 时代的组织形态等内容。

在对谈正式开始之前,屏幕上播放了一个 18 分钟的 AI 短片——即梦 AI 青年导演合作计划之一《老妈的心愿》。这是导演小文关于自己妈妈的故事。过去,她是一个编剧,借助即梦 AI,她和搭档晓丹做起了导演,将脑海中的故事变成了有完整逻辑且不掉帧的影像——在即梦 AI 这样的 AI 工具出现之前,这很难想象。

《老妈的心愿》截图|图片来源:极客公园

 

张楠通过对即梦实践的复盘,重新定义了人与 AI 的关系: AI 不仅是工具 , 而且 是人类能力的「放大器」,如同乔布斯所说的「大脑的自行车」

她观察到,创作者正在从试图精准「控制」AI,转向与 AI「深度共创」,AI 开始扮演一种能提供意外灵感、共同探索与实验的伙伴角色。

这种技术变革,让编剧这类有故事内核,但缺乏传统影视资源的叙事者, 也有了可以创作和表达的机会。

针对行业热议的 Sora,曾负责抖音多年的张楠,认为二者有明确不同:抖音的本质是记录真实生活,而 Sora 等 AI 产品更像是在进行「想象力的社交」。

张楠在极客公园创新大会 2026 上对话张鹏|图片来源:极客公园

她也分享了即梦Dreamina 的目标:并非一味地通过自动化来「取悦」用户或追求极致的效率,而是要在人机协作中建立一种类似「伙伴」的关系——AI 应当具备激发创作者的能力,通过不断的反馈与激荡,帮助人类突破自身创造力的上限。

在组织形态与个人思考层面,张楠坦言自己坚持「始终创业」的价值观,在即梦内部通过「探索产品 demo 制」和小团队作战来拥抱技术的不确定性。

「创造」是比「创业」更底层的驱动力,大厂的基础设施反而能让她心无旁贷地回归产品本身。

面对未来,张楠展现了极强的好奇心,预判内容形态将从线性的时间轴叙事演进为「空间叙事」,AI 或将在线上重构类似《Sleep No More》那样的沉浸式互动体验,让观众在故事中自由行走。

以下为张楠与张鹏在极客公园 IF 2026 创新大会上的对话实录,由极客公园整理

张鹏:欢迎张楠又来到我们极客公园创新大会,上次我们俩聊是 2021 年 1 月份,当时还在疫情期间,我们跟抖音联合合作了极客公园创新大会。你当时看这个片子是什么感受?我当时看这个片子是很感动的。

张楠: 大家好,首先感谢极客公园的邀请。我不知道现场大家这么有耐心,看完这个 18 分钟的片子,有什么感受?我当时看完了内心特别感动。这是小文的真实故事,包括她用即梦 AI,做姥姥姥爷的照片,都是真实的故事。这个真实的故事非常打动我,我在看的时候非常投入,甚至感动流泪。当时张鹏坐在我边上,我偷偷用眼睛瞄他,他也在偷偷擦眼角,确实很打动人,让我完全忘掉了它到底是用什么样的技术创作的影片。

张鹏:特别难得的不是这个画面多么顺畅,人物一致性怎么样,而是这个故事本身不掉帧,让我很投入把这个故事看下去并且沉浸其中,尤其是最后它跟真实的人出现交集的时候,突然让人觉得特别的震撼和感动。你刚才说虽然我们已经忘了这是拿什么技术去做的,但我还是确认一下,是不是都是拿 AI 来做的,它到底是怎样的过程?

张楠: 首先我替小文证明一下,因为我完整参与了青年导演合作计划,它是一个为期三个月的项目,邀请了七个导演,风格特别不一样,他们真实地跟我们一起工作了三个月,每周都开会。

张鹏:不只是来投个稿。

张楠: 不是,我们几乎每周都会开会,甚至一起线下开会,每个双周,我们会讨论哪些事情在技术上应该做改进,他们想做的这些故事,想要完成什么样的效果,我们应该怎么配合,这是一个深度共创的过程。

小文的这部片子,从头到尾,从生图到生视频,全部都是由 AI 来创作的。整体有差不多 400 多个分镜,生成上万张图,做了几千条视频。我觉得小文太有毅力了,这是非常了不起的一个实验。

张鹏:为什么今年即梦会做青年导演 合作 计划?目标是什么?作为一个成熟的公司,任何一件事肯定有自己非常明确的目标。

张楠: 其实这个计划从一年多前开始构思即梦这个项目时,它就发生了。主要源于我对创作者的创作力、想象力是如何发生的好奇心。我特别想近距离的去观察,这个世界上这么有创意的人,他们每天在想什么,思考什么,那些灵感怎么突然出现在他们脑子里。

青年导演合作计划,是我们与创作者合作的第三期项目。第一期是跟普通的 AI 创作者,他们是最先尝试 AI 技术的。第二期找了这些 AI 创作者里,特别会讲故事的。第三期,我们邀请了一批真正意义上的导演,他们很多都曾经斩获釜山国际电影节、FIRST 电影节短片大奖,出身传统影视体系,也是对 AI 技术很感兴趣的一群人。

 

01

从想驾驭 AI,到跟 AI 共创

 

张鹏:这个时候他们把 AI 当成创作自己的作品唯一的工具的时候,会遇到什么问题?

张楠: 创作过程其实非常周折,我们也确实遇到了很多挑战。大家都很想用技术去完美还原脑海中的画面、讲述心中的故事,但技术在当下的确存在局限。

让我印象深刻的是,参与者中既有 AI 老手,也有完全的新手,能明显感受到两类人的预期截然不同。

用过 AI 的人相对保守 ,因为了解现阶段技术的局限和底层逻辑,他们更懂得妥协; 而没用过的人则倾向于完美主义 ,起初极度想要掌控 AI,试图精准还原每一个表达瞬间。

但有趣的是,随着过程推进,他们逐渐发现可以跟 AI「做游戏」、「做实验」,心态发生了奇妙的转变。

这其中,胤超导演作为一位审美极佳的导演,在制作写实风格短片时,突然捕捉到了一个瞬间,让他领悟到了 AI 的乐趣。

他曾经分享过一位摄影指导朋友的故事:这位朋友热衷实拍,有天晨跑时路过一座小桥,光影绝佳。出于职业本能,他举起手机准备记录。就在按下快门的刹那,一只白鹭伴着晨光意外闯入画面。那一刻,他觉得大自然的馈赠让他捕捉到了不可复制的绝美镜头。

胤超感慨,用 AI 创作也有同样的感受: 从最初试图「驾驭」,演变为后来的「共创」

AI 也会赋予你那种神奇的时刻——它生成的结果可能完全超乎想象,却又与你的内核莫名契合,让你惊叹「哇,还可以这样」。这种由技术带来的动人瞬间与心态转变,给我留下了很深刻的印象。

 

02

AI 是人能力的放大器

 

张鹏:听你说这点也是我当时很感兴趣的,比如像小文,据我了解她应该没有自己原来导演过任何片子,她本身是做编剧的。经历完这个过程,你也很关心 AI 跟人的关系,看完之后你会有一个答案吗?AI 未来变得越来越强,人和 AI 会是什么样的关系?不要控制它,要把它当伙伴?

张楠: 对,就拿即梦来说,首先它肯定是一个工具产品,我们一开始定位它就是工具。

因为一切都要从基础做起:先做一个能切实服务创作者的工具,帮他们把脑海中的故事更好地实现出来,这是根本。

跟传统工具截然不同的是,AI 工具自带「智能」。面对一个有智能的工具,人类应该如何跟它协作?

关于人与 AI 的关系,我想引用乔布斯那个著名的比喻——「电脑是大脑的自行车」。这源于一份关于生物移动效率的测试:单纯靠双腿,人类的能耗效率显著落后于秃鹰;但如果给人类一辆自行车,人类就能轻松胜出。

在今天,AI 更像人类能力的放大器。它不应该单纯是一个工具,而是应该能够把创作者带往此前未有涉足的领域,帮他们实现连自己都未曾想象的创作。我希望在未来,人与 AI 的关系是共同协作:AI 激发人,人再借助 AI 走得更远。

张鹏:是不是未来也会出现这样的情况?一些好的影视的作品,它会变得更加的平权。就像你说的,它是对我的放大,我自己的一个审美或者是某种世界观,我今天有了 AI 能力,我可以摆脱原来现实世界行业传统的重力,能够去提前让它去闪耀一些东西。

张楠: 特别对,你提到平权这个词,也是当时创作者们提到最高频的词。

小文原来是编剧,在传统影视行业里,编剧往往不像导演那样拥有对现场的指挥权和掌控力。

蓝天导演曾跟我们感慨,AI 确实赋予了普通人表达的权力——像小文这样,只要脑海里有故事、有细腻的情感,对生活有观察和积累,即便没有资源,也完全可以用 AI 进行创作。

传统影视制作的门槛其实非常高,哪怕只是制作一支像样的短片,其复杂程度也远超简单的 UGC 短视频拍摄。

蓝天导演甚至用了一个很有趣的词叫「街头智慧」:在传统的片场,要调度导演、制片、摄影、置景以及一大堆演员,没有点「街头智慧」根本玩不转。

可见,过去想要通过影像讲述一个完整的故事,是多么复杂的一件事。

 

03

Sora 不是 AI 版的抖音

 

张鹏:我想把这个话题再放到跟技术相关的节点。其实 Sora 今年引发一波全球互联网大家对它的关注。Sora 刚出来的时候,很多人都说这是一个 AI 版的抖音,抖音是当时你带着把它做起来,你最了解这个过程。你怎么看大家这个说法?你怎么看 Sora?

张楠: 这个问题问得很好。首先说回抖音,这是我最熟悉的产品,我认为它的本质还是「人」。

抖音源于真实生活——无论是有趣还是平凡,生活中那些不期而遇的意外和瞬间,都是天然的创作素材。只要人活着,生活每天 24 小时都在继续,你只需将其记录下来就好。

所以我不认为 Sora 是「AI 版的抖音」。

Sora 其实是在用「想象力」进行创作和社交,就像是用想象力跟朋友们一起玩。但挑战在于,想象力很容易枯竭,人很容易想着想着就没灵感了,这不像真实生活那样源源不断。所以,这类产品还需要通过大量的产品设计,来解决如何让用户保持长期活跃的问题。

张鹏:所以非常确定的,大家如果说它是 AI 版的抖音,这个事你是不认同的?

张楠: 抖音是记录美好生活,Sora 没有在记录。

张鹏:你会觉得 Sora 有什么东西让你觉得这个东西做的挺好,甚至这个事是不是未来也应该做或者我觉得这件事为什么不是我们做,会感受到有这种压力或者是在里边被启发到这样的一些真实的反馈吗?它出来之后对即梦的团队有什么影响,从你的视角怎么看这件事?

张楠: 首先,我认为 Sora 的出现是一件好事,它让大家看到了更多的可能性。给我感触最深的是,他们能够将模型的迭代与真实的应用场景紧密结合,以此来推动协作与工作,这一点是非常好的。

张鹏:你是说它的模型本身在产品出来的形态上预先就做了对齐。

张楠: 是的,因为也是一个团队,同时大家在迭代这个技术的过程中,也在充分思考这个技术在真实的产品应用场景里面,它应该是什么样的效果。大家应该怎么去玩、互动,这个地方他们考虑的挺充分的。

 

04

拥抱不确定性

 

张鹏:刚才咱们聊上一 Part 的时候,你很关注人跟 AI 的关系。是说你们比如说即梦或者说 Sora 这样的东西,大家的出发点不一样吗?虽然那个技术很炸裂,可能并没有让你有太多的波澜?

张楠: 首先技术确实很好,但我相信这个技术不难,大家假以时日肯定都能做到,技术不是这件事情里面最重要的。

张鹏:必要不充分。

张楠: 也可以这样讲,我更关心或者我们更想做的事是怎么利用技术去更好地思考这个技术以后怎么去服务人,服务什么样的人。在服务过程中你怎么去找到一个正确的目标函数,这是我们特别关心的。

张鹏:你提到了目标函数,目标函数的核心其实就是你最终的目标嘛。你也经历在抖音,现在在即梦,这个什么目标以及目标函数有什么变化?

张楠: 我个人人生非常重要的目标或者能带来意义感的东西,肯定是有更多的体验。作为一个好奇心很强的人,我很关注我人生当中的体验是不是足够丰富。

即梦的探索方向是试图结合 AI 技术,去解锁全新的场景与产品形态。

在立项之初,我们就确立了目标——希望借助 AI 技术帮助人类提升创造力,甚至去激发人类创造力的上限。

在这个过程中,我也感受到了很强的不确定性。因为技术一直在高速发展、尚未收敛,产品的形态也在不断演变,我们需要时刻思考产品、技术与人之间的关系。比如在人机协作中,究竟何时该由人主导,何时该由 AI 辅助?这些一直在变化。

张鹏:你比较喜欢不确定性是吗?

张楠: 喜欢体验的人都不喜欢一成不变,我非常拥抱不确定性。

张鹏:今天看到即梦让你兴奋恰恰是这不确定性,在这个不确定性里面,有没有看到一些确定的东西。就像你说对于人的创造力的解放,这两年你看到了一些方向,未必可能是百分之百确定,但有一些方向已经很明确了,它可能是通向哪儿?

张楠: 首先技术不停的发展,技术越来越变好,肯定是很确定的,成本的降低肯定是很确定的。人和 AI 的关系,创作者越来越利用 AI 来完成创作,肯定是非常确定的。这些确定,使我和我团队比较坚信这件事情是非常正确的方向。我自己在这个过程当中,也确实看到一些在产品设计上、技术上能明确带来对创作者的帮助,每个阶段的小踏步,这些确定性都很真实。

张鹏:我们换一个视角,今天大家用 AI 有多种用法,可能取决于目标。如果目标是把 AI 当成一个生产力,我就可以躺平,它就是帮我干活,我创作一堆哪怕有垃圾内容,量足够大,成本足够低,也许能满足我某些任务的目标。另一种我要去表现自己,我要去探索某种像你说的新的形态边界,它更像一个工业化的过程还是通向文艺复兴?从你的角度你会看到对人的解放还是它是对工业化未来的生产力?

张楠: 这是一个很大的话题,核心取决于具体的应用场景。对于某些场景,确实可以实现端到端的自动化,人只需要在最后环节进行检查或纠偏即可。但在「创作」或「表达」这类场景中,我无比坚信人类必须深度参与。因为表达的本质,是你、是我、是每一位创作者发自内心的诉求——只有我们自己最清楚想要表达什么。

在这个过程中,AI 更像是一个「全能团队」,它协助你完成表达,填补你过去因资源匮乏、支持不足或能力受限而留下的空白。但归根结底,表达的源动力一定来自于人本身。

张鹏:人想表达,你要问的大家可能都有要表达,但是真的做成一个东西向世界表达不容易的。很多时候这个摩擦力也很大的。今天很多人都不发朋友圈了,那么简单的表达大家都不表达,我们怎么去把这件事去解决?

张楠: 表达是人类与生俱来的本能。我非常理解你所说的「摩擦力」,这往往源于过往资源的受限,或是创作者对自己表现不够好的担忧。但相比于消除摩擦力,我更关注的是创作的动力:用户在这个过程中能否感受到自己的成长?表达内容的品质是否在不断提升?这种「获得感」才最关键。

这就涉及到产品设计的核心目标函数,有些 AI 产品专注于生产效率,旨在通过自动化替代繁琐的美工工作。但在即梦,我们在设计时,不仅仅关注任务完成得顺不顺、快不快。我们不需要一味地迎合或谄媚,而是要在过程中不断激发创作者。

这里需要一个微妙的平衡:AI 既要作为帮手,处理掉那些你原本不愿做的琐碎工作,同时又要作为伙伴去激发你、质疑你——「这样做是不是还不够好?」迫使你进行更深度的思考,花更多时间去打磨作品,这其实是很难做到的。

张鹏:这两个概念我理解,在产品里能举一些例子吗?比如从即梦的视角,什么样的设置最终通向刚才说的这两点?你要让用户有参与,甚至不要谄媚用户,怎么理解这句话?

张楠: 举个例子,以前的生图或生视频往往是单向的:你给一个 Prompt,它吐出一个结果。但现在的工作流正转变为以 Agent(智能体)为核心的协作模式。在这个过程中,你需要与 AI 进行大量的沟通、讨论甚至争辩。即便 Agent 能自动调用工具,我们依然要在流程中,给创作者预留出足够的参与空间、决策权和引导能力,让它变成真正的「深度共创」。

张鹏:AI 反过来,它会觉得用户给它的 promt 不够好吗?比如提升上限,我觉得有时候身边对我提出不同意见的人对我帮助更大,如果一直夸我,我上限就我自己决定了,我怎么突破呢?AI 需要有这样的能力吗?

张楠: 肯定需要。

过去我们追求效率,总想着把一小时的活压缩到半小时。但如果我们想提升创造力和内容质量,时长缩短并不是唯一的关键指标。

相反, 创作时间变长未必是坏事 。关键在于这多出来的时间里发生了什么——你们进行了多少轮对话?对话质量如何?AI 给出的建议你是否采纳?这中间有没有发生过激烈的思维碰撞?这些高质量的互动过程不仅对作品至关重要,对我们后续的模型训练也极具价值。

张鹏:听起来这条线有很多探索空间。如果真的要有一个伙伴,纯粹取悦型的人格看起来不太 OK,我更需要能跟我在平级做探讨,能够共振的东西。这真的不是纯技术的视角。

张楠: 对,取决于什么样的场景解决什么样的问题。

 

05

AI 时代的组织形态

 

张鹏:除了技术产品,在这个时代里都说要有一些新的组织形态,我们现在看到很多年轻的创业团队就几个人,他们也在做看起来很大的事情,明显比当年移动互联网的时代团队规模缩减很多。我挺好奇,即梦现在是什么样的内部结构?你们用什么方式组织团队?和上个时代有变化吗?

张楠: 首先现在我们的工作方式非常像创业团队,字节也一直都有很重要的价值观,「始终创业」。

外界通常认为字节以强大的「中台」著称,但在一些创新业务线,我们打破了传统的组织界限。以即梦为例,里面有很多探索型项目,团队非常精干,从产品、技术到设计可能一共只有 10 个人。大家坐在一起,工作节奏非常紧凑灵活。

张鹏:团队构成是什么样的?搞模型的,搞产品的,这些都要在一起吗?

张楠: 对。

张鹏:是一个浓缩在一起的综合型的团队。

张楠: 对,其实很像一个小的创业组织,五脏俱全,但非常小而精。通俗的说法是,以前很多沟通要靠飞书,现在的沟通就靠吼,因为都坐在一起。

张鹏:今年听好几个人跟我讲,今天怎么做是对的,大家都能看得见,不是说还有中台。在今年 AI 创新的里面,天然是反中台的,因为需要把原来所谓的能力要整个塞到一个团队变成一体循环,这个你是否也感受到了?

张楠: 这个和业务成熟度相关,我们有很多成熟的业务还是需要流程化、标准化的工作,保证线上众多用户的服务质量。

探索型的项目早期,我们实行的是「探索产品 Demo 制」。这里没有严格的项目评审,几个人今晚有想法,做出来,明早直接看 Demo,是一个快速滚动迭代的过程。

 

06

创造,比创业更底层

 

张鹏:最近在整个 AI 这波浪潮里,很多年轻人,甚至 00 后都在开始拿 AI,看起来拿 AI 创造一个新东西的门槛在降低,很多年轻人有点像导演领域一样,我未必混很多年有很多资源才能干我想做的影片,今天有 AI 就可以做这样的尝试。年轻人今天是被追捧的,即梦团队平均年龄怎么样?里面的人员怎么构成的?

张楠: 我应该是团队里年纪最大的,但我们在组建团队时并不看重年龄,而是看重背后的能力模型以及成员间的协作搭配。

对于探索性业务,我更看重成员是否有好奇心、是否坚持学习,以及是否有韧性。因为探索必然伴随着试错,韧性是支撑我们走下去的关键。

张鹏:即便即梦在字节的体系下是大厂,可能也需要面对一个问题去回答,为什么今天优秀的年轻人还要去大厂?

张楠: 这个问题虽然有点尖锐,但很好。

我自己创过业,所以对「创业」这件事已经祛魅。创业的成功率很低,且过程异常辛苦,很多时候你无法纯粹地做产品,必须分心去处理各种琐碎杂事。

而在字节跳动,有现成的基础设施、技术储备和人才流动机制,这让我能心无旁贷地专注于我擅长的产品本身。

从这个视角看,「创业」只是手段,且未必是成功率最高的手段;而「创造」才是比创业更底层的驱动力。

我关注人生体验,更看重能否把想做的事情做成。

张鹏:这个视角可以理解为创造是比较创业更底层的那件事。

张楠: 你这么说,我觉得是。

张鹏:创造比创业更底层一点。

张楠: 创业是手段,但是不是成功率最高的手段。

张鹏:到最后一个问题了,你的经历很丰富,自己创业,带动抖音 发展到 今天我们每天花很多时间的产品,你现在又 在 尝试即梦。在未来的五年,你更希望自己去实现的是什么?你的好奇心希望被满足的是什么?在今天它很清晰了吗?能被定义了吗?

张楠: 现阶段我们要先做好工具,但我对未来的内容形态有着强烈的好奇心。

我为什么那么喜欢创作者,就像极客公园很喜欢创业者一样,我喜欢跟他们共创做一些新东西的过程,我对于未来的内容形态其实有一些想象,有非常强烈的好奇心想要做一些这样的实验和探索。

其实早在 1960 年代就有过互动电影《Kinoautomat》的实验,还有像 Punchdrunk 的《Sleep No More》这种线下沉浸式戏剧——观众戴着面具在整栋楼里自由探索,跟随角色进入故事。

受这些的启发,我认为未来的讲故事方式可能不再是基于线性的「时间轴」,而是基于「空间图」的空间叙事。

观众不再是单纯的旁观者,而是可以在故事中「行走」。我非常期待 AI 能在未来帮助我们在云端实现这种全新的体验。

张鹏:听起来它不只是对于今天的创作者上限的提升,有可能对于内容的形态带来全新的改变,甚至观众在里面更多的参与,因为可能也需要 AI 把工具做完,在第一步之后内容形态的变化自然会有更多。

张楠: 甚至可能跟工具本身的打造也很有关系。

张鹏:听起来这是一个在未来挺让人兴奋的事情。特别感谢你来到公园给我们做这些分享,我也期待这五年你也在你的好奇心上不断有收获,再回到这来分享你的认知。

影石刘靖康:全景无人机,是「马车时代的汽车」

2025年12月7日 15:20

整理|Moonshot

编辑| 靖宇

 

影石的 2025 年完成了一个堪称疯狂的双连击:6 月上市敲钟后,12 月发布全球首款全景无人机影翎 Antigravity A1。前者为十年创业史按下句点,后者则像打开全新关卡的起点。

一家从十几人起步的小团队,在成长为 4000 人规模、市值跨过千亿门槛的超级独角兽后,却选择在无人机赛道重启探索。这件事本身就足够戏剧性,以至于足以让所有旁观者好奇「Why?为什么要做无人机?」

对于这个疑问,影石创始人刘靖康在极客公园创新大会 2026 的舞台上,给出了自己的答案:

问题从来不是「要不要做无人机」,而是—— 影像的终极形态会是什么

对影石来说,无人机并不是进入一个巨头盘踞的红海,而是延续使命、洞察行业本质、寻找非共识机会的结果。

从「马车时代的汽车」这个经典比喻,到刘靖康用 40 分钟洗澡萌生的全景无人机该如何交互的创意,都指向一个核心判断: 影像的终极需求不是设备,而是内容本身

用户并不迷恋设备本身,他们追求的是一种无需学习、不被打断、能让人生被自然记录的体验。

所以当行业仍在比拼参数、拉扯价格、试图在存量里内卷时,影石选择了一个更难但更具确定性的方向:去搭建下一代影像设备的操作系统,打造一种更「直觉化」、更具沉浸感的新拍摄方式。

这并非「挑战对手」式的冲动,而是影石对自身进化的主动选择。

刘靖康、张鹏在现场展开对谈|图片来源:极客公园

 

这种选择,也反映出影石对行业与商业逻辑的另一重认知:在影像这个高度开放、需求分层又极度依赖体验的市场里,创新从来不是靠性价比,而且无人机的赛道并没有大家想象中的难,反而还有很大的市场空间。

无人机对影石来说,也是一次能力体系的全面升维:研发、工程、供应链、营销、服务,整个公司被迫进入一个全新的复杂度。

也正是在这样的背景下,影石做无人机这件颇具「挑战对手」戏剧色彩的决定,显得既大胆又合理。

因此,当我们重新审视影石 2025 的状态,不难发现:上市从来不是影石的终点,而更像一个必须重新起跑的起点。无人机、AI 全流程影像生成、跨品类能力升级,才是这家公司要面对的真正挑战。

在极客公园创新大会 2026 上,极客公园创始人张鹏与影石创始人刘靖康进行了一场长谈。在这场对谈中,不仅是关于一款新品如何诞生的故事,而是一家影像公司如何试图理解「下一代影像」本身。

嘉宾精彩观点:

无人机的技术门槛比造车、造手机要低,但市场规模并不小。

我们推出的全景无人机,就像是在马车市场提供了「汽车」。

无人机是对短板容忍度特别差的品类,长板再好也不能覆盖安全性层面的短板。

人们对影像的需求终极并不是工具本身,而是照片和视频。

打价格战永远是巨头的特权,不是创业公司或新入场者的权力。

很多公司最后都会消亡,但它在历史上存在的意义,就是它发明、改变、创造了新的市场。

影像背后的三个需求是:记录、分享、创作。

将每一个小场景服务好、抓住,最终加总在一起的市场,可能比大家普遍认为的要大。

等到你犯了大错也不会下牌桌的时候,你再去做最勇敢的尝试。

 

下面是刘靖康和张鹏在极客公园创新大会 2026 上的对话实录,由极客公园整理:

张鹏: 欢迎刘靖康再次来到极客公园创新大会!今年公司上市了,你瘦了,是上市公司老板开始做形象管理了吗?

刘靖康: 感谢邀请。倒不是因为上市,其实上市后那段时间我反而胖了十几斤。现在的体重是过去一两个月通过自我意志管理瘦下来的。因为公司业务将变得越来越复杂,作为典型的「P 人」,我需要更有计划性,对自我有更多约束。所以,先从体重管理开始,这也是一种心理建设。

 

01

上市时的「冷静时间」

 

张鹏: 上市那一瞬间,你真实的心理活动是什么?

刘靖康: 那一瞬间的感觉,就像读书时刚考完期末考准备放假,但人还没离校;或者刚开完年会放假了,但还没回到家。这是一种非常短暂的歇息:完成了一件大事,正准备迎接下一个困难,也还没面对家里亲戚的唠叨。

那是一种介于两件事之间的 Gap,心情非常放松、放空,完全没有想工作,注意力短暂地回归到了自己本身。

刘靖康在极客公园创新大会 2026 现场|图片来源:极客公园

 

张鹏: 很多人认为上市意味着财富自由和松弛感,你们团队在「喘完这口气」后,有松弛吗?

刘靖康: 上市后的两个季度,我们核心管理层的加班反而明显变多了。原因很现实:

第一, 影像行业是个很苦的生意,容错率极低,做不到一定程度没法「躺平」。

第二,我们有远大的目标。就像小鹏总做具身智能,我们公司在相机赛道终极想做的是摄影机器人,它能全自动地帮人拍出好照片、剪出好视频。我们现在做到的程度相比我们的目标还很远,还有很多事情没做。

 

02

马车时代的「造车」逻辑

 

张鹏: 最近大家很关注你们做的无人机,不是 What 和 How,而是 Why?为什么有个伟大的对手在那里,还要做?

刘靖康: 我们做无人机主要基于三个原因:

首先,这是公司使命的延续。我们的使命是「帮助人们更好地记录和分享生活」。这个是我们 2016 年公司定下的使命,也在过去十年指导着公司的业务选择,也会在未来 10 年继续指导公司业务的选择。

今天我们看到在售的运动相机,无论是我们在售还是友商的运动相机,或者说手机厂做手机,相机它仅仅只是一个工具,但是消费者买一个工具的目的不是更好的操作这个工具,而是他想要一张好的照片和好的视频,希望人生的珍贵时刻被很好的保存下来,可以分享给他的朋友、家人。

因此,我们发现大部分人不懂运镜、不懂剪辑、不懂修图。但是好的影像体验,是你不用担心拍摄本身,尽情享受当下,有摄影师会帮你拍好,修好,剪好。

所以人们对影像的需求终极并不是工具本身,而是照片和视频。 最大的障碍就是:用户对摄影摄像的学习门槛,和人们在该享受当下的时没有享受当下,而是专注在拍摄上。

我们从十年前一直在思考这个问题,五年前我们确定,大概率还是要做机器人形态的产品。这里不是指人形机器人,因为它很大很重,而是要做形态更紧凑、具有移动能力的无人机。

12 月 4 日,影石孵化的影翎 Antigravity A1 全景无人机上市|图源:Insta360

 

第二,是关于市场竞争的思考。虽然无人机赛道友商遥遥领先,但我说个很现实的问题。今天很多新能源车企营收大概几百亿,好一点的做到千亿。但友商的无人机或者 Pocket 系列,单品类都要 200 亿元起步。

然而这两个品类的开发难度比汽车简单得多,甚至比手机还要简单。所以这是属于「非共识」的领域: 看上去一家独大,但并不是非常难的事情。大家都在共识范围之外卷汽车、卷手机,反而忽视了这个品类。

更重要的是,我们做产品研究一直有个理论叫「看需求背后的需求」。

例如进入马车市场,问客户想要什么样的马车,客户会说「希望马不吃饭不睡觉一直跑」、「轮子避震」、「椅子变沙发」、「车厢有空调」。这就像过去大家对无人机的期望:参数更高、飞得更远、避障更好。

假设有一天你做到马车世界第一,问客户为什么需要马车?客户会说「希望更快地从 A 点到 B 点」。想满足这个需求,方案不一定是马车,可以是汽车或新能源车。

虽然友商在无人机领域遥遥领先,但这其实是一个「非共识」的机会。 无人机的技术门槛比造车、造手机要低,但市场规模并不小。

在无人机领域,「A 点到 B 点」的需求是航拍和换个角度看世界。传统无人机的痛点是操作门槛高、容易炸机。

我们推出的全景无人机,就像是在马车市场提供了「汽车」。通过全景相机和 VR 眼镜的体感操控,用户手指哪里就飞哪里,眼睛看哪里就拍哪里,不需要学习复杂的运镜,这种交互是自然的。

同时因为我们是通过 VR 眼镜,把你看到的画面整个包裹你的眼球,你看哪里都是全景,就像鸟一样在天上飞,感受整个空间、整个世界。

所以它不仅是一种新的航拍方式,甚至会变成一种新的旅游方式。我们在五年前探索出全景无人机这个品类,认为它就是「马车市场背后的汽车」。

第三,是对公司能力的要求。无论研发、工程、营销,还是服务、供应能力,无人机相比五年前只做运动相机的影石,有着全面提升的要求。

今天大家会听到一个说法:友商做扫地机器人、电动车、运动相机叫「降维打击」,这算是一个事实。反过来讲,我们做无人机是「能力升维」的事情。无人机显然是对能力宽度和深度要求高得多的品类。

我们观察那些穿越好几个周期、历时长达数十年的科技公司,它们 基业长青最核心的点,是有非常清晰的主航道,并在其上延伸和加深能力,使自己成为每个周期里准备最充分的选手。

我们从无人机赛道看到了对公司能力牵引的可能性。今天终于做出了无人机,并成功推上市。

我也非常自信地说,今天影石的能力相比五年前做无人机时是全面成长的,完全不可同日而语。当然和友商还有很多差距,但比五年前的影石强得多。

做手机跟做汽车,相比做无人机又是一个更加升维的能力,但无人机的赛道在大家的非共识里面,这个赛道没有大家想象中的难。

张鹏: 背后有那么复杂 Why 的产品,它到底有什么样的特点?

刘靖康: 打个比方,友商的 Mavic 系列就像民航客机,拍摄出来的画面四平八稳,本身也很好看,但只是一个类型的画面。当时有一个品类叫穿越机,就像战斗机,飞行轨迹非常多样,角度多变,观赏性比民航机好很多。但穿越机的问题就像开战斗机一样,门槛太高,很难上手。

当时我们把这个问题解构了一下:无人机拍摄的画面由什么构成?第一是飞行轨迹,第二是拍摄角度。分别由三个自由度组合,共六个自由度决定无人机拍摄的画面和感受。

传统无人机,无论是穿越机还是 Mavic 系列,都需要通过两个摇杆操作。你需要把六个维度的东西转化成摇杆上的四个维度,经过人脑的转换,这就是穿越机学习门槛高的原因。

有一天我洗澡时想了 40 分钟,想出一个 idea。做全景相机时,我们有 VR 头显,戴上就可以看到 360 度的画面。你想拍哪里就看哪里,眼睛就是最自然的取景器。

所以无人机飞行原理首先是:画面实时传输到头显,你的头看哪儿,就传输哪部分的画面。

影翎 Antigravity A1 的使用方式不同于其他无人机|图源:Insta360

 

第二,你想飞哪里?当时乔布斯发布 iPhone 时认为,人的手指就是最自然的鼠标。我们也是,你想飞哪里,就拿手柄指向哪里。手柄就像激光笔,指向哪里,扳动油门,它就往那边飞。

人头和人手是两个非常自然,三个自由度的器官,全景无人机很好地利用了人最符合直觉的两个器官来指导飞机拍哪里、飞哪里。

所以很多博主评价,这不仅是一个飞机,更是一种新的旅行方式,它「飞」的意义大于「拍」的意义。因为你就鸟一样,想飞哪里就指哪里,想看哪里就看哪里。眼睛被画面完全包裹,像完全置身空中一样。这就是我们的设计思路。

 

03

创新、定价与商业竞争

 

张鹏: 中国商业领域有一个更简单的办法:讲性价比,参数高一点,价格低一点。你为什么没有选择这个路线?

刘靖康: 首先,这跟行业有关。影像市场是个开放市场。还有一种市场是纯效率和工具类的,比如自动驾驶,评价维度单一,满足安全、快速的前提下,舒适抵达目的地即可,扫地机也是,扫得越快、干净、安静,产品就越好。

但影像市场特别像游戏市场。你没听过哪个游戏是靠性价比、靠运行速度快而饱受好评的。很多好玩的游戏配置要求高、单价高,但玩家愿意付费。

影像市场的特点正是需求多样化,你在不同场景下对滤镜、特效、拍摄角度的诉求不一样。包括拍完后通过 AI 生成各种脑补画面,这本身对内容和相机的需求都是多样性的,没有一个特别收敛的维度。

所以相机不只是比拼画质、续航、价格。很多人不知道怎么用好相机,不知道什么角度最好,不知道怎么加特效、剪辑。这些都是我们作为产品公司给客户提供的差异化价值。

所谓的开放市场,提供了很多差异化空间,这给不打价格战提供了基础支撑。

第二,打价格战是巨头的特权。很多公司有技术主营业务,在互联网行业都是拿主营业务去补贴。比如外卖大战,美团是主业,但对京东和阿里来说不是主业,他们得拿其他主业补贴。

对于创业公司来讲,打价格战不是特权,我们没有这样的权力。我们投无人机投了五年,是非常大的一笔钱和时间。我们为什么有钱投入创新?因为我们的资金来源就是经营所得。经营所得来自于合理的利润,合理的利润来自于合理的毛利,而合理的毛利来自于合理的差异化,只有客户认可你独特的价值,才能正向循环。

刘靖康在极客公园创新大会 2026 现场|图片来源:极客公园

 

如果今天巨头打价格战,我们口袋没有巨头深,创新就难以为继。 所以打价格战永远是巨头的特权,不是创业公司或新入场者的权力。

第三,我想说一个非常重要的事情。最开始大家想到触屏手机是苹果,其实发明这个品类的并不是苹果。 所以很多行业里,最早的发明者并不是后来大家知道的公司,很多发明者已经消失了。但这些公司在历史上的意义,就是为行业提供基础、养分和启发。

我们公司作为公众公司,会尽全力做好全景相机、拇指相机、全景无人机这些品类。但也有可能有一天我们没做好,最后把全景无人机普及的可能是友商或其他对手。但这个品类的确是我们创造出来的,如果没有我们,巨头不见得会做。

我们也是这么一个心态,我发现 很多公司最后都会消亡,但它在历史上存在的意义,就是它发明和改变了一些东西,创造了新的市场。

解决别人没有解决的问题,而不是怎么去歼灭别人,这是影石的核心价值观。 如果有一天我们灭亡了,只要创造了一些新的东西,这就是唯一的意义。我们在无人机品类上是做好了充分的自我心理建设才进来的,不是脑袋发热。

张鹏: 如果拆解一下技术栈,实现今天这样的能力,有哪些关键技术是一定要驾驭的?

刘靖康在极客公园创新大会 2026 现场|图片来源:极客公园

 

刘靖康: 无人机有几个关键能力。影像和云台对我们不构成真正门槛,难点在于图传和飞控。

图传需要做电磁屏蔽和协议栈,研究抗各种干扰的技术,我们花了很多力气去做。另一个难点是飞控,飞控最大的问题是实验室和测试没问题,但在客户场景下可能失控。十年前 GoPro 信心满满上市无人机,结果用户飞着飞着掉电掉下来,这就是安全和飞控的问题,GoPro 没做测试吗?肯定做了。但无人机存在大量不同的使用场景。

我们为什么做无人机做了五年,大量的工作都是在做测试,三年做技术,最后两年才立项做产品。没有很好的安全做基础,其他东西再好也起不来。 无人机是对短板容忍度特别差的品类,长板再好也不能覆盖安全性层面的短板。

今天我们的安全性做得还可以。比如双机在 30 多公里时速下碰撞,也不会掉下来,有很强的自救能力。

而且飞控是测试面上比较容易通过,然而在各式各样的用户场景里有很多坑,我们历史上所有产品都是发布即发货,只有无人机发布后隔了三四个月才发货,因为这期间我们要在全国做公测,确保问题暴露并修复完再发货。

无人机还有一个隐形门槛是制造。运动相机产线上大概 100 多个组装工序,无人机加起来接近 500 多个。在保证良率、直通率、制造工艺、供应商物料质量管控上,是另一个水平的要求,也是容易被忽略的要求,但是这个相比做车和做手机还是比较简单的。

张鹏: 这个也解释了为什么市场需要五年的时间。

 

04

AI 时代的硬件创业机会

 

张鹏: 今天年轻的创业者,想在硬件领域结合 AI 做创新,你看好吗?

刘靖康: 首先,我非常看好 AI 相关的创业。原因不在于 AI 本身能力有多强,而在于商业模式的变化。

以前互联网是「羊毛出在猪身上」,要发明非常复杂的商业模式,不能直接从客户那边收费,客户没有软件付费习惯,但今天的字节、腾讯、谷歌这些巨头亲自下场教育客户你得对软件付费。

软件的创业门槛各有各的门槛,但硬件创业要苦得多,我们公司有 600 多个工种,起码得养 600 多个不同领域的人才,才能够做出一个硬件产品,但做软件所需要的团队要短得多。

今天大家愿意为软件付费,从这个角度来看,我非常看好今天基于 AI 本身的创业。

其次,关于 AI 是否会取代相机。市场有一种声音担心 AIGC 很厉害,大家就不需要相机了。我认为不会,因为 影像背后的三个需求是:记录、分享、创作。

记录本身是发信号,想把珍贵时刻记下来。它是「先发生,再记录」,而不是凭空创造。

分享的底层动机是「装」。现在通过剪映 AI,画面可以从 80 分变成 120 分。比如一个普通的滑雪视频,可以通过套模版,做得非常酷炫,但大家发现谁都可以套模版后,反而会去比较「真实」的部分,所以点赞高的往往是那些真实的后空翻、刻滑。

Insta 360 App 早已支持 AI 和自动剪辑|图源:App Store

 

创作的心理动机不仅是片子好看,而是这跟个人能力相关,通过练习克服困难去实现。就像依然有人喜欢开手动挡的车,依然有厨师创作菜品。

所以, 当 AI 平权之后,大家拥有了同样的能力,反而会比较「什么是我有你没有的」。 这不会消灭分享需求,反而会进一步刺激大家相互比较、相互创作、锻炼自己真实动作的欲望。

张鹏: 在更多垂直领域,如果能把复杂操作变简单且交付结果,你认为有新的机会吗?要注意什么?

刘靖康: AI 很强,但在消费电子行业有个非常重要的观点: 任何一个再长的长板,都不能构成品类的充分条件,一个品类是否能做好,还有很多必要条件。

比如 Pocket 3,从画质角度看,很多旗舰手机比它好。但它提供了手机提供不了的东西:第一是长时间握持和拍摄的手感;第二是解锁体验:屏幕一掰就可以拍,非常有仪式感和情绪价值。这些是在手机影像足够强的情况下,支撑它卖了几百亿的原因。

我们千万不要低估其他部分的重要性。很多品类存在的形态,是因为它集成了手机或更大品类不会集成的传感器,且能显著提高体验。

比如最近很火的「AI 望远镜」。以前是先用望远镜看,再用相机拍。现在直接把相机集成到望远镜里,还能告诉你这是什么鸟。如果把望远镜的光学模组集成到手机里,手机就会变得很大,没法日常使用。这就是细分品类的机会。

所以大家要看几个特别重要的品类:眼镜和手机。这两个是有可能大家将来最高频携带的品类,如果这两个品类集成的传感器足以解决你的问题,那你的品类就很危险,可能会被降维打击。

今天我们看到 AirPods 销量巨大,手机厂商的耳机出货量也很大,但同时韶音科技依然发展得很好,包括华为、OPPO 等品牌的智能手表出货量也表现突出。

这件事情的关键是, 你要抓住你理解最深的那个场景和那批目标客户,找到他们尚未被解决的需求,并在更大品类的「射程范围」之外找到这个交集。 这就是很多垂直细分品类得以生存的空间。

我们公司今年的收入体量大约在百亿级别,但如果看我们的产品品类,其实相对小众。然而,我们相信聚沙成塔, 将每一个小场景服务好、抓住,最终加总在一起的市场,可能比大家普遍认为的要大。

相反,一些超级共识的赛道,比如汽车、具身智能,大家都认为这是巨大的赛道,但同时可以看到这边的玩家非常多,竞争也极其激烈,创造差异化的空间相对更小,需求和技术路径的透明度也更高。

每个人的创业目的不一定纯粹是为了赚钱,每个人选择解决的问题也不一样。但从商业化的角度来看,我提供以上思考角度供大家参考。

 

05

大胆想象与底线思维

 

张鹏: 你们公司文化特别喜欢「Bold」(大胆)这个词,但怎么把握勇气与疯狂之间的平衡点?

刘靖康: 我提供两个观点:

第一是底线思维。我刚毕业创业时的底线是:反正什么都没有,失败了也什么都没有,但攒了一身本领,去任何公司都能找到不错的工作。

我们做无人机的底线是:假设没干过对手,我们去做吹风机或小家电也能过得不错。这就是底线思维。我很少讲 All in or nothing(孤注一掷),那是很冲动的,你要想好底牌,不要做让自己下牌桌不能翻身的事, 等到你犯了大错也不会下牌桌的时候,你再去做最勇敢的尝试。

第二是工程思维(第一性原理)。 你把要解决的问题严肃地捋出来,谁决定谁,谁依赖谁,这是充分条件还是必要条件还只是自嗨?

一定要认真看你的差距在哪儿,整个业务的关键在哪儿,要大胆想象,但脚踏实地,验证部分一定要一步一个脚印往前走。

张鹏: 我看到有个兄弟自己手搓喷气机,欠了 200 多万,你们「 Think Bold 」基金帮他还了 180 万。这难道不是「做了一件让自己无法翻身的事」,你们为什么支持他?

刘靖康: 我们公司的口号是「Think Bold」(始于敢想)。因为很多发明和艺术创作最开始都源于大胆的想法。

所以我们设立 Think Bold 挑战基金,向全球征集想法。比如两年前跟 Tim 一起发卫星;前段时间也支持了蔡磊总攻克渐冻症的基金。我们就是希望帮助这些想法大胆,但又可以被验证的事情往前走一步。

那个哥们叫阿宇,他做了很多疯狂的事儿,我们在两三年前就开始合作,那时候他在做喷气式相关的项目,影石挑战基金也和他合作过。没想到经过好几年打磨,他做出来的东西确实越来越厉害,甚至做到了「空中客厅」。

阿宇的「空中客厅」在 B 站达到 800 万+ 的播放量|图源:bilibili

 

在这个过程中,他也遇到被骗的情况,背上了一些债务,但他们本身是非常有勇气的人。我们希望他们能少一点负担,能把事情做到更好,所以我们提供了 180 万支持,就是希望他能更轻松地去做下一个他认可的大胆项目。

我想强调一点,像阿宇、Tim 这样的创作者本身就是非常大胆的想法人。他们的行动和作品激励了很多人。我们生活中的很多时刻,其实都在被这些人的作品影响着。

影石本身也是一家充满大胆想法、比较「浪」的公司,希望通过一些发明改变一些事情。同样,很多创作者也能通过自己的作品、自己的世界观去影响别人。我们希望通过这些项目、这些人,让公司产生更大的影响力。

张鹏: 如果从今天看五年后,你希望自己是什么状态?希望影石是一家什么样的公司?

刘靖康: 第一,希望我们能活下来,在巨头的竞争里活下来。

第二,希望真的创造一些世界级的产品,能真正大幅改变人们生活习惯的产品,像当年的索尼 Walkman,或者 Pocket 3 一样。

第三,影石是一个非常长协作链的平台。很多人能在这里学到跨领域的知识。随着跨领域能力越来越强,他们会越来越接近「创业者的能力栈」。未来即便他们离开,我们也愿意继续合作,比如投资他们的项目。

我们不仅希望影石的产品能改变世界,也希望从影石走出去的人,也能创造非常厉害的东西,影响更多行业。

希望五年后,影石多少能做成这几件事情里的一件或者两件吧。

❌
❌