普通视图

发现新文章,点击刷新页面。
今天 — 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 自动跳转并弹窗提示,成功解决了用户反馈的兼容性错误。

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

代码的“病历本”:深入解读C#常见异常

作者 烛阴
2025年12月7日 12:32

异常的常见几种类型

1. NullReferenceException:空指针引用异常

```csharp
// 场景1: 未初始化的对象
List<string> names = null;
Console.WriteLine(names.Count); // 异常: names 是 null

// 场景2: 方法返回了 null
string FindUserById(int id)
{
    // 假设数据库里没找到id=999的用户
    if (id == 999) return null;
    return "Admin";
}
string user = FindUserById(999);
Console.WriteLine(user.ToUpper()); // 异常: user 是 null
```
  • 解决方案:
    1. 防御性检查:在访问任何可能为 null 的对象前,进行显式的 null 检查。
      if (user != null) {
          Console.WriteLine(user.ToUpper());
      }
      
    2. 空条件运算符 ?.??
      // 如果 user 是 null,整个表达式直接返回 null,而不是抛出异常
      string upperUser = user?.ToUpper(); 
      
      // 如果 user 是 null,则使用 "Guest" 作为默认值
      string displayName = user ?? "Guest"; 
      
      // 组合使用
      Console.WriteLine(user?.ToUpper() ?? "USER NOT FOUND");
      
    3. 可空引用类型:通过静态分析,在编译时就警告你潜在的 NullReferenceException
      #nullable enable // 开启可空引用类型检查
      string? user = FindUserById(999); // ? 表示 user 可以为 null
      Console.WriteLine(user.ToUpper()); // 编译器会在这里发出警告!
      

2. IndexOutOfRangeException:索引越界异常

  • 当你试图用一个无效的索引来访问数组、列表(List<T>)或其他基于索引的集合的元素时,此异常就会被抛出。无效索引指的是小于0,或大于等于集合的元素数量。

    int[] scores = { 98, 76, 100 };
    // 场景1: 访问不存在的索引
    Console.WriteLine(scores[3]); //  有效索引是 0, 1, 2
    
    // 场景2: 循环条件错误
    for (int i = 0; i <= scores.Length; i++) { // 错误在于 i <= Length
        Console.WriteLine(scores[i]); // 当 i 等于 scores.Length (3) 时 BOOM!
    }
    
    // 场景3: 对空集合进行索引访问
    var emptyList = new List<string>();
    Console.WriteLine(emptyList[0]); // BOOM!
    
  • 解决方案:

    1. 正确的循环:在 for 循环中,永远使用 < 而不是 <= 来比较索引和集合长度。
      for (int i = 0; i < scores.Length; i++) { /* 安全 */ }
      
    2. 优先使用 foreachforeach 循环在内部处理了迭代逻辑,完全避免了手动操作索引,从而根除了此类错误。
      foreach (var score in scores) { /* 绝对安全 */ }
      
    3. 边界检查:在直接通过索引访问前,检查索引是否在有效范围内。
      int index = GetUserInput();
      if (index >= 0 && index < scores.Length) {
          Console.WriteLine(scores[index]);
      } else {
          Console.WriteLine("索引无效!");
      }
      

3. FormatException:格式异常

  • 当一个方法的参数格式不符合预期时抛出,最常见于字符串向其他数据类型(如数字、日期)的转换。

    string userInput = "twelve";
    int number = int.Parse(userInput); // "twelve" 不是有效的整数格式
    
    string dateString = "2023-30-01"; // 无效的日期 (30月)
    DateTime date = DateTime.Parse(dateString); //
    
  • 解决方案:

    1. 使用 TryParse 模式:这是应对 FormatException最佳实践TryParse 方法会尝试转换,如果成功,返回 true 并通过 out 参数提供结果;如果失败,返回 false不会抛出异常。这遵循了“先看后跳”(LBYL)的原则。
      string userInput = Console.ReadLine();
      if (int.TryParse(userInput, out int number)) {
          Console.WriteLine($"转换成功: {number}");
      } else {
          Console.WriteLine("输入无效,请输入一个数字。");
      }
      

3.1 ArgumentException 家族 (ArgumentNullException, ArgumentOutOfRangeException)

  • ArgumentException:通用的参数错误,表示传递给方法的某个参数不合法。

  • ArgumentNullExceptionArgumentException 的子类,特指一个不应为 null 的参数被传入了 null

  • ArgumentOutOfRangeExceptionArgumentException 的子类,特指一个参数的值超出了可接受的范围。

  • 示例:

    public void SetUserName(string name) 
    {
        // 卫语句:主动防御,而不是等着用到 name 时再爆炸
        if (string.IsNullOrWhiteSpace(name)) 
        {
            throw new ArgumentException("用户名不能为空或仅包含空白字符。", nameof(name));
        }
        this.UserName = name;
    }
    
    public void SetDiscount(double percentage) 
    {
        if (percentage < 0 || percentage > 1) 
        {
            throw new ArgumentOutOfRangeException(nameof(percentage), "折扣必须在0和1之间。");
        }
        this.Discount = percentage;
    }
    
    // 调用者犯错
    myObject.SetUserName(null); // 会立即捕获到 ArgumentException,而不是后面的 NullReferenceException
    

4. InvalidCastException:无效转换异常

  • 在运行时执行了一个显式的类型转换,但源类型无法被转换为目标类型时发生

  • 示例:

    object myObject = "Hello World";
    // 这是一个字符串,不能被强制转换为一个 StringBuilder
    StringBuilder sb = (StringBuilder)myObject; 
    
  • 解决方案:

    1. 使用 as 运算符as 运算符尝试进行转换,如果成功,返回转换后的对象;如果失败,它会返回 null不是抛出异常。然后你可以配合 null 检查来安全地执行后续操作。
      StringBuilder sb = myObject as StringBuilder;
      if (sb != null) {
          sb.Append("...");
      } else {
          Console.WriteLine("对象不是 StringBuilder 类型。");
      }
      
    2. 使用 is 运算符和模式匹配is 运算符检查一个对象是否兼容某个类型
      if (myObject is StringBuilder sb) {
          sb.Append("...");
      }
      

    as/is vs. 强制转换:与 TryParse vs. Parse 类似,如果你不确定转换能否成功,就应该使用 asis。只有在你100%确定类型兼容时,才使用强制转换,因为它能更早地暴露逻辑错误。

5. InvalidOperationException:无效操作异常

  • 当方法调用对于对象的当前状态无效时抛出。错误不在于参数,而在于对象“还没准备好”或“已处于不当状态”。

  • 示例:

    // 场景1: 修改正在迭代的集合
    var numbers = new List<int> { 1, 2, 3, 4 };
    foreach (var number in numbers) {
        if (number == 2) {
            numbers.Remove(number); //  不能在 foreach 循环中修改集合
        }
    }
    
    // 场景2: 使用已耗尽的迭代器
    var enumerator = numbers.GetEnumerator();
    while(enumerator.MoveNext()) { }
    // 迭代器已到末尾
    Console.WriteLine(enumerator.Current); // BOOM! (或返回默认值,取决于实现)
    

6. IOException 家族 (FileNotFoundException, DirectoryNotFoundException, etc.)

  • 所有与输入/输出(I/O)操作相关的错误的基类。
    • FileNotFoundException:尝试访问一个不存在的文件。
    • DirectoryNotFoundException:文件路径中的某个目录不存在。
    • UnauthorizedAccessException:程序没有足够的权限去访问文件或目录。
  • 核心特点:这类异常是典型的必须使用 try-catch 来处理的场景。因为即使你在操作前用 File.Exists 检查,也无法保证在检查和实际操作之间的瞬间,文件不会被用户或其他程序删除(这被称为“竞态条件”)。
  • 最佳实践
    try
    {
        string content = File.ReadAllText(@"C:\secret\data.txt");
        // ... process content ...
    }
    catch (FileNotFoundException ex)
    {
        Console.WriteLine("错误:文件未找到。请确认文件路径是否正确。");
    }
    catch (UnauthorizedAccessException ex)
    {
        Console.WriteLine("错误:权限不足。请尝试以管理员身份运行程序。");
    }
    catch (IOException ex) // 兜底处理其他I/O错误
    {
        Console.WriteLine($"发生了一个I/O错误: {ex.Message}");
    }
    

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

【基础】Unity着色器编程的语言和数学基础介绍

作者 SmalBox
2025年12月7日 10:47

【Unity Shader Graph 使用与特效实现】专栏-直达

着色器编程语言基础

Unity URP(Universal Render Pipeline)管线中主要支持三种着色器语言:GLSL(OpenGL Shading Language)、CG(C for Graphics)以及HLSL(High-Level Shading Language)。这些语言均基于C语言的语法结构,并针对GPU并行计算的特点进行了专门优化。

GLSL与HLSL/CG的差异

GLSL是OpenGL标准中使用的着色语言,而HLSL由微软为DirectX平台设计,CG则是由NVIDIA推出的跨平台着色语言。Unity早期开发中主要使用CG语言,但随着URP管线的推广,HLSL逐渐成为更主流的选择。GLSL与HLSL/CG在以下方面存在差异:

  • 语法细节上有所不同
  • 内置函数的命名与实现方式存在差异
  • 矩阵存储顺序不同:HLSL与CG采用列优先(column-major),而GLSL使用行优先(row-major)

Shader Graph中的语言抽象机制

Shader Graph借助节点化系统对底层着色语言进行了抽象封装,开发者无需直接编写代码即可构建复杂的着色效果。然而,掌握底层语言知识对于调试着色器以及实现更高级的图形效果仍然至关重要。

数学基础

向量运算

着色器编程中广泛使用向量运算,主要包括:

  • 向量分量访问:float3 v = (1, 2, 3); float x = v.x;
  • 向量相加:float3 a + float3 b
  • 点积(标量积):float d = dot(a, b),常用于计算光照强度等场景

坐标系变换

在URP渲染管线中,主要涉及以下四种坐标系:

  • 物体空间(Object Space):模型自身的局部坐标系
  • 世界空间(World Space):整个场景的全局三维坐标系
  • 观察空间(View Space):以摄像机为原点的坐标系
  • 裁剪空间(Clip Space):顶点在标准化设备坐标之前的空间

坐标系之间的转换通过矩阵运算实现,例如使用UnityObjectToWorld函数可将顶点从物体空间变换至世界空间。

着色器类型详解

顶点着色器

顶点着色器负责处理每个顶点的数据,执行几何变换与基础光照计算。其典型结构如下:

struct appdata {
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
};

struct v2f {
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
};

v2f vert(appdata v) {
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    o.uv = v.uv;
    return o;
}

片元着色器

片元着色器(又称像素着色器)处理每个像素的颜色输出,示例结构如下:

fixed4 frag(v2f i) : SV_Target {
     fixed4 col = tex2D(_MainTex, i.uv);
     return col;
}

几何着色器

几何着色器用于处理图元(点、线、三角形),并能够生成新的几何结构。在URP中使用时需注意:

  • 定义三个结构体:输入(appdata)、几何处理阶段(v2g)与输出(g2f)
  • 使用#pragma geometry geom指令声明几何着色器
  • 通过[maxvertexcount]属性限制输出的最大顶点数量

计算着色器

计算着色器(Compute Shader)适用于通用GPU计算任务,不限于图形渲染管线。其主要特点包括:

  • 基于线程组(Thread Group)组织并行计算
  • 支持通过RWTexture等类型读写纹理数据
  • 适用于大规模并行数据处理场景

Shader Graph的核心优势

Shader Graph为URP开发提供了以下显著优势:

  • 可视化编辑环境:通过节点连接实现着色器逻辑,降低编码门槛
  • 快速原型迭代:实时预览着色效果,大幅提升开发效率
  • 跨平台兼容性:自动适配不同图形API的底层差异
  • 丰富的内置节点库:提供常用数学运算、纹理操作与效果节点
  • 灵活的材质参数配置:直观地暴露和调整着色器属性

性能优化策略

在URP项目中优化着色器性能时,应重点关注以下方面:

  • 减少纹理采样次数:尽可能合并多次采样操作
  • 简化光照计算模型:移动端设备建议使用简化光照
  • 合理选择数值精度:在适当场景中使用half类型替代float
  • 避免复杂分支逻辑:GPU执行分支可能导致性能波动
  • 实施细节层次(LOD):为不同性能的设备提供多级别着色器细节

示例:URP基础着色器实现

以下是一个符合URP规范的简单着色器代码框架:

Shader "URP/ExampleShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Color ("Color", Color) = (1,1,1,1)
    }

    SubShader
    {
        Tags 
        { 
            "RenderType" = "Opaque" 
            "RenderPipeline" = "UniversalPipeline" 
        }

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            float4 _Color;

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            Varyings vert(Attributes input)
            {
                Varyings output;
                output.positionCS = TransformObjectToHClip(input.positionOS);
                output.uv = input.uv;
                return output;
            }

            half4 frag(Varyings input) : SV_Target
            {
                half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
                return texColor * _Color;
            }
            ENDHLSL
        }
    }
}

此示例展示了在URP中如何定义着色器属性、组织顶点与片元处理逻辑,以及使用URP内置的宏与函数库实现基础渲染流程。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Swift 6.2 列传(第三篇):字符串插值的 “补位神技”

2025年12月7日 10:19

在这里插入图片描述

引子:糖葫芦与报错齐飞,熊猫侠卡壳客栈

洛阳城的 “码林分舵” 客栈里,大熊猫侯佩正一手攥着糖葫芦,一手戳着屏幕上的红色报错,圆脸蛋皱成了包子。

这位自称 “插值小能手,头亮也不秃” 的 Swift 玩家,此刻正被一行字符串打印代码难住 —— 用户信息里的ageInt?,想给个 “Unknown” 当默认值,结果编译器偏说 “类型不匹配,此路不通”。

在这里插入图片描述

“岂有此理!” 侯佩咬碎一颗山楂,糖渣掉在键盘上,“name ?? "Anonymous"还好好的,换age就翻脸?难不成要我先把age转成字符串,多此一举像绕远路吃包子?”

在本篇武林列传中,您将学到如下内容:

  • 引子:糖葫芦与报错齐飞,熊猫侠卡壳客栈
  • 🎯 1. 新招揭秘:SE-0477 的 “简约补位术”
  • 🤔 2. 初看平淡?nil coalescing 的 “软肋” 在此
  • ✨ 3. 关键突破:跨类型补位的 “通关秘籍”
  • 🚨 4. 细节提醒:别踩 “默认值类型” 的小坑
  • 🔮 结尾:复杂插值现新疑,秘籍残页藏玄机

就在他准备写 “笨办法” 时,窗边传来轻柔的声音:“侯大侠莫急,我这有 SE-0477 的‘补位神技’(Default Value in String Interpolations),专解这种‘类型不对付’的难题。” 只见程灵素一身青布衣裙,手中拿着本《Swift 插值秘籍》,笑容温和如春风。

在这里插入图片描述


🎯 1. 新招揭秘:SE-0477 的 “简约补位术”

SE-0477 这门功法,看似小巧,却藏着大智慧 —— 它给字符串插值(String Interpolation) 里的可选类型(optional) 加了 “补位功能”:

如果可选值是nil,直接在插值里指定默认值就行,不用再写额外的判断。

侯佩凑过去一看,程灵素写下的基础用法让他眼前一亮:

var name: String? = nil // 用户没填名字,是nil

// 用SE-0477的新语法:插值里加(default: 默认值)

print("Hello, \(name, default: "Anonymous")!")

// 直接输出:Hello, Anonymous!

“这比以前的\(name ?? "Anonymous")就少个问号啊?” 侯佩挠挠头,山楂核差点掉进键盘缝,“看着也没多厉害嘛。”

在这里插入图片描述

程灵素笑着摇头:“侯大侠别急,这只是‘开胃小菜’,真正的厉害之处,你且后面再看。”


🤔 2. 初看平淡?nil coalescing 的 “软肋” 在此

侯佩不服气,掏出之前能跑的代码:“你看,age ?? 0就没问题,打印出来是‘Age: 0’,也没报错啊!”

var age: Int? = nil

// 以前的nil coalescing(空合运算符)写法,默认值是Int类型

print("Age: \(age ?? 0)") // 输出:Age: 0

“可要是你想给‘Unknown’当默认值呢?” 程灵素轻轻一点屏幕,“比如产品说‘没年龄就显示 “未知”’,你再试试?”

侯佩立马修改,结果红色报错又冒了出来:

// ❌ 编译报错:Int?和String类型不兼容,nil coalescing不支持跨类型

// print("Age: \(age ?? "Unknown")")

“这不就卡壳了?” 程灵素莞尔,“nil coalescing(空合运算符)的软肋就在这 —— 它要求默认值和可选值类型必须一致,就像糖葫芦只能串山楂,不能混着包子串。”

在这里插入图片描述

侯佩恍然大悟,拍了下大腿(差点把糖葫芦拍掉):“原来如此!我之前绕远路转类型,就是因为这‘类型锁’!”


✨ 3. 关键突破:跨类型补位的 “通关秘籍”

“别急,SE-0477 的‘补位神技’,就是来解这‘类型锁’的。”

程灵素拿起笔,在纸上写下关键代码:

var age: Int? = nil

// ✅ Swift 6.2+新语法:插值里直接给不同类型的默认值

print("Age: \(age, default: "Unknown")");

// 输出:Age: Unknown

侯佩眼睛瞪得溜圆,赶紧在电脑上试了试 —— 居然真的编译通过,运行结果完美!

在这里插入图片描述

“这也太丝滑了吧!” 侯佩激动地咬了口糖葫芦,“不用转类型,不用写额外判断,像程姑娘你配药一样,既精准又省事!”

程灵素补充道:“它的原理很简单 —— 插值时 Swift 会自动处理类型转换,把Int?String的默认值‘调和’成字符串输出,就像我配药时调和不同药材,让它们发挥合力。”

为了让侯佩彻底明白,她又写了个 “用户信息汇总” 的实战例子:

// 模拟用户数据:name是String?,age是Int?,score是Double?
struct User {
    var name: String?
    var age: Int?
    var score: Double?
}

let user = User(name: nil, age: nil, score: 89.5)

// 用SE-0477统一处理所有可选值的默认值,类型互不干扰
let userInfo = """
用户昵称:\(user.name, default: "匿名用户")
用户年龄:\(user.age, default: "未填写")
用户分数:\(user.score, default: "暂无数据")
"""

print(userInfo)
/* 输出结果:
用户昵称:匿名用户
用户年龄:未填写
用户分数:89.5
*/

“你看,不管是 String、Int 还是 Double 的可选值,都能按需求给不同类型的默认值,再也不用‘拆东补西’了。” 程灵素说。

在这里插入图片描述

侯佩连连点头,边记笔记边嘀咕:“这招比我之前‘先判断 nil,再转类型,再拼接’的笨办法,效率高了不止一点,还能少写好几行代码 —— 毕竟写代码就像吃包子,能一口解决的,绝不咬第二口!”


🚨 4. 细节提醒:别踩 “默认值类型” 的小坑

程灵素突然话锋一转:“不过有个小细节要注意 —— 默认值的类型得是‘能转成字符串’的,比如数字、布尔值、字符串都行,但要是传个UIView?这种‘不好转字符串’的类型,还是会报错。”

她举了个反例:

import UIKit

var view: UIView? = nil

// ❌ 编译报错:UIView类型无法直接转成字符串,默认值也得是“可字符串化”的

// print("View: \(view, default: "No View")")

“哦!这就像配药不能放‘不能入口’的药材,对吧?” 侯佩立马 get 到,“得确保默认值本身能‘变成字符串’,不然 Swift 也‘调不匀’。”

在这里插入图片描述

程灵素笑着点头:“正是这个理。不过大部分日常开发场景,比如用户信息、日志打印,常用类型都能支持,这招已经能解决九成以上的插值难题了。”


🔮 结尾:复杂插值现新疑,秘籍残页藏玄机

侯佩彻底掌握了 “补位神技”,开心地把剩下的糖葫芦吃完,还想试试更复杂的场景 —— 比如在插值里加计算,像\(user.score.map { $0 * 10 }, default: "暂无")

在这里插入图片描述

可刚写完,他又愣住了:“哎?这里加了map处理,默认值还能用吗?”

程灵素凑过来看了看,指了指《Swift 插值秘籍》最后一页的残页:“Add Collection conformances for enumerated()

侯佩盯着残页上模糊的字迹,好奇心被勾了起来:“难道还有更厉害的招式?下次咱们可得好好研究研究!”

Swift 6.2 列传(第二篇):标识符的 “破界神通”

2025年12月7日 10:18

在这里插入图片描述

引子:码林命名劫,熊猫侠卡壳当场

华山脚下的 “码林客栈” 里,大熊猫侯佩正对着桌面的代码手稿唉声叹气,圆滚滚的身子瘫在椅背上,手里的肉包子都忘了啃。

这位自称 “码界美髯公,头亮不秃头” 的 Swift 高手,此刻正被一个看似简单的问题难住 ——HTTP 错误码的枚举命名。“401、404 这些数字当枚举 case,Swift 偏说‘名不正言不顺’,难不成要我改成_401、error404 这种不伦不类的名字?” 侯佩抓了抓头顶的绒毛,生怕再掉一根就破了 “不秃头” 的誓言。

在这里插入图片描述

就在他愁眉不展时,一道娇俏的身影掀帘而入,正是身着紫色纱裙的小昭。

在本篇武功秘籍中,您将学到如下内容:

  • 引子:码林命名劫,熊猫侠卡壳当场
  • 🎯 1. 新招揭秘:反引号下的 “命名自由”
  • ⚙️ 2. 功法实操:数字、空格皆可成名
  • 🚨 3. 避坑指南:数字命名的 “使用诀窍”
  • 🎉 4. 最大赢家:测试代码的 “可读性革命”
  • ⚠️ 5. 细节陷阱:运算符命名的 “边界红线”
  • 🔮 结尾:诡异命名现端倪,下卷功法藏玄机

她手中捧着一本泛黄的《Swift 新功法秘籍》,眼眸灵动:“侯大侠莫急,我这有 SE-0451 的‘破界神通’(Raw identifiers),专解命名之困,就算是数字开头、带空格的名字,也能在码林里畅行无阻!”

在这里插入图片描述


🎯 1. 新招揭秘:反引号下的 “命名自由”

SE-0451 这门 “破界神通”,堪称码林的 “命名救星”—— 它极大扩展了标识符(变量、函数、枚举 case 等的名字)的可用字符范围。

只要把名字放进反引号(`) 里,就能随心所欲命名,再也不用受 “不能以数字开头”“不能含空格” 的束缚。

在这里插入图片描述

比如,下面的代码在 Swift 6.2 是合法且有效的:

func `function name with spaces`() {
    print("Hello, world!")
}

`function name with spaces`()

这一下可真是 “柳暗花明又一村”!以前命名时束手束脚的烦恼,如今一个反引号就能轻松化解,简直是为侯佩这种 “强迫症命名党” 量身定做。


⚙️ 2. 功法实操:数字、空格皆可成名

侯佩眼睛一亮,抢过秘籍迫不及待地尝试。

小昭在一旁指点,他很快写出了第一份 “实战代码”:

// HTTP错误码枚举,数字开头也能直接当case名
enum HTTPError: String {
    case `401` = "Unauthorized" // 反引号包裹,数字开头无压力
    case `404` = "Not Found"    // 再也不用写_404或error404
    case `500` = "Internal Server Error"
    case `502` = "Bad Gateway"
}

“妙啊!” 侯佩拍案叫绝,肉包子都掉在了桌上,“这样一来,枚举 case 和实际错误码一一对应,可读性直接拉满,再也不用费劲记那些冗余的命名了!”

在这里插入图片描述

小昭笑着提醒:“侯大侠别急着得意,用数字开头的标识符时,可得注意‘避坑’,不然容易让 Swift‘认不出’。”


🚨 3. 避坑指南:数字命名的 “使用诀窍”

侯佩刚想进一步尝试,就被小昭拦住。她指着秘籍上的注解,耐心讲解:“用数字当标识符时,有两个诀窍,否则会触发 Swift 的‘ confusion 大法’。”

在这里插入图片描述

  1. 类型限定,明确身份:使用时必须加上类型前缀,避免 Swift 把数字当成畸形浮点字面量。
let error = HTTPError.401 // 加上HTTPError限定,Swift才知道401是枚举case

switch error {
case HTTPError.401, HTTPError.404: // 明确类型,避免歧义
    print("Client error: \(error.rawValue)")
default:
    print("Server error: \(error.rawValue)")
}
  1. 反引号精准定位:也可以把数字本身用反引号包裹,注意不要包含前面的点。
switch error {
case .`401`, .`404`: // 反引号只包数字,点留在外面
    print("Client error: \(error.rawValue)")
default:
    print("Server error: \(error.rawValue)")
}

侯佩边听边记,时不时点头:“原来如此,这就像给数字标识符‘挂个名牌’,让 Swift 不会认错人!”


🎉 4. 最大赢家:测试代码的 “可读性革命”

小昭接着说道:“这门‘破界神通’最大的受益者,当属 Swift Testing 框架。

以前写测试用例,名字又长又绕,还得额外加字符串描述,如今直接用自然语言命名就行。”

在这里插入图片描述

她随手写下两段代码对比:

// 以前的写法:冗余又麻烦
import Testing

@Test("Strip HTML tags from string")
func stripHTMLTagsFromString() {
    // 测试逻辑
}

// 现在的写法:反引号+自然语言,简洁明了
import Testing

@Test
func `Strip HTML tags from string`() { // 反引号内直接写中文语义的测试名
    // 测试逻辑
}

“哇!这样一来,测试用例的名字直接就是测试目的,再也不用‘名不对文’,还少了重复的字符串描述,简直是‘减负神器’!”

在这里插入图片描述

侯佩看得两眼放光,花痴属性瞬间上线,“小昭你真是太聪明了,这招我学定了!”


⚠️ 5. 细节陷阱:运算符命名的 “边界红线”

就在侯佩兴致勃勃地尝试给函数命名为add + subtract时,小昭急忙制止:“侯大侠慢着!这门功法虽能破界,但也有‘红线’不能碰。”

她指着秘籍上的警示:“原始标识符可以以运算符字符开头、包含或结尾,但不能只包含运算符字符。比如+123abc-xyz是合法的,但+-*这种纯运算符名字就不行。”

在这里插入图片描述

侯佩吐了吐舌头,赶紧修改代码:“还好你提醒,不然我又要踩坑了!看来这‘破界神通’也得守规矩,不能随心所欲。”


🔮 结尾:诡异命名现端倪,下卷功法藏玄机

侯佩成功掌握了 “破界神通”,开心地啃起了掉落的肉包子。他兴致勃勃地写下一个新函数:func 吃包子 + 写代码() { print("两不误!") },运行后居然毫无报错。

在这里插入图片描述

就在这时,小昭突然发现秘籍最后一页有一行模糊的字迹:“Default Value in String Interpolations”。 侯佩凑过去一看,只见字迹歪歪斜斜不像中原的符号,似一群调皮的小蝌蚪。

“这隐藏功法是什么?难道还有比‘破界神通’更厉害的命名招式?” 侯佩瞪大了眼睛,好奇心被彻底勾起。

在这里插入图片描述

欲知这隐藏功法的奥秘,且听下回分解!

vxe-gantt vue table 甘特图子任务多层级自定义模板用法

2025年12月7日 10:05

vxe-gantt vue table 甘特图子任务多层级自定义模板用法,通过树结构来渲染多层级的子任务,将数据带有父子层级的数组进行渲染

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

安装

npm install xe-utils@3.8.0 vxe-pc-ui@4.10.45 vxe-table@4.17.26 vxe-gantt@4.1.2
// ...
import VxeUIBase from 'vxe-pc-ui'
import 'vxe-pc-ui/es/style.css'

import VxeUITable from 'vxe-table'
import 'vxe-table/es/style.css'

import VxeUIGantt from 'vxe-gantt'
import 'vxe-gantt/lib/style.css'
// ...

createApp(App).use(VxeUIBase).use(VxeUITable).use(VxeUIGantt).mount('#app')
// ...

效果

image

代码

树结构由 tree-config 和 column.tree-node 参数开启,支持自动转换带有父子层级字段的平级列表数据,例如 { id: 'xx', parentId: 'xx' }。只需要设置 tree-config.transform 就可以开启自动转换,通过 task-view-config.scales.headerCellStyle 自定义列头样式

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions">
      <template #task-bar="{ row }">
        <div class="custom-task-bar" :style="{ backgroundColor: row.bgColor }">
          <div class="custom-task-bar-img">
            <vxe-image :src="row.imgUrl" width="60" height="60"></vxe-image>
          </div>
          <div>
            <div>{{ row.title }}</div>
            <div>开始日期:{{ row.start }}</div>
            <div>结束日期:{{ row.end }}</div>
            <div>进度:{{ row.progress }}%</div>
          </div>
        </div>
      </template>

      <template #task-bar-tooltip="{ row }">
        <div>
          <div>任务名称:{{ row.title }}</div>
          <div>开始时间:{{ row.start }}</div>
          <div>结束时间:{{ row.end }}</div>
          <div>进度:{{ row.progress }}%</div>
        </div>
      </template>
    </vxe-gantt>
  </div>
</template>

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

const ganttOptions = reactive({
  border: true,
  height: 600,
  cellConfig: {
    height: 100
  },
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskViewConfig: {
    tableStyle: {
      width: 380
    },
    showNowLine: true,
    scales: [
      { type: 'month' },
      {
        type: 'day',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      },
      {
        type: 'date',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      }
    ],
    viewStyle: {
      cellStyle ({ dateObj }) {
        // 周日高亮
        if (dateObj.e === 0) {
          return {
            backgroundColor: '#f9f0f0'
          }
        }
        return {}
      }
    }
  },
  taskBarConfig: {
    showTooltip: true,
    barStyle: {
      round: true
    }
  },
  columns: [
    { field: 'title', title: '任务名称', treeNode: true },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, parentId: null, title: '任务1', start: '2024-03-03', end: '2024-03-10', progress: 20, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product9.png' },
    { id: 10002, parentId: 10001, title: '任务2', start: '2024-03-05', end: '2024-03-12', progress: 15, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10003, parentId: 10001, title: '任务3', start: '2024-03-10', end: '2024-03-21', progress: 25, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product1.png' },
    { id: 10004, parentId: 10002, title: '任务4', start: '2024-03-15', end: '2024-03-24', progress: 70, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product3.png' },
    { id: 10005, parentId: 10003, title: '任务5', start: '2024-03-20', end: '2024-04-05', progress: 50, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10006, parentId: null, title: '任务6', start: '2024-03-22', end: '2024-03-29', progress: 38, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product7.png' },
    { id: 10007, parentId: null, title: '任务7', start: '2024-03-28', end: '2024-04-04', progress: 24, bgColor: '#78e6d1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10008, parentId: 10007, title: '任务8', start: '2024-05-18', end: '2024-05-28', progress: 65, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' },
    { id: 10009, parentId: 10008, title: '任务9', start: '2024-05-05', end: '2024-05-28', progress: 78, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product6.png' },
    { id: 10010, parentId: 10008, title: '任务10', start: '2024-04-28', end: '2024-05-17', progress: 19, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10011, parentId: 10009, title: '任务11', start: '2024-04-01', end: '2024-05-01', progress: 100, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' },
    { id: 10012, parentId: 10009, title: '任务12', start: '2024-04-09', end: '2024-04-22', progress: 90, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10013, parentId: 10010, title: '任务13', start: '2024-03-22', end: '2024-04-05', progress: 86, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10014, parentId: null, title: '任务14', start: '2024-04-05', end: '2024-04-18', progress: 65, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product6.png' },
    { id: 10015, parentId: 10014, title: '任务15', start: '2024-03-05', end: '2024-03-18', progress: 48, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10016, parentId: null, title: '任务16', start: '2024-03-01', end: '2024-03-28', progress: 28, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product12.png' },
    { id: 10017, parentId: null, title: '任务17', start: '2024-03-19', end: '2024-04-02', progress: 36, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' }
  ]
})
</script>

<style lang="scss" scoped>
.custom-task-bar {
  display: flex;
  flex-direction: row;
  padding: 8px 16px;
  width: 100%;
  font-size: 12px;
}
.custom-task-bar-img {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  width: 70px;
  height: 70px;
}
</style>

gitee.com/x-extends/v…

从零构建Vue项目的完全指南:手把手打造现代化前端工程

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

从零构建Vue项目的完全指南:手把手打造现代化前端工程

一、项目构建整体流程图

让我们先看看完整的项目构建流程:

deepseek_mermaid_20251207_d1ddc8.png

二、详细构建步骤

步骤1:环境准备与项目初始化

首先确保你的开发环境已准备好:

# 检查Node.js版本(建议18+)
node -v

# 检查npm版本
npm -v

# 安装Vue CLI(如果还没有)
npm install -g @vue/cli

# 创建新项目
vue create my-vue-project

# 选择配置(推荐手动选择)
? Please pick a preset: 
  Default ([Vue 2] babel, eslint)
  Default (Vue 3) ([Vue 3] babel, eslint)
❯ Manually select features

# 选择需要的功能
? Check the features needed for your project:
 ◉ Babel
 ◉ TypeScript
 ◉ Progressive Web App (PWA) Support
 ◉ Router
 ◉ Vuex
 ◉ CSS Pre-processors
❯◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

步骤2:项目目录结构设计

一个良好的目录结构是项目成功的基础。这是我推荐的目录结构:

my-vue-project/
├── public/                    # 静态资源
│   ├── index.html
│   ├── favicon.ico
│   └── robots.txt
├── src/
│   ├── api/                  # API接口管理
│   │   ├── modules/         # 按模块划分的API
│   │   ├── index.ts         # API统一导出
│   │   └── request.ts       # 请求封装
│   ├── assets/              # 静态资源
│   │   ├── images/
│   │   ├── styles/
│   │   └── fonts/
│   ├── components/          # 公共组件
│   │   ├── common/         # 全局通用组件
│   │   ├── business/       # 业务组件
│   │   └── index.ts        # 组件自动注册
│   ├── composables/        # 组合式函数
│   │   ├── useFetch.ts
│   │   ├── useForm.ts
│   │   └── index.ts
│   ├── directives/         # 自定义指令
│   │   ├── permission.ts
│   │   └── index.ts
│   ├── layouts/            # 布局组件
│   │   ├── DefaultLayout.vue
│   │   └── AuthLayout.vue
│   ├── router/             # 路由配置
│   │   ├── modules/       # 路由模块
│   │   ├── index.ts
│   │   └── guard.ts      # 路由守卫
│   ├── store/              # Vuex/Pinia状态管理
│   │   ├── modules/       # 模块化store
│   │   └── index.ts
│   ├── utils/              # 工具函数
│   │   ├── auth.ts        # 权限相关
│   │   ├── validate.ts    # 验证函数
│   │   └── index.ts
│   ├── views/              # 页面组件
│   │   ├── Home/
│   │   ├── User/
│   │   └── About/
│   ├── types/              # TypeScript类型定义
│   │   ├── api.d.ts
│   │   ├── global.d.ts
│   │   └── index.d.ts
│   ├── App.vue
│   └── main.ts
├── tests/                   # 测试文件
├── .env.*                   # 环境变量
├── vite.config.ts          # Vite配置
├── tsconfig.json           # TypeScript配置
└── package.json

步骤3:核心配置详解

1. 配置Vite(vite.config.ts)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components'),
      '@views': path.resolve(__dirname, 'src/views'),
    },
  },
  server: {
    host'0.0.0.0',
    port3000,
    proxy: {
      '/api': {
        target'http://localhost:8080',
        changeOrigintrue,
        rewrite: (path) => path.replace(/^/api/, ''),
      },
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@/assets/styles/variables.scss";`,
      },
    },
  },
})
2. 路由配置(router/index.ts)
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routesRouteRecordRaw[] = [
  {
    path'/',
    name'Home',
    component() => import('@views/Home/Home.vue'),
    meta: {
      title'首页',
      requiresAuthtrue,
    },
  },
  {
    path'/login',
    name'Login',
    component() => import('@views/Login/Login.vue'),
    meta: {
      title'登录',
    },
  },
  {
    path'/user/:id',
    name'User',
    component() => import('@views/User/User.vue'),
    propstrue,
  },
]

const router = createRouter({
  historycreateWebHistory(),
  routes,
})

// 路由守卫
router.beforeEach((to, from, next) => {
  document.title = to.meta.title as string || 'Vue项目'
  
  // 检查是否需要登录
  if (to.meta.requiresAuth && !localStorage.getItem('token')) {
    next('/login')
  } else {
    next()
  }
})

export default router
3. 状态管理(使用Pinia)
// store/user.ts
import { defineStore } from 'pinia'

interface UserState {
  userInfo: {
    namestring
    avatarstring
    rolesstring[]
  } | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    userInfonull,
  }),
  actions: {
    async login(credentials: { username: string; password: string }) {
      // 登录逻辑
      const response = await api.login(credentials)
      this.userInfo = response.data
      localStorage.setItem('token', response.token)
    },
    logout() {
      this.userInfo = null
      localStorage.removeItem('token')
    },
  },
  getters: {
    isLoggedIn(state) => !!state.userInfo,
    hasRole(state) => (role: string) => 
      state.userInfo?.roles.includes(role) || false,
  },
})

步骤4:核心工具库和插件选择

这是我在项目中推荐使用的库:

{
  "dependencies": {
    "vue""^3.3.0",
    "vue-router""^4.2.0",
    "pinia""^2.1.0",
    "axios""^1.4.0",
    "element-plus""^2.3.0",
    "lodash-es""^4.17.21",
    "dayjs""^1.11.0",
    "vxe-table""^4.0.0",
    "vue-i18n""^9.0.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue""^4.2.0",
    "@types/node""^20.0.0",
    "sass""^1.62.0",
    "eslint""^8.0.0",
    "prettier""^3.0.0",
    "husky""^8.0.0",
    "commitlint""^17.0.0",
    "vitest""^0.30.0",
    "unplugin-auto-import""^0.16.0",
    "unplugin-vue-components""^0.25.0"
  }
}

步骤5:实用的组件示例

1. 全局请求封装
// src/api/request.ts
import axios from 'axios'
import type { AxiosRequestConfigAxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

const service = axios.create({
  baseURLimport.meta.env.VITE_API_BASE_URL,
  timeout10000,
})

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

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, data, message } = response.data
    
    if (code === 200) {
      return data
    } else {
      ElMessage.error(message || '请求失败')
      return Promise.reject(new Error(message))
    }
  },
  (error) => {
    if (error.response?.status === 401) {
      // 未授权,跳转到登录页
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
    ElMessage.error(error.message || '网络错误')
    return Promise.reject(error)
  }
)

export default service
2. 自动导入组件配置
// vite.config.ts 补充配置
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    // 自动导入API
    AutoImport({
      imports: ['vue''vue-router''pinia'],
      dts'src/types/auto-imports.d.ts',
      resolvers: [ElementPlusResolver()],
    }),
    // 自动导入组件
    Components({
      dts'src/types/components.d.ts',
      resolvers: [ElementPlusResolver()],
      dirs: ['src/components'],
    }),
  ],
})
3. 实用的Vue 3组合式函数
// src/composables/useForm.ts
import { ref, reactive, computed } from 'vue'
import type { Ref } from 'vue'

export function useForm<T extends object>(initialData: T) {
  const formData = reactive({ ...initialData }) as T
  const errors = reactive<Record<stringstring>>({})
  const isSubmitting = ref(false)

  const validate = async (): Promise<boolean> => {
    // 这里可以集成具体的验证逻辑
    return true
  }

  const submit = async (submitFn: (data: T) => Promise<any>) => {
    if (!(await validate())) return
    
    isSubmitting.value = true
    try {
      const result = await submitFn(formData)
      return result
    } catch (error) {
      throw error
    } finally {
      isSubmitting.value = false
    }
  }

  const reset = () => {
    Object.assign(formData, initialData)
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
  }

  return {
    formData,
    errors,
    isSubmittingcomputed(() => isSubmitting.value),
    validate,
    submit,
    reset,
  }
}

步骤6:开发规范与最佳实践

1. 代码提交规范
# 安装Git提交钩子
npx husky install
npm install -D @commitlint/config-conventional @commitlint/cli

# 创建commitlint配置
echo "module.exports = { extends: ['@commitlint/config-conventional'] }" > .commitlintrc.js

# 创建提交信息规范
# feat: 新功能
# fix: 修复bug
# docs: 文档更新
# style: 代码格式
# refactor: 重构
# test: 测试
# chore: 构建过程或辅助工具的变动
2. 环境变量配置
# .env.development
VITE_APP_TITLE=开发环境
VITE_API_BASE_URL=/api
VITE_USE_MOCK=true

# .env.production
VITE_APP_TITLE=生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_USE_MOCK=false

步骤7:性能优化建议

// 路由懒加载优化
const routes = [
  {
    path'/dashboard',
    component() => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue'),
  },
  {
    path'/settings',
    component() => import(/* webpackChunkName: "settings" */ '@/views/Settings.vue'),
  },
]

// 图片懒加载指令
// src/directives/lazyLoad.ts
import type { Directive } from 'vue'

const lazyLoadDirective = {
  mounted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value
          observer.unobserve(el)
        }
      })
    })
    observer.observe(el)
  },
}

三、项目启动和常用命令

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
    "format": "prettier --write src/",
    "prepare": "husky install",
    "test": "vitest",
    "test:coverage": "vitest --coverage"
  }
}

四、总结与建议

通过以上步骤,你已经拥有了一个现代化、可维护的Vue项目基础。记住几个关键点:

  1. 1. 保持一致性 - 无论是命名规范还是代码风格
  2. 2. 模块化设计 - 功能解耦,便于维护和测试
  3. 3. 类型安全 - 充分利用TypeScript的优势
  4. 4. 自动化 - 尽可能自动化重复工作
  5. 5. 渐进式 - 不要一开始就追求完美,根据项目需求逐步完善

项目代码就像一座大厦,良好的基础决定了它的稳固性和可扩展性。希望这篇指南能帮助你在Vue项目开发中少走弯路!

vue也支持声明式UI了,向移动端kotlin,swift看齐,抛弃html,pug升级版,进来看看新语法吧

作者 alamhubb
2025年12月7日 02:48

众所周知,新生代的ui框架(如:kotlin,swift,flutter,鸿蒙)都已经抛弃了XML这类的结构化数据标记语言改为使用声明式UI

只有web端还没有支持此类ui语法,此次我开发的ovsjs为前端也带来了此类声明式UI语法的支持,语法如下

项目地址

github.com/alamhubb/ov…

语法插件地址:

marketplace.visualstudio.com/items?itemN…

新语法如下:

image.png

我认为更强的地方是我的新设计除了为前端带来了声明式UI,还支持了 #{ } 不渲染代码块的设计,支持在 声明式UI中编写代码,这样UI和逻辑之间的距离更近,维护更方便,抽象组件也更容易

对比kotlin,swift,flutter,鸿蒙语法如下:

kotlin的语法

import kotlinx.browser.*
import kotlinx.html.*
import kotlinx.html.dom.*

fun main() {
    document.body!!.append.div {
        h1 {
            +"Welcome to Kotlin/JS!"
        }
        p {
            +"Fancy joining this year's "
            a("https://kotlinconf.com/") {
                +"KotlinConf"
            }
            +"?"
        }
    }
}

swiftUI的语法

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 16) {
            Text("Hello SwiftUI")
                .font(.largeTitle)
                .fontWeight(.bold)

            Text("Welcome to SwiftUI world")

            Button("Click Me") {
                print("Button clicked")
            }
        }
        .padding()
    }
}

flutter的语法

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Text(
                "Hello Flutter",
                style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 12),
              const Text("Welcome to Flutter world"),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () {
                  print("Button clicked");
                },
                child: const Text("Click Me"),
              )
            ],
          ),
        ),
      ),
    );
  }
}

鸿蒙 arkts

@Entry
@Component
struct Index {
  @State message: string = 'Hello ArkUI'

  build() {
    Column() {
      Text(this.message)
        .fontSize(28)
        .fontWeight(FontWeight.Bold)

      Text('Welcome to HarmonyOS')
        .margin({ top: 12 })

      Button('Click Me')
        .margin({ top: 16 })
        .onClick(() => {
          console.log('Button clicked')
        })
    }
    .padding(20)
  }
}

原理实现

简述一下实现原理,就是通过parser支持了新语法,然后将新语法转义为 iife包裹的vue的h函数

为什么要iife包裹

因为要支持不渲染代码块

ovs图中的代码对应的编译后的代码是这样的

import {defineOvsComponent} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {$OvsHtmlTag} from "/@fs/D:/project/qkyproject/test-volar/ovs/ovs-runtime/src/index.ts";
import {ref} from "/node_modules/.vite/deps/vue.js?v=76ca4127";
export default defineOvsComponent(props => {
  const msg = "You did it!";
  let count = ref(0);
  const timer = setInterval(() => {
    count.value = count.value + 1;
  },1000);
  return $OvsHtmlTag.div({class:'greetings',onClick(){
    count.value = 0;
  }},[
    $OvsHtmlTag.h1({class:'green'},[msg]),
    count,
    $OvsHtmlTag.h3({},[
      "You've successfully created a project with ",
      $OvsHtmlTag.a({href:'https://vite.dev/',target:'_blank',rel:'noopener'},['Vite']),
      ' + ',
      $OvsHtmlTag.a({href:'https://vuejs.org/',target:'_blank',rel:'noopener'},['Vue 3']),
      ' + ',
      $OvsHtmlTag.a({href:'https://github.com/alamhubb/ovsjs',target:'_blank',rel:'noopener'},['OVS']),
      '.'
    ])
  ]);
});

parser是我自己写的,抄了 chevortain 的设计,写了个subhuti,支持定义peg语法

github.com/alamhubb/ov…

slimeparser,支持es2025语法的parser,基于subhuti,声明es2025语法就行

github.com/alamhubb/ov…

然后就是ovs继承slimeparser,添加了ovs的语法支持,并且在ast生成的时候将代码转为vue的渲染函数,运行时就是运行的vue的渲染函数的代码,所以完美支持vue的生态

感兴趣的可以试试,入门教程

github.com/alamhubb/ov…

由于本人能力有先,文中存在错误不足之处,请大家指正,有对新语法感兴趣的欢迎留言和我交流

❌
❌