阅读视图

发现新文章,点击刷新页面。

深入浅出Electron打包应用原理

前言

本篇文章,脱离Electron Forge或者Electron Build打包工具,从手动打包Electron应用开始,逐步深入了解Electron应用打包的过程及原理,并简单剖析@electron/packager的打包过程

手动打包electron应用

在这篇文章中,我们将脱离Electron常用的打包工具,比如electron forge或者electron build,尝试自己手动打包Electron应用并重命名应用名字。

我们使Electron 预构建可执行文件一步一步打包我们的应用,并且使用asar将我们的应用程序源代码存档。最后我们可以简单的了解一下@electron/packager的打包原理及过程。

一、使用Electron预构建可执行文件

首先去electron的Github Release下载预构建可执行文件。这里可以根据自己电脑的系统配置下载对应的版本即可。

下载下来的Electron就是一个可以直接启动的app。如果是window,可以直接点击electron.exe启动应用。如果是Mac,可以直接点击Electron.app启动应用。默认的Electron应用源码入口是resources/default_app.asar。如果需要Electron启动的是我们自己的应用,那么可以直接在resources目录下新建一个app目录,将我们的应用源码比如src和package.json放进app目录下面即可。或者将src和package.json打包成app.asar,将打包的asar放到resources/下面即可。这样点击electron.exe或者Electron.app启动的就是我们的应用了。下面会简单介绍window系统和mac系统的源码替换过程。

1.1 window系统

如果电脑是window系统

manual_02.png

所以下载下面的版本

manual_01.png

下载完成后,解压,解压后的文件如下,点击下面的electron.exe就可以直接启动一个简单的electron应用,这个应用默认的启动入口是resources目录下的default_app.asar。

manual_03.png

现在,我们将electron的启动入口从resources/default_app.asar改成我们自己的应用。在resources目录下新建一个app,然后把我们的应用代码放在这里

manual_04.png

然后点击electron.exe就可以启动我们的应用

manual_05.png

1.2 mac系统

需要确认mac处理器是ARM64架构还是x64架构。可以通过选择mac的“关于本机”,在这个窗口中,你会看到 “芯片” 或 “处理器” 信息。如果显示的是 “Apple 芯片”(如 M1、M2 等),那么你的 Mac 是 ARM64 架构。如果显示的是英特尔(Intel)处理器,那么它就是 x64 架构。

由于我的mac是英特尔处理器,因此选择下载下面的zip

manual_09.jpg

解压缩后,可以看到文件内容如下,其中,Electron.app本身就是一个electron应用,点击可以启动一个客户端的应用。这个应用的默认入口就是Electron.app/Contents/Resources/default_app.asar。可以通过右键Electron.app显示包内容查看。

manual_11.jpg

现在,我们需要将Electron默认的启动入口default_app.asar改成我们自己的应用。在包内容中的Contents/Resources/目录下新建一个app文件夹,存放我们的源代码:

manual_10.jpg

双击Electron,就可以启动我们的应用了

manual_12.jpg

二、将我们的应用源码打包成asar

如果你没有使用 Parcel 或 Webpack 之类的构建工具,为了减轻拷贝源文件的分发压力,你可以把你的 app 打包成一个 asar 包来提升在 Windows 等平台上读取文件的性能。

为了使用一个 asar 档案文件代替 app 文件夹,需要将app打包为app.asar,然后将其放到 Electron 的Resources资源文件夹下,然后 Electron 就会试图读取这个app.asar档案文件并从中启动。

首先运行 npm install --engine-strict @electron/asar 安装asar

然后我们写个脚本,将src和pacakge.json一起打包成一个app.asar文件:

manual_06.png

然后在package.json中添加打包命令:

manual_07.png

运行 npm run asar,即可在src同级目录下看到一个app.asar文件了

2.1 window系统

复制生成的app.asar文件,放到electron的resources目录下:

manual_08.png

点击electron.exe可以看到应用启动了。

2.2 mac系统

复制生成的app.asar文件,放到electron的resources目录下:

manual_13.jpg

以上,我们使用Electron预构建文件来启动我们的应用,并且将应用源码打包成app.asar。接下来我们来了解一下如何重命名我们的应用。

三、重命名、如何定制我们的应用

当我们使用Electron构建我们的应用程序后,我们还需要在把应用分发给用户前,将Electron应用进行重新定制。

3.1 window定制

您可以将electon.exe重命名为您喜欢的任何名称,也可以通过rcedit编辑其图标和其他信息。

3.2 mac定制

默认情况下,当我们点击下面的Electron.app启动应用时,活动监视器的名称如下所示:

manual_12.jpg

manual_14.jpg

现在我们将它改成录屏大帝。

首先,将Electron.app重命名成录屏大帝.app:

manual_15.jpg

修改Electron.app/Contents/Info.plist下面几个字段:

<key>CFBundleDisplayName</key>
<string>录屏大帝</string>
<key>CFBundleExecutable</key>
<string>录屏大帝</string>
<key>CFBundleName</key>
<string>录屏大帝</string>

将Electron.app/Contents/MacOS/Electron重命名为Electron.app/Contents/MacOS/录屏大帝

同时还要将Electron.app/Contents/Frameworks/Electron Helper.app、Electron.app/Contents/Frameworks/Electron Helper (GPU).app、Electron.app/Contents/Frameworks/Electron Helper (Renderer).app这三个app文件重命名为

  • Electron.app/Contents/Frameworks/录屏大帝 Helper.app
  • Electron.app/Contents/Frameworks/录屏大帝 Helper (GPU).app
  • Electron.app/Contents/Frameworks/录屏大帝 Helper (Renderer).app

同时还要重新命名上面三个.app文件包内容里面的MacOS/Electron Helper、MacOS/Electron Helper (GPU)、MacOS/Electron Helper (Renderer)。并且修改相应的info.plist

Electron.app/Contents/Frameworks/录屏大帝 Helper.app/Contents/info.plist:

<key>CFBundleName</key>
<string>录屏大帝</string>
<key>CFBundleDisplayName</key>
<string>录屏大帝 Helper</string>
<key>CFBundleExecutable</key>
<string>录屏大帝 Helper</string>

Electron.app/Contents/Frameworks/录屏大帝 Helper (GPU).app/Contents/info.plist:

<key>CFBundleName</key>
<string>录屏大帝 Helper (GPU)</string>
<key>CFBundleDisplayName</key>
<string>录屏大帝 Helper (GPU)</string>
<key>CFBundleExecutable</key>
<string>录屏大帝 Helper (GPU)</string>

Electron.app/Contents/Frameworks/录屏大帝 Helper (Renderer).app/Contents/info.plist:

<key>CFBundleName</key>
<string>录屏大帝 Helper (Renderer)</string>
<key>CFBundleDisplayName</key>
<string>录屏大帝 Helper (Renderer)</string>
<key>CFBundleExecutable</key>
<string>录屏大帝 Helper (Renderer)</string>

最终的目录结构如下:

manual_19.jpg

点击录屏大帝.app启动我们的应用,可以发现应用名字、进程名字也已经被重命名了。

manual_20.jpg

Electron Package打包过程

如果阅读@electron/packager的源码可以发现,

src/platform.ts文件中,将我们的源代码打包成app.asar。

manual_22.jpg

src/mac.ts文件中,定制我们的应用,比如重命名应用名称,应用图标等。

manual_21.jpg

实际上做的就是我们前面手动打包的过程。

npm install electron安装的是什么

如果仔细观察,我们通过npm install electron后,会在node_modules里安装了一个electron,而这个electron就是我们前面从electron github release下载的文件。

electron/dist就是我们解压zip后的内容,两者是一致的:

manual_17.jpg

所以当我们使用electron forge或者electron build等打包工具打包时,这些工具会自动从node modules/electron中复制一个electron可执行文件出来,然后将我们src下面的源码和package.json一起打包成app.asar,放到复制出来的electron的资源文件Resources目录下,并重命名。这就是打包工具的原理。

小结

总的来说,Electron应用的打包就是直接下载Electron预构建可执行文件,这个本身就是一个简单的应用,然后将我们的源代码和package.json、node_modules下面的依赖放在Electron的资源目录Resources下面的app目录下。当然也可以将我们的源码打包成app.asar直接放到Resources目录下。然后就是重命名我们的应用名称、图标等这些定制信息。

vue 入门到实战 一

第1章 初始Vue.js

1.1 网站交互方式

Web网站有单页应用程序(SPA,Single-page Application)和多页应用程序(MPA,Multi-page Application)两种交互方式。

多页应用程序,顾名思义是由多个页面组成的站点。在多页应用程序中,每个网页在每次收到相应的请求时都会重新加载。多页应用程序很大,由于不同页面的数量和层数,有时甚至可以认为很麻烦,我们可以在大多数电子商务网站上找到MPA的示例。

多页应用程序以服务端为主导,前后端混合开发,例如:.php、.aspx、.jsp。技术堆栈包括HTML、CSS、JavaScript、jQuery,有时还包括AJAX。

图片

单页应用程序,就是只有一张Web页面的应用。单页应用程序是加载单个HTML页面并在用户与应用程序交互时,动态更新该页面的Web应用程序。浏览器一开始会加载必需的HTML、CSS和JavaScript,所有的操作都在这张页面上完成,都由JavaScript来控制。因此,对单页应用来说模块化的开发和设计显得相当重要。单页应用开发技术复杂,所以诞生了许多前端开发框架:Angular.js、React.js、Vue.js等。

选择单页应用程序开发时,软件工程师通常采用以下技术堆栈:HTML5、Angular.js、React.js、Vue.js、Ember.js、AJAX等。

图片

1.2 MVVM模式

MVVM是Model-View-ViewModel的缩写,它是一种基于前端开发的架构模式,其核心是提供对View和ViewModel的双向数据绑定,这使得ViewModel的状态改变可以自动传递给View,即所谓的数据双向绑定。

在MVVM架构下,View和Model 之间并没有直接的联系,而是通过ViewModel进行交互,Model和ViewModel之间的交互是双向的,因此View数据的变化会同步到Model中,而Model数据的变化也会立即反应到View上。

图片

1.2.1、Model

模型(Model):对应data中的数据,一般JS对象

data: {a'',address: '',name: ''}

data中书写的Key:Value都会出现在Vue实例VM身上

图片

1.2.2、View

视图(View):对应模板(DOM)

<div id="root"><h2>{  { a }}</h2><h2>{{ address }}</h2><h2>{  { name }}</h2></div>

1.2.3、ViewModel

视图模型(ViewModel):对应Vue实例对象(VM)

3、ViewModel

视图模型(ViewModel):对应Vue实例对象(VM)<script type="text/javascript">// ViewModelnew Vue({.   // Viewel: '#root', // Modeldata: {  a: '',address: '', name: ''})</script>

1.3 Vue.js是什么

Vue(读音/vjuː/,类似于view)是一套构建用户界面的渐进式框架。与其它重量级框架不同的是,Vue.js采用自底向上增量开发的设计。

Vue.js本身只是一个JS库,它的目标是通过尽可能简单的API实现响应的数据绑定和组合的视图组件。Vue.js可以轻松构建SPA(Single Web Application)应用程序,通过指令扩展HTML,通过表达式将数据绑定到HTML,最大程度解放DOM操作。

1.4 安装Vue.js

将Vue.js添加到项目中有4种主要方法:本地独立版本方法、CDN方法、NPM方法以及命令行工具(CLI)方法。

本地独立版本方法

可通过地址“unpkg.com/vue@next”将最…

首先安装一个live server插件,在helllovue.html代码上点击右键出现一个名为Open with Live Server的选项,自动打开浏览器,默认端口号是5500。

图片图片

CDN方法

可通过CDN(Content Delivery Network,内容分发网络)引入最新版本的Vue.js库。

图片

NPM方法

在使用Vue.js构建大型应用时推荐使用NPM安装最新稳定版的Vue.js,因为NPM能很好地和webpack模块打包器配合使用。示例如下:

npm install vue@next

命令行工具(CLI)方法

Vue.js提供一个官方命令行工具(Vue CLI),为单页面应用快速搭建繁杂的脚手架。对于初学者不建议使用NPM和Vue CLI方法安装Vue.js。

具体步骤可以参考下面的链接;

aistudy.baidu.com/okam/pages/…

1.5 第一个Vue.js程序

可通过“code.visualstudio.com”地址下载VSCode,本书使用的安装文件是VSCodeUserSetup-x64-1.52.1.exe(双击即可安装)。

图片图片

const vueApp = Vue.createApp({        //数据        data() {            return {                title"Vue3.0 使用 Vue.createApp() 创建一个应用程序 ",                userInfo: {} //定义用户对象            }        },        //初始化的入口        created: function () {            //调用方法:获取用户信息            this.getUserInfo();        },        //方法        methods: {            //获取用户信息            getUserInfo: function () {                this.userInfo = {                    userId1,                    userName"vivi的博客",                    blogName"您好,欢迎访问 vivi的博客",                    blogUrl"https://blog.csdn.net/vivi"                }            }        }        //使用 mount() 方法,装载应用程序实例的根组件    }).mount('#app'); 

每个Vue.js应用都是通过用createApp函数创建一个新实例开始,具体语法如下:

  const app = Vue.createApp({ /* 选项 */ }) 通过上面那个图片,可以看出这个选项可以定义一个参数,也可以包裹一个data数据,所以选项是选择。

传递给createApp的选项用于配置根组件(渲染的起点)。Vue.js应用创建后,调用mount方法将Vue.js应用挂载到一个DOM元素(HTML元素或CSS选择器)中,例如,如果把一个Vue.js应用挂载到

上,应传递#app。示例代码如下:

  const HelloVueApp = {}//配置根组件

  const vueApp = Vue.createApp(HelloVueApp)//创建Vue实例

  const vm = vueApp.mount('#hello-vue')//将Vue实例挂载到#app

1.6 插值与表达式

Vue的插值表达式“{ { }}”的作用是读取Vue.js中data数据,显示在视图中,数据更新,视图也随之更新。“{ { }}”里只能放表达式(有返回值),不可以放语句,例如,{ { var a = 1 }}与{ { if (ok) { return message } }}都是无效的。

数据绑定最常见的形式就是使用“Mustache(小胡子)”语法(双花括号)的文本插值,它将绑定的数据实时显示出来。例如,{ { counter }},无论何时,绑定的Vue.js实例的counter属性值发生改变,插值处的内容都将更新。

“{ { }}”将数据解释为普通文本,而非HTML代码。当我们需要输出真正的HTML代码时,可使用v-html指令。

假如,Vue.js实例的data为:

data() {            return {                rawHtml'<hr>'            }    }

则“

无法显示HTML元素内容: { { rawHtml }}

”显示的结果是
;而“

可正常显示HTML元素内容:

”显示的结果是一条水平线。

对于所有的数据绑定,Vue.js都提供了完全的JavaScript表达式支持。示例如下:

{ { number + 1 }}

{ { isLogin? 'True' : 'False' }}

{ { message.split('').reverse().join('')}}

HTML Element 的 alt 属性详解 —— 渐进式 Web 可访问性实践

在 Web 开发领域, HTML 元素 中的 alt 属性 是一项关键技术,其作用涉及到浏览器渲染、搜索引擎优化以及无障碍访问。 alt 属性 通常应用于 img 标签 上,其主要功能是为图像提供替代文本,以便在图像无法正常加载或用户使用屏幕阅读器时,仍能获取图像所表达的信息。下文将通过严谨的推理与分步分析,结合真实世界的案例,深入探讨 alt 属性 的各个方面。

在理解 alt 属性 之前,有必要认识到图像在 Web 页面中占据的重要地位。 Web 页面的图像能够传递丰富的信息与情感,但同时也存在图像加载失败、网络延迟或设备兼容性问题等风险。考虑到这一点, alt 属性 的诞生即为解决这些问题而设计。它不仅使页面内容在图像缺失时仍然保持可读性,同时也是提升 Web 可访问性和用户体验的基础措施之一。

在解释 alt 属性 的工作原理时,可以将其视为图像的备用说明。设想某个电子商务网站展示产品图片,但在网络状况不佳或图片资源被阻止加载的情况下,用户会看到一段简洁的文字描述,而不是空白区域或破损图标。此时, alt 属性 就发挥了关键作用。借助 alt 属性,浏览器能够在无法渲染图像时,显示由开发者预先定义的文本,从而保证用户仍能理解该部分内容。这一机制不仅提升了用户体验,也为搜索引擎提供了识别页面内容的依据,间接助力页面排名和搜索优化。

通过对 alt 属性 工作机制的深入分析,可以发现浏览器内核在渲染页面时,会首先加载 HTML 代码并解析各个标签,遇到 img 标签 时便会检查其 alt 属性 是否存在。如果图像资源加载成功, alt 属性 中的文本通常不会直接呈现;但当图像加载失败或者用户启用了无图模式时,浏览器会显示 alt 属性 的文本内容。屏幕阅读器 在为视障用户朗读页面内容时,同样会调用 alt 属性 的文本,使得用户能够理解图像所要表达的信息。这样一来,无论用户处于何种环境, alt 属性 都能够保证页面信息传达的完整性。

考虑到实际应用情景,不少知名企业均将 alt 属性 作为无障碍设计的重要组成部分。例如,某国际知名电商平台在产品详情页中,不仅为产品图片添加了详细描述,而且在图片为装饰性用途时,会将 alt 属性 设置为空字符串(alt=``""``")以避免冗余信息被屏幕阅读器朗读。这种实践经过反复验证,显著提升了页面对各类用户的友好度。以实际案例来看,一家主打家居产品的企业,在设计网站时,专门聘请了无障碍设计专家,通过对每个 img 标签 设置符合语义要求的 alt 属性,最终实现了访问量和转化率的双提升,用户反馈中对页面易用性给予了高度评价。

对 alt 属性 的最佳实践之一是确保替代文本的描述应尽可能精确与简洁。开发者在撰写 alt 属性 文本时,应避免使用模糊或无意义的词语。换句话说, alt 属性 的文本内容需要能够客观、准确地传达图像信息。设想一家旅游网站展示风景照片,如果 alt 属性 文本仅仅写成 图片,便难以让用户感知图像所蕴含的具体内容;而如果能够写成 夕阳西下 海边悬崖,则能更好地传递情感与场景。因而在实际操作中,合理描述图像内容显得尤为重要。

在讨论 alt 属性 的应用时,还需要注意其与其他 HTML 属性 的关系。 Web 开发过程中常常会使用到 title 属性、 aria-label 属性等,这些属性各有侧重。 title 属性 提供了鼠标悬停提示信息,而 aria-label 属性 则用于无障碍辅助技术,两者与 alt 属性 有相互补充的作用。与此同时,开发者需要区分装饰性图像与功能性图像之间的差异。对于纯粹起装饰效果的图像, alt 属性 通常应设置为空字符串(例如: <img src=decorative.jpg alt=```` />),以免干扰屏幕阅读器的内容输出。反之,对于承载主要信息的图像, alt 属性 的文本需要包含足够的信息细节,使得不使用图像的用户也能理解页面的意图。

回顾 alt 属性 的历史背景,可以发现早期的 Web 标准对无障碍访问的要求较低,但随着无障碍立法和用户需求的不断提升, alt 属性 的重要性逐步凸显。开发者们逐渐认识到,任何忽视 alt 属性 设计的网页,往往难以满足现代 Web 用户的多样化需求。国际标准组织( W3C )在其 Web 可访问性指南( WCAG )中,对 alt 属性 的使用提出了详细建议。透过这些指导原则,开发者可以确保页面在视觉、听觉和认知等各个层面都具有良好的可访问性,从而实现真正的包容性设计。

对浏览器内核而言, alt 属性 的处理机制体现了软件设计中容错与降级加载的思想。具体而言,当图像资源无法呈现时,内核会自动调用 alt 属性 中的文本作为占位符,这种设计不仅提高了系统的健壮性,还确保了用户体验的一致性。开发者在编写 HTML 代码时,应充分考虑到这一点,从而构建出既美观又实用的 Web 页面。举例来说,在新闻网站上,编辑在发布文章时,经常会上传配图;若因网络故障或图片链接失效, alt 属性 中的描述文本便成为文章内容的重要组成部分,使得读者仍能领略文章所要表达的意境。

在真实项目中, alt 属性 的设置经常涉及跨部门协作。设计师、开发者与内容编辑需要密切配合,确保每个图像都能准确反映其语义。例如,某大型在线教育平台在设计课程介绍页面时,不仅在产品原型阶段就明确规定了图像 alt 属性 的填写要求,还在上线后定期通过无障碍测试工具检测页面是否存在缺失或错误的 alt 属性。该平台借助详细的规范和自动化测试手段,成功降低了因图像描述不当导致的用户困惑和无障碍障碍风险,进一步巩固了品牌在用户心中的专业形象。

在技术实现上, alt 属性 与浏览器渲染引擎 的协作体现了 Web 开发中“优雅降级”与“渐进增强”的设计理念。渲染引擎在解析 HTML 代码时,会优先确定页面结构,然后根据各标签的属性值进行资源加载与显示。当 img 标签 中存在 alt 属性 时,无论图像是否加载成功,页面最终都能呈现出对用户友好的文本信息。对开发者而言,理解这一点可以帮助其在出现异常情况时迅速定位问题,并对症下药。举例而言,某网站在服务器负载高峰期出现部分图片加载失败,通过检测 HTML 源码发现部分 img 标签 缺失 alt 属性,故而在后续版本中及时修正,最终使得用户体验得到明显改善。

在讨论 alt 属性 的同时,还应关注到其在搜索引擎优化( SEO )中的作用。搜索引擎在对网页进行爬取和索引时,会分析 alt 属性 中的文本,从而判断图像所代表的内容是否与页面主题相关。合理使用 alt 属性 能够提升网页在相关搜索结果中的排名,进而吸引更多目标用户访问。比如,一家美食博客在展示菜肴图片时,通过在 alt 属性 中描述菜名与主要原料,成功使页面在美食搜索关键词中获得较高排名,从而带来大量精准流量。

在项目实践过程中, alt 属性 的编写需要遵循统一标准与规范。部分开发团队会借助静态代码分析工具检测 HTML 文件中的 alt 属性 是否合理存在,确保没有遗漏或错误。团队内部通常会制定详细的文档说明,规定何时应设置 alt 属性、何时应将其置为空以及如何描述图像信息。某科技公司在制定前端开发规范时,将 alt 属性 的设置作为代码审核的重要一环,并通过代码提交钩子( hook )自动检查这一问题。经过这种流程管控,团队大大降低了因 alt 属性 忽略导致的用户体验问题,并为后续的无障碍测试提供了有力的数据支持。

针对不同类型的图像, alt 属性 的描述策略也有所区别。功能性图像,例如按钮或图标,应当通过 alt 属性 明确传达其操作意义;而纯装饰性图像则可以将 alt 属性 设置为空字符串,从而让辅助技术忽略这些无关信息。以一个社交平台为例,其在设计消息通知图标时,会在 img 标签 中设置 alt=``""提醒图标"",而对于背景装饰图片则使用 alt=``""`。这种区分处理既保证了视觉效果,又提升了页面在无障碍环境下的使用流畅度。

综上所述, alt 属性 在 HTML 元素 中具有不可替代的重要性,它不仅是图像加载失败时的备用文本,更是提升无障碍访问与搜索引擎优化的重要手段。通过对浏览器内核渲染流程的理解、对无障碍设计需求的关注以及对真实项目案例的借鉴,开发者可以更好地掌握 alt 属性 的使用技巧,从而打造出兼具美观与实用的 Web 页面。无论是面向广泛用户群体的商业网站,还是注重用户体验的个人博客, alt 属性 的合理设置都将成为成功 Web 开发的重要基石。通过不断实践与改进,开发团队能够在提升页面加载效率的同时,保障所有用户均能获得一致且优质的体验,最终实现包容性设计与高效沟通的目标。

前端Three.js简介

Three.js 封装了什么?

Three.js 的核心目标是极大地简化在浏览器中进行 3D 图形编程的复杂性。它通过封装底层的 WebGL API(Web Graphics Library)来实现这一点。WebGL 本身是一个非常底层的接口,直接使用它需要编写大量冗余且复杂的代码来处理着色器、缓冲区、矩阵变换等。Three.js 在此基础上提供了更高层次、更易于使用的抽象。

主要封装内容包括:

  1. 渲染器 (Renderers) :

    • WebGLRenderer: 主要的渲染器,利用 WebGL 在 GPU 上高效渲染场景。封装了 WebGL 的状态管理、着色器编译链接、绘制调用等。
    • 还包括一些其他渲染器,如 CSS2DRenderer, CSS3DRenderer (用于将 DOM 元素与 3D 场景结合) 和 SVGRenderer (用于将场景渲染为 SVG)。
  2. 场景 (Scene) :

    • Scene: 3D 世界的容器,所有要渲染的物体、光源、相机等都必须添加到场景中。它维护了一个场景图 (Scene Graph) 的数据结构。
  3. 相机 (Cameras) :

    • PerspectiveCamera: 透视相机,模拟人眼的视觉效果,物体近大远小。
    • OrthographicCamera: 正交相机,物体大小不随距离变化,常用于 2D 游戏或工程制图。
    • 封装了视图矩阵 (View Matrix) 和投影矩阵 (Projection Matrix) 的计算。
  4. 物体 (Objects) :

    • Object3D: 所有 3D 物体的基类,提供了位置 (position)、旋转 (rotation/quaternion)、缩放 (scale) 等属性,以及层级关系(父子关系)。
    • Mesh: 表示由几何体 (Geometry) 和材质 (Material) 构成的网格物体,是最常见的可见物体。
    • Points: 表示点云物体。
    • Line, LineSegments, LineLoop: 表示线框物体。
    • Group: 用于组织多个 Object3D,方便整体变换。
    • SkinnedMesh: 用于骨骼动画。
    • InstancedMesh: 用于高效渲染大量相同几何体和材质但具有不同变换的实例。
  5. 几何体 (Geometries) :

    • BoxGeometry, SphereGeometry, PlaneGeometry, CylinderGeometry, TorusGeometry 等:内置的参数化几何体。
    • BufferGeometry: 更底层的几何体表示,允许开发者直接定义顶点位置、法线、UV 坐标、颜色等属性的缓冲区数据。Three.js 的所有内置几何体最终都转换为 BufferGeometry
    • 封装了顶点数据、面数据、法线、UV 坐标等的管理。
  6. 材质 (Materials) :

    • MeshBasicMaterial: 基础材质,不受光照影响,常用于测试或简单着色。
    • MeshLambertMaterial: Lambertian 光照模型,计算漫反射,表面比较粗糙。
    • MeshPhongMaterial: Phong 光照模型,计算漫反射和高光反射,表面更光滑。
    • MeshStandardMaterial (PBR): 基于物理的渲染材质,提供更真实的金属度 (metalness) 和粗糙度 (roughness) 控制。
    • MeshPhysicalMaterial (PBR): MeshStandardMaterial 的扩展,增加了清漆 (clearcoat)、透光性 (transmission) 等高级 PBR 属性。
    • ShaderMaterial, RawShaderMaterial: 允许开发者使用自定义的顶点着色器和片元着色器。
    • PointsMaterial, LineBasicMaterial, LineDashedMaterial 等。
    • 封装了颜色、纹理贴图、光照响应方式、透明度、混合模式等。
  7. 纹理 (Textures) :

    • Texture: 用于将图像数据应用到材质上。
    • CubeTexture: 用于环境贴图、天空盒。
    • CanvasTexture, VideoTexture, DepthTexture, DataTexture 等。
    • 封装了纹理的加载、过滤、环绕模式等。
  8. 光源 (Lights) :

    • AmbientLight: 环境光,均匀地照亮场景中的所有物体。
    • DirectionalLight: 平行光,模拟太阳光。
    • PointLight: 点光源,从一个点向所有方向发射光线。
    • SpotLight: 聚光灯,有方向和角度的锥形光源。
    • HemisphereLight: 半球光,模拟天空和地面的反射光。
    • RectAreaLight: 矩形区域光(仅 MeshStandardMaterialMeshPhysicalMaterial 支持)。
    • 封装了光源的颜色、强度、位置、方向、衰减等属性。
  9. 数学库 (Math) :

    • Vector2, Vector3, Vector4: 二维、三维、四维向量。
    • Matrix3, Matrix4: 三阶、四阶矩阵,用于变换。
    • Quaternion: 四元数,用于表示旋转,避免万向节锁。
    • Euler: 欧拉角,另一种表示旋转的方式。
    • Color: 颜色对象。
    • Box3, Sphere, Plane, Ray, Triangle 等几何辅助对象。
    • MathUtils: 提供常用的数学函数,如角度弧度转换、随机数生成、钳制等。
  10. 加载器 (Loaders) :

    • TextureLoader: 加载图像纹理。
    • FileLoader, ImageLoader, ImageBitmapLoader.
    • GLTFLoader: 加载 glTF 和 GLB 格式的 3D 模型(推荐)。
    • OBJLoader, FBXLoader, ColladaLoader 等:加载其他格式的 3D 模型。
    • FontLoader: 加载字体用于 TextGeometry
    • 封装了异步加载资源的过程和解析。
  11. 动画 (Animation) :

    • AnimationClip: 存储动画数据,如关键帧序列。
    • AnimationMixer: 动画混合器,用于播放和控制 AnimationClip
    • KeyframeTrack: 定义特定属性(如位置、旋转、缩放)随时间变化的轨迹。
  12. 辅助对象 (Helpers) :

    • AxesHelper: 显示三维坐标轴。
    • GridHelper: 显示网格平面。
    • CameraHelper, DirectionalLightHelper, PointLightHelper, SpotLightHelper 等:可视化相机和光源的位置及范围。
    • BoxHelper, Box3Helper: 可视化物体的包围盒。
  13. 控制器 (Controls) :

    • OrbitControls: 允许用户通过鼠标交互(旋转、缩放、平移)来控制相机围绕目标点观察。
    • TrackballControls, FlyControls, FirstPersonControls 等。
  14. 后期处理 (Post-processing) :

    • EffectComposer: 用于实现后期处理效果,如模糊、辉光、景深、颜色校正等。通过组合不同的 Pass 来实现。

Three.js 的核心原理是什么?

  1. 基于 WebGL:

    • Three.js 的核心渲染能力来自于 WebGL。它将用户定义的场景、物体、材质、光照等高级概念转换为 WebGL 可以理解的指令,例如:

      • 将几何体数据(顶点、索引、法线、UV)组织成 WebGL 的顶点缓冲对象 (VBO) 和索引缓冲对象 (IBO)。
      • 根据材质和光照类型,动态生成或选择合适的 GLSL 着色器程序 (Vertex Shader 和 Fragment Shader)。
      • 设置着色器的 uniforms (如模型视图投影矩阵、光照参数、纹理采样器等)。
      • 调用 WebGL 的 gl.drawArrays()gl.drawElements() 进行绘制。
  2. 场景图 (Scene Graph) :

    • Three.js 使用场景图来组织 3D 世界中的所有元素。场景图是一个树状结构,Scene 对象是根节点。
    • Object3D 对象可以有子对象,形成层级关系。
    • 当父对象进行变换(平移、旋转、缩放)时,其所有子对象也会相应地进行变换。这是通过矩阵乘法实现的:子对象的最终世界变换矩阵 = 父对象的世界变换矩阵 × 子对象的局部变换矩阵。
    • Object3D 内部维护了 matrix (局部变换矩阵) 和 matrixWorld (世界变换矩阵)。updateMatrixWorld() 方法会递归地更新场景图中所有对象的世界变换矩阵。
  3. 渲染循环 (Render Loop) :

    • 3D 图形通常需要持续不断地重新绘制到屏幕上以创建动画或响应用户交互。这通过渲染循环实现,通常使用 requestAnimationFrame API。

    • 在每一帧:

      1. 更新状态: 更新场景中的物体(如动画、用户输入导致的变换)、相机位置、光照等。
      2. 渲染: 调用 renderer.render(scene, camera)
    • renderer.render(scene, camera) 内部会:

      • 更新相机矩阵 (视图矩阵、投影矩阵)。

      • 遍历场景图中的可见物体。

      • 对于每个物体:

        • 更新其世界变换矩阵 (object.updateMatrixWorld())。
        • 设置 WebGL 的渲染状态(如深度测试、混合模式)。
        • 绑定物体的几何体数据 (VBOs, IBOs)。
        • 绑定材质对应的着色器程序。
        • 传递 uniforms (模型矩阵、视图矩阵、投影矩阵、材质属性、光照信息等) 给着色器。
        • 执行绘制命令。
  4. 材质与着色器 (Materials and Shaders) :

    • 材质决定了物体表面的外观(颜色、纹理、对光的反应等)。
    • Three.js 的每种内置材质内部都对应一套或多套预定义的 GLSL 着色器代码。
    • 例如,MeshPhongMaterial 会使用实现了 Phong 光照模型的着色器。
    • 当使用 ShaderMaterialRawShaderMaterial 时,开发者可以直接提供自定义的 GLSL 代码。
    • 渲染时,材质的属性(如 color, map, metalness, roughness)会作为 uniforms 传递给着色器。
  5. 数据驱动:

    • 几何体由顶点数据(位置、法线、UV、颜色等)定义,这些数据存储在 BufferAttribute 中。
    • 材质的属性也是数据。
    • 这种数据驱动的方式使得更新和操作 3D 对象更加灵活。
  6. 事件与交互:

    • 虽然 Three.js 本身不直接处理 DOM 事件,但它提供了工具如 Raycaster 来实现 3D 空间中的拾取(判断鼠标点击到了哪个物体)。
    • 控制器如 OrbitControls 内部监听鼠标和触摸事件,并据此更新相机的位置和朝向。

如何使用 Three.js?(详细代码讲解)

下面是一个相对详细的示例,展示了 Three.js 的许多核心概念。我们将创建一个场景,包含一些基本物体、光源、纹理、加载一个 GLTF 模型,并使用 OrbitControls 进行交互。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 详细示例</title>
    <style>
        body { margin: 0; overflow: hidden; }
        canvas { display: block; }
    </style>
</head>
<body>
    <!-- Three.js 和相关库将通过 CDN 引入 -->
    <!-- 在实际项目中,你可能会使用 npm 和模块打包工具 -->
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.164.1/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.164.1/examples/jsm/"
            }
        }
    </script>

    <script type="module">
        // 导入 Three.js 核心库
        import * as THREE from 'three';
        // 导入 OrbitControls 用于相机交互
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        // 导入 GLTFLoader 用于加载 GLTF 模型
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        // 导入 RGBELoader 用于加载 HDR 环境贴图 (可选,但能提升 PBR 材质效果)
        import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
        // 导入 dat.GUI 用于创建简单的 UI 控制面板 (可选)
        import { GUI } from 'three/addons/libs/lil-gui.module.min.js';

        // --- 全局变量 ---
        let scene, camera, renderer;
        let controls;
        let cube, sphere, plane;
        let pointLight, spotLight, directionalLight;
        let textureLoader, gltfLoader, rgbeLoader;
        let mixer; // 用于模型动画
        const clock = new THREE.Clock(); // 用于动画和时间相关的更新
        let gui; // dat.GUI 实例

        // --- 初始化函数 ---
        function init() {
            // 1. 创建场景
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x87ceeb); // 天蓝色背景
            // scene.fog = new THREE.Fog(0x87ceeb, 10, 100); // 添加雾效 (可选)

            // 2. 创建相机
            // PerspectiveCamera(fov, aspect, near, far)
            // fov: 视野角度
            // aspect: 宽高比 (通常是渲染区域的宽高比)
            // near: 近裁剪面
            // far: 远裁剪面
            camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
            camera.position.set(5, 8, 15); // 设置相机位置
            camera.lookAt(scene.position); // 相机看向场景原点

            // 3. 创建渲染器
            renderer = new THREE.WebGLRenderer({ antialias: true }); //开启抗锯齿
            renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染尺寸
            renderer.setPixelRatio(window.devicePixelRatio); // 设置设备像素比,以获得更清晰的图像
            renderer.shadowMap.enabled = true; // 开启阴影渲染
            renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 柔和阴影
            // renderer.toneMapping = THREE.ACESFilmicToneMapping; // 色调映射,用于 HDR (可选)
            // renderer.toneMappingExposure = 1.0; // 色调映射曝光度 (可选)
            document.body.appendChild(renderer.domElement); // 将渲染器的 canvas 元素添加到 DOM 中

            // 4. 创建控制器 (OrbitControls)
            controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true; // 启用阻尼效果,使交互更平滑
            controls.dampingFactor = 0.05;
            // controls.autoRotate = true; // 自动旋转 (可选)
            controls.minDistance = 5; // 相机最小距离
            controls.maxDistance = 100; // 相机最大距离
            // controls.maxPolarAngle = Math.PI / 2 - 0.1; // 限制相机垂直旋转角度,防止看到地面以下

            // 5. 添加光源
            // 环境光 (AmbientLight): 无特定方向,均匀照亮场景
            const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // 颜色, 强度
            scene.add(ambientLight);

            // 平行光 (DirectionalLight): 模拟太阳光
            directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
            directionalLight.position.set(10, 15, 5);
            directionalLight.castShadow = true; // 光源产生阴影
            // 设置阴影属性
            directionalLight.shadow.mapSize.width = 2048;
            directionalLight.shadow.mapSize.height = 2048;
            directionalLight.shadow.camera.near = 0.5;
            directionalLight.shadow.camera.far = 50;
            directionalLight.shadow.camera.left = -15;
            directionalLight.shadow.camera.right = 15;
            directionalLight.shadow.camera.top = 15;
            directionalLight.shadow.camera.bottom = -15;
            scene.add(directionalLight);
            // scene.add(new THREE.CameraHelper(directionalLight.shadow.camera)); // 可视化平行光阴影相机

            // 点光源 (PointLight)
            pointLight = new THREE.PointLight(0xffaa00, 2, 50, 1); // 颜色, 强度, 距离, 衰减
            pointLight.position.set(-5, 5, 5);
            pointLight.castShadow = true;
            scene.add(pointLight);
            // scene.add(new THREE.PointLightHelper(pointLight, 1)); // 可视化点光源

            // 聚光灯 (SpotLight)
            spotLight = new THREE.SpotLight(0x00ff00, 5, 100, Math.PI / 6, 0.2, 1); // 颜色, 强度, 距离, 角度, penumbra(半影衰减), 衰减
            spotLight.position.set(8, 10, -5);
            spotLight.target.position.set(0, 0, 0); // 聚光灯目标
            spotLight.castShadow = true;
            scene.add(spotLight);
            scene.add(spotLight.target); // 需要将 target 也加入场景
            // scene.add(new THREE.SpotLightHelper(spotLight)); // 可视化聚光灯

            // 6. 创建物体 (几何体 + 材质 = 网格)
            // 创建一个地面平面
            const planeGeometry = new THREE.PlaneGeometry(50, 50);
            const planeMaterial = new THREE.MeshStandardMaterial({
                color: 0xcccccc,
                side: THREE.DoubleSide, // 双面可见
                roughness: 0.8,
                metalness: 0.2
            });
            plane = new THREE.Mesh(planeGeometry, planeMaterial);
            plane.rotation.x = -Math.PI / 2; // 旋转使其平铺在 xz 平面
            plane.position.y = -0.5; // 向下移动一点,避免与物体底部重叠
            plane.receiveShadow = true; // 平面接收阴影
            scene.add(plane);

            // 创建一个立方体
            const cubeGeometry = new THREE.BoxGeometry(2, 2, 2); // 宽, 高, 深
            const cubeMaterial = new THREE.MeshPhongMaterial({
                color: 0xff0000, // 红色
                shininess: 80,   // 高光强度
                // wireframe: true // 线框模式 (可选)
            });
            cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
            cube.position.set(-3, 1.5, 0);
            cube.castShadow = true; // 立方体投射阴影
            cube.receiveShadow = true;
            scene.add(cube);

            // 创建一个球体
            const sphereGeometry = new THREE.SphereGeometry(1.5, 32, 32); // 半径, 水平分段数, 垂直分段数
            const sphereMaterial = new THREE.MeshStandardMaterial({
                color: 0x0000ff, // 蓝色
                roughness: 0.1,  // 粗糙度
                metalness: 0.9   // 金属度 (使其看起来像金属)
            });
            sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
            sphere.position.set(3, 1.5, 2);
            sphere.castShadow = true;
            sphere.receiveShadow = true;
            scene.add(sphere);

            // 7. 加载纹理 (Texture)
            textureLoader = new THREE.TextureLoader();
            textureLoader.load(
                'https://threejs.org/examples/textures/uv_grid_opengl.jpg', // 纹理图片 URL
                function (texture) { // 加载成功回调
                    // 将纹理应用到立方体的材质上
                    cube.material.map = texture;
                    cube.material.needsUpdate = true; // 通知材质更新
                    console.log("Texture loaded successfully.");
                },
                undefined, // onProgress 回调 (可选)
                function (err) { // 加载错误回调
                    console.error('An error happened during texture loading:', err);
                }
            );

            // 8. 加载 GLTF 模型
            gltfLoader = new GLTFLoader();
            gltfLoader.load(
                'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf', // GLTF 模型 URL
                function (gltf) {
                    const model = gltf.scene;
                    model.scale.set(2, 2, 2); // 缩放模型
                    model.position.set(0, 1, -5);
                    // 遍历模型中的所有网格,使其能够投射和接收阴影
                    model.traverse(function (child) {
                        if (child.isMesh) {
                            child.castShadow = true;
                            child.receiveShadow = true;
                        }
                    });
                    scene.add(model);
                    console.log("GLTF model loaded successfully.");

                    // 如果模型有动画
                    if (gltf.animations && gltf.animations.length) {
                        mixer = new THREE.AnimationMixer(model);
                        const action = mixer.clipAction(gltf.animations[0]); // 播放第一个动画
                        action.play();
                    }
                },
                function (xhr) { // 加载进度回调
                    console.log((xhr.loaded / xhr.total * 100) + '% loaded');
                },
                function (error) { // 加载错误回调
                    console.error('An error happened during GLTF loading:', error);
                }
            );

            // (可选) 加载 HDR 环境贴图 (用于 PBR 材质的环境光照和反射)
            rgbeLoader = new RGBELoader();
            rgbeLoader.load('https://threejs.org/examples/textures/equirectangular/royal_esplanade_1k.hdr', function (texture) {
                texture.mapping = THREE.EquirectangularReflectionMapping;
                // scene.background = texture; // 可以将 HDR 作为背景
                scene.environment = texture; // 应用为环境贴图
                console.log("HDR environment map loaded.");
            });


            // 9. 添加辅助对象 (Helpers)
            const axesHelper = new THREE.AxesHelper(5); // 参数为坐标轴长度
            scene.add(axesHelper);

            const gridHelper = new THREE.GridHelper(50, 50); // 网格尺寸, 网格细分数
            // gridHelper.position.y = -0.51; // 略低于地面,避免与地面重叠闪烁
            scene.add(gridHelper);

            // 10. 添加 GUI 控制 (dat.GUI)
            gui = new GUI();
            const cubeFolder = gui.addFolder('Cube');
            cubeFolder.add(cube.position, 'x', -5, 5).name('Position X');
            cubeFolder.add(cube.position, 'y', -5, 5).name('Position Y');
            cubeFolder.add(cube.position, 'z', -5, 5).name('Position Z');
            cubeFolder.add(cube.rotation, 'x', 0, Math.PI * 2).name('Rotation X');
            cubeFolder.add(cube.material, 'wireframe').name('Wireframe');
            cubeFolder.addColor(cube.material, 'color').name('Color');
            // cubeFolder.open(); // 默认展开

            const lightFolder = gui.addFolder('Lights');
            lightFolder.add(directionalLight, 'intensity', 0, 2).name('Dir Intensity');
            lightFolder.add(pointLight, 'intensity', 0, 10).name('Point Intensity');
            lightFolder.add(spotLight, 'intensity', 0, 10).name('Spot Intensity');
            lightFolder.add(spotLight, 'angle', 0, Math.PI / 2).name('Spot Angle');
            lightFolder.add(spotLight, 'penumbra', 0, 1).name('Spot Penumbra');


            // 11. 监听窗口大小变化事件,实现响应式
            window.addEventListener('resize', onWindowResize, false);
        }

        // --- 窗口大小调整函数 ---
        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight; // 更新相机宽高比
            camera.updateProjectionMatrix(); // 更新相机投影矩阵
            renderer.setSize(window.innerWidth, window.innerHeight); // 更新渲染器尺寸
        }

        // --- 动画循环函数 ---
        function animate() {
            requestAnimationFrame(animate); // 请求下一帧动画

            const delta = clock.getDelta(); // 获取自上一帧以来的时间差

            // 更新物体动画 (示例:让立方体和球体旋转)
            if (cube) {
                cube.rotation.x += 0.01;
                cube.rotation.y += 0.005;
            }
            if (sphere) {
                sphere.rotation.y -= 0.008;
                // 让球体上下浮动
                sphere.position.y = 1.5 + Math.sin(clock.getElapsedTime() * 2) * 0.5;
            }

            // 更新动画混合器 (如果加载的模型有动画)
            if (mixer) {
                mixer.update(delta);
            }

            // 更新控制器 (如果启用了阻尼或自动旋转)
            controls.update();

            // 渲染场景
            renderer.render(scene, camera);
        }

        // --- 启动 ---
        init();
        animate();

    </script>
</body>
</html>

代码讲解:

  1. HTML 结构:

    • 基本的 HTML5 骨架。
    • <style> 用于移除 body 的默认 margin 和隐藏滚动条,确保 canvas 占满视口。
    • <script type="importmap">: 这是现代浏览器支持的一种方式,用于简化 ES6 模块的导入路径。我们为 three 核心库和 three/addons/(包含控制器、加载器等)定义了别名。这样就不需要在每个 import 语句中写完整的 URL。
    • <script type="module">: 我们的 JavaScript 代码将作为 ES6 模块执行。
  2. 导入模块:

    • import * as THREE from 'three';: 导入 Three.js 核心库的所有导出,并将其放入 THREE 命名空间。
    • import { OrbitControls } from 'three/addons/controls/OrbitControls.js';: 从 addons 中导入轨道控制器。
    • import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';: 导入 GLTF 模型加载器。
    • import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';: 导入 HDR 环境贴图加载器。
    • import { GUI } from 'three/addons/libs/lil-gui.module.min.js';: 导入 lil-gui (一个轻量级的 GUI 库,dat.GUI 的替代品)。
  3. 全局变量:

    • scene, camera, renderer: Three.js 的三大核心组件。
    • controls: OrbitControls 实例。
    • cube, sphere, plane: 我们创建的几何物体。
    • pointLight, spotLight, directionalLight: 光源实例。
    • textureLoader, gltfLoader, rgbeLoader: 加载器实例。
    • mixer: 用于 GLTF 模型动画的动画混合器。
    • clock: THREE.Clock 实例,用于获取时间增量,使动画独立于帧率。
    • gui: GUI 实例。
  4. init() 函数 (初始化所有内容) :

    • scene = new THREE.Scene(); : 创建场景。

      • scene.background: 设置场景背景色。
      • scene.fog: (可选) 添加雾效,参数为雾的颜色、近处开始距离、远处完全遮挡距离。
    • camera = new THREE.PerspectiveCamera(...) : 创建透视相机。

      • 参数:fov (视野角度), aspect (宽高比), near (近裁剪面), far (远裁剪面)。
      • camera.position.set(x, y, z): 设置相机在世界坐标系中的位置。
      • camera.lookAt(scene.position): 让相机朝向场景的原点 (0,0,0)。
    • renderer = new THREE.WebGLRenderer({ antialias: true }); : 创建 WebGL 渲染器。

      • antialias: true: 开启抗锯齿,使边缘更平滑。
      • renderer.setSize(): 设置渲染输出的 canvas 尺寸。
      • renderer.setPixelRatio(): 适配高 DPI 屏幕。
      • renderer.shadowMap.enabled = true;: 启用阴影贴图。
      • renderer.shadowMap.type = THREE.PCFSoftShadowMap;: 设置阴影类型为柔和阴影。
      • document.body.appendChild(renderer.domElement);: 将渲染器生成的 <canvas> 元素添加到 HTML body 中。
    • controls = new OrbitControls(camera, renderer.domElement); : 创建轨道控制器。

      • 参数:要控制的 camera 和监听事件的 DOM element
      • controls.enableDamping = true;: 启用阻尼,使拖拽停止后有惯性效果。
      • 其他属性如 minDistance, maxDistance 用于限制缩放范围。
    • 光源 (Lights) :

      • AmbientLight: 提供基础的环境光照,使场景不至于全黑。

      • DirectionalLight: 模拟平行光(如太阳光)。

        • directionalLight.position.set(): 设置光源位置(对于平行光,位置主要决定方向)。
        • directionalLight.castShadow = true;: 使此光源能投射阴影。
        • directionalLight.shadow.mapSize: 设置阴影贴图的分辨率(越高阴影越清晰,但性能开销越大)。
        • directionalLight.shadow.camera: 平行光的阴影是通过一个正交相机来计算的,这里设置该阴影相机的视锥体范围。
      • PointLight: 从一点向四周发光。

        • 参数:颜色, 强度, 距离 (光线能照射到的最大距离,0 表示无限远), 衰减 (物理上通常是 2)。
        • pointLight.castShadow = true;
      • SpotLight: 聚光灯。

        • 参数:颜色, 强度, 距离, angle (光锥角度), penumbra (光锥边缘的半影衰减程度, 0-1), decay (衰减)。
        • spotLight.target.position.set(): 设置聚光灯照射的目标点。spotLight.target 也需要加入场景。
        • spotLight.castShadow = true;
    • 物体 (Objects) :

      • 地面 (Plane) :

        • new THREE.PlaneGeometry(width, height): 创建平面几何体。

        • new THREE.MeshStandardMaterial(...): 创建标准 PBR 材质。

          • color: 材质颜色。
          • side: THREE.DoubleSide: 使平面两面都可见(默认只渲染正面)。
          • roughness, metalness: PBR 材质的关键参数。
        • plane = new THREE.Mesh(planeGeometry, planeMaterial): 合并几何体和材质创建网格。

        • plane.rotation.x = -Math.PI / 2;: 将平面旋转90度,使其水平。

        • plane.position.y = -0.5;: 调整位置。

        • plane.receiveShadow = true;: 使平面能接收其他物体投射的阴影。

      • 立方体 (Cube) :

        • new THREE.BoxGeometry(width, height, depth): 创建立方体几何体。

        • new THREE.MeshPhongMaterial(...): 创建 Phong 材质,能表现高光。

          • shininess: 高光反射强度。
        • cube.castShadow = true;: 使立方体能投射阴影。

        • cube.receiveShadow = true;: (可选) 使立方体也能接收阴影。

      • 球体 (Sphere) :

        • new THREE.SphereGeometry(radius, widthSegments, heightSegments): 创建球体几何体。分段数越多,球体越平滑。
        • new THREE.MeshStandardMaterial(...): 再次使用 PBR 材质,调整 roughnessmetalness 可以得到不同质感。
        • sphere.castShadow = true;
    • 加载纹理 (TextureLoader) :

      • textureLoader = new THREE.TextureLoader();

      • textureLoader.load(url, onLoad, onProgress, onError): 异步加载纹理。

        • onLoad 回调中,将加载的 texture 赋值给 cube.material.map
        • cube.material.needsUpdate = true;: 通知 Three.js 材质已更新,需要重新编译着色器或更新 uniform。
    • 加载 GLTF 模型 (GLTFLoader) :

      • gltfLoader = new GLTFLoader();

      • gltfLoader.load(url, onLoad, onProgress, onError): 异步加载 GLTF 模型。

        • onLoad 回调中,gltf.scene 是加载到的模型根节点 (一个 THREE.GroupTHREE.Object3D)。

        • model.scale.set(): 调整模型大小。

        • model.position.set(): 调整模型位置。

        • model.traverse(...): 遍历模型的所有子节点。如果子节点是 Mesh (child.isMesh),则设置其 castShadowreceiveShadow 属性。

        • scene.add(model);: 将模型添加到场景。

        • 处理动画: 如果 gltf.animations 数组不为空,说明模型带有动画。

          • mixer = new THREE.AnimationMixer(model);: 为该模型创建一个动画混合器。
          • mixer.clipAction(gltf.animations): 获取第一个动画剪辑的控制器 (AnimationAction)。
          • action.play(): 播放动画。
    • 加载 HDR 环境贴图 (RGBELoader) : (可选,但对 PBR 材质效果提升巨大)

      • rgbeLoader = new RGBELoader();
      • 加载 .hdr 格式的图片。HDR 图片能提供更宽广的动态范围光照信息。
      • texture.mapping = THREE.EquirectangularReflectionMapping;: 告诉 Three.js 这是一张全景反射贴图。
      • scene.environment = texture;: 将此 HDR 纹理用作场景的环境贴图。PBR 材质会使用它来进行基于图像的光照 (IBL),从而产生真实的反射和环境光。
    • 辅助对象 (Helpers) :

      • AxesHelper: 在场景原点显示红(X)、绿(Y)、蓝(Z)三色坐标轴,方便调试。
      • GridHelper: 在 XZ 平面显示一个网格,方便感知空间和物体位置。
    • GUI 控制 (lil-gui) :

      • gui = new GUI();: 创建 GUI 实例。

      • gui.addFolder(name): 创建一个可折叠的文件夹。

      • folder.add(object, property, min, max, step).name(displayName): 添加一个控制器。

        • object: 要控制的对象。
        • property: 要控制的属性名 (字符串)。
        • min, max, step: (可选) 对于数值型属性,定义范围和步长。
        • .name(): 设置在 GUI 中显示的名称。
      • folder.addColor(object, property).name(displayName): 添加颜色选择器。

    • 窗口大小调整监听:

      • window.addEventListener('resize', onWindowResize, false);: 当浏览器窗口大小改变时,调用 onWindowResize 函数。
  5. onWindowResize() 函数:

    • 当窗口大小改变时,需要相应地更新相机的宽高比和渲染器的尺寸,以避免场景变形或显示不全。
    • camera.aspect = window.innerWidth / window.innerHeight;
    • camera.updateProjectionMatrix();: 非常重要! 更改相机参数(如 aspect, fov, near, far)后,必须调用此方法来重新计算相机的投影矩阵。
    • renderer.setSize(window.innerWidth, window.innerHeight);
  6. animate() 函数 (渲染循环) :

    • requestAnimationFrame(animate);: 这是创建平滑动画循环的标准方法。浏览器会尝试以每秒 60 帧 (FPS) 的速率调用 animate 函数。

    • const delta = clock.getDelta();: 获取自上次调用 clock.getDelta() 以来经过的时间(秒)。这对于创建与帧率无关的动画非常重要。

    • 更新物体:

      • cube.rotation.x += 0.01;: 简单地让立方体沿 X 轴旋转。
      • sphere.position.y = 1.5 + Math.sin(clock.getElapsedTime() * 2) * 0.5;: 使用 clock.getElapsedTime() (获取自时钟创建以来总共经过的时间) 和 Math.sin() 来让球体上下平滑浮动。
    • 更新动画混合器:

      • if (mixer) { mixer.update(delta); }: 如果存在 mixer (即模型有动画),则调用其 update 方法并传入时间增量 delta,以驱动模型动画的播放。
    • 更新控制器:

      • controls.update();: 如果 OrbitControlsenableDamping 设置为 true,则必须在动画循环中调用 controls.update() 来应用阻尼效果。
    • 渲染:

      • renderer.render(scene, camera);: 最关键的一步,命令渲染器使用指定的相机来渲染指定的场景。
  7. 启动:

    • init();: 调用初始化函数,设置好所有东西。
    • animate();: 启动动画循环。

这个示例覆盖了 Three.js 的许多基本但重要的方面。通过修改其中的参数、尝试不同的几何体、材质、光源和模型,你可以进一步探索 Three.js 的强大功能。记住,官方文档和示例是学习 Three.js 的最佳资源。

计算机图形学三维坐标系统全面解析

一、三维坐标系统基础认知

在计算机图形学的领域中,三维坐标系统是构建虚拟三维空间的重要基石,它借助三个相互垂直的坐标轴来精准定位空间里的每一个点。

(一)常见三维坐标系统分类

  1. 笛卡尔坐标系统
    • 该系统包含三个两两垂直的坐标轴,分别是 X 轴(代表水平方向)、Y 轴(代表垂直方向)和 Z 轴
    • 依据坐标轴方向的不同,又可细分为左手坐标系和右手坐标系。
      • 左手坐标系:伸出左手,让大拇指指向 X 轴正方向,食指指向 Y 轴正方向,那么中指所指方向就是 Z 轴正方向。
      • 右手坐标系:伸出右手,使大拇指指向 X 轴正方向,食指指向 Y 轴正方向,此时中指所指方向为 Z 轴正方向。
  1. 摄像机坐标系统
    • 此系统以虚拟摄像机的视角作为参考,其坐标轴定义如下:
      • X 轴:指向摄像机的右侧。
      • Y 轴:指向摄像机的上方。
      • Z 轴:指向摄像机的前方(也就是视线的方向)。
  1. 局部坐标系统与世界坐标系统
    • 局部坐标系统:每个物体都拥有自身独立的坐标系统,便于对物体进行局部变换操作。
    • 世界坐标系统:是整个场景所使用的全局坐标系统,用于确定所有物体在场景中的绝对位置。

二、三维坐标变换操作

在三维空间中,物体的位置、方向和大小等属性可以通过坐标变换来实现调整,主要的变换类型包括平移、旋转和缩放。

(一)平移变换

平移变换是指将物体沿着某个坐标轴方向进行移动。假设在三维空间中有一个点 ( (x, y, z) ),要将其沿着 X 轴平移 ( t_x ) 个单位,沿着 Y 轴平移 ( t_y ) 个单位,沿着 Z 轴平移 ( t_z ) 个单位,那么平移后的点坐标为 ( (x + t_x, y + t_y, z + t_z) )。

在 JavaScript 中,可以通过以下代码实现平移变换:

function translate(point, tx, ty, tz) {
    return {
        x: point.x + tx,
        y: point.y + ty,
        z: point.z + tz
    };
}
// 示例:将点(1, 2, 3)沿X轴平移2个单位,Y轴平移3个单位,Z轴平移4个单位
const point = { x: 1, y: 2, z: 3 };
const translatedPoint = translate(point, 2, 3, 4);
// 输出结果:{ x: 3, y: 5, z: 7 }

(二)旋转变换

旋转变换是围绕某个坐标轴对物体进行旋转操作,这里我们以绕 X 轴、Y 轴、Z 轴旋转为例进行说明。

  1. 绕 X 轴旋转

假设点 ( (x, y, z) ) 绕 X 轴旋转角度 ( \theta ),旋转后的点坐标计算方式如下:

新的 Y 坐标为 ( y \times \cos\theta - z \times \sin\theta )

新的 Z 坐标为 ( y \times \sin\theta + z \times \cos\theta )

X 坐标保持不变,即仍为 ( x )。

在 JavaScript 中实现绕 X 轴旋转的代码如下:

function rotateX(point, angle) {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return {
        x: point.x,
        y: point.y * cos - point.z * sin,
        z: point.y * sin + point.z * cos
    };
}
// 示例:将点(0, 1, 0)绕X轴旋转90度(π/2弧度)
const point = { x: 0, y: 1, z: 0 };
const rotatedPoint = rotateX(point, Math.PI / 2);
// 输出结果:{ x: 0, y: 0, z: 1 }
  1. 绕 Y 轴旋转

当点 ( (x, y, z) ) 绕 Y 轴旋转角度 ( \theta ) 时,旋转后的点坐标为:

新的 X 坐标为 ( x \times \cos\theta + z \times \sin\theta )

新的 Z 坐标为 ( -x \times \sin\theta + z \times \cos\theta )

Y 坐标不变,为 ( y )。

JavaScript 实现代码如下:

function rotateY(point, angle) {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return {
        x: point.x * cos + point.z * sin,
        y: point.y,
        z: -point.x * sin + point.z * cos
    };
}
// 示例:将点(1, 0, 0)绕Y轴旋转90度(π/2弧度)
const point = { x: 1, y: 0, z: 0 };
const rotatedPoint = rotateY(point, Math.PI / 2);
// 输出结果:{ x: 0, y: 0, z: 1 }
  1. 绕 Z 轴旋转

点 ( (x, y, z) ) 绕 Z 轴旋转角度 ( \theta ) 后,坐标变化为:

新的 X 坐标为 ( x \times \cos\theta - y \times \sin\theta )

新的 Y 坐标为 ( x \times \sin\theta + y \times \cos\theta )

Z 坐标不变,是 ( z )。

JavaScript 代码如下:

function rotateZ(point, angle) {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return {
        x: point.x * cos - point.y * sin,
        y: point.x * sin + point.y * cos,
        z: point.z
    };
}
// 示例:将点(0, 1, 0)绕Z轴旋转90度(π/2弧度)
const point = { x: 0, y: 1, z: 0 };
const rotatedPoint = rotateZ(point, Math.PI / 2);
// 输出结果:{ x: -1, y: 0, z: 0 }

(三)缩放变换

缩放变换用于改变物体的大小。对于点 ( (x, y, z) ),分别沿着 X 轴、Y 轴、Z 轴进行缩放,缩放因子为 ( s_x )、( s_y )、( s_z ),缩放后的点坐标为 ( (x \times s_x, y \times s_y, z \times s_z) )。

JavaScript 实现代码如下:

function scale(point, sx, sy, sz) {
    return {
        x: point.x * sx,
        y: point.y * sy,
        z: point.z * sz
    };
}
// 示例:将点(2, 2, 2)在三个轴上都缩放2倍
const point = { x: 2, y: 2, z: 2 };
const scaledPoint = scale(point, 2, 2, 2);
// 输出结果:{ x: 4, y: 4, z: 4 }

三、三维坐标系统的实际应用场景

(一)三维建模领域

在三维建模软件中,如 Blender、Maya 等,设计师借助三维坐标系统来精确确定模型中每个顶点的位置,通过对顶点进行平移、旋转和缩放等操作,构建出复杂的三维模型。

(二)游戏开发领域

在游戏开发过程中,三维坐标系统用于确定游戏角色、场景物体以及摄像机的位置和方向。例如,通过对游戏角色进行平移变换使其在场景中移动,通过旋转变换改变角色的朝向,通过缩放变换实现角色的变大或变小等效果。

(三)虚拟现实(VR)和增强现实(AR)领域

在 VR 和 AR 应用中,三维坐标系统至关重要。它用于跟踪用户的头部和手部运动,并将这些运动转换为虚拟环境中的坐标变换,从而为用户带来沉浸式的体验。例如,当用户转动头部时,系统通过摄像机坐标系统的变换来更新虚拟场景的视角。

四、总结

三维坐标系统是计算机图形学的核心概念之一,平移、旋转和缩放等坐标变换操作是实现三维图形效果的基础。通过深入理解三维坐标系统及其变换原理,并结合 JavaScript 等编程语言进行实践,能够更好地在计算机图形学领域进行开发和创作。在实际应用中,需要根据具体的场景选择合适的坐标系统和变换方式,以实现预期的图形效果。

以上是关于计算机图形学三维坐标系统的教学内容。你对这篇文章的内容深度、案例选择等方面有什么看法或进一步需求,欢迎随时告诉我。

Three.js 中计算两个物体之间的距离

在 3D 场景开发中,计算两个物体之间的距离是常见需求。无论是实现碰撞检测、AI 行为逻辑,还是创建视觉特效,距离计算都是基础且关键的功能。本文将详细介绍在 Three.js 中如何计算两个物体之间的距离。

基本概念

在 Three.js 中,物体之间的距离通常指的是它们位置 (position) 之间的欧几里得距离。每个 Three.js 对象都有一个 position 属性,它是一个 Vector3 实例,表示该对象在 3D 空间中的坐标 (x, y, z)。

计算距离的方法

Three.js 的 Vector3 类提供了多种计算距离的方法:

  1. distanceTo () - 计算当前向量到另一个向量的距离
  1. distanceToSquared () - 计算距离的平方 (性能更好,适用于比较距离大小的场景)

下面是一个简单的示例,展示如何使用这些方法:

// 假设我们有两个Three.js对象
const object1 = new THREE.Mesh(geometry, material);
const object2 = new THREE.Mesh(geometry, material);
// 设置它们的位置
object1.position.set(10, 5, 0);
object2.position.set(4, 1, 0);
// 计算它们之间的距离
const distance = object1.position.distanceTo(object2.position);
console.log('两个物体之间的距离是:', distance); // 输出约为7.21
// 如果只需要比较距离大小,可以使用distanceToSquared()
const distanceSquared = object1.position.distanceToSquared(object2.position);
console.log('距离的平方是:', distanceSquared); // 输出约为52

应用示例:距离检测系统

下面是一个完整的示例,展示如何实现一个简单的距离检测系统。当两个物体之间的距离小于某个阈值时,我们会改变它们的颜色。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 距离检测示例</title>
    <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
</head>
<body>
    <script>
        // 创建场景、相机和渲染器
        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        const renderer = new THREE.WebGLRenderer();
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);
        // 创建两个立方体
        const geometry = new THREE.BoxGeometry(1, 1, 1);
        
        // 物体1 - 红色立方体
        const material1 = new THREE.MeshBasicMaterial({ color: 0xff0000 });
        const object1 = new THREE.Mesh(geometry, material1);
        object1.position.set(-3, 0, 0);
        scene.add(object1);
        
        // 物体2 - 蓝色立方体
        const material2 = new THREE.MeshBasicMaterial({ color: 0x0000ff });
        const object2 = new THREE.Mesh(geometry, material2);
        object2.position.set(3, 0, 0);
        scene.add(object2);
        // 添加坐标轴辅助
        const axesHelper = new THREE.AxesHelper(5);
        scene.add(axesHelper);
        // 设置相机位置
        camera.position.z = 5;
        // 距离阈值
        const distanceThreshold = 4;
        // 创建一个标签显示距离
        const distanceLabel = document.createElement('div');
        distanceLabel.style.position = 'absolute';
        distanceLabel.style.top = '10px';
        distanceLabel.style.left = '10px';
        distanceLabel.style.color = 'white';
        distanceLabel.style.fontFamily = 'Arial';
        distanceLabel.style.fontSize = '16px';
        document.body.appendChild(distanceLabel);
        // 动画循环
        function animate() {
            requestAnimationFrame(animate);
            // 让物体2向物体1移动
            object2.position.x -= 0.01;
            
            // 如果物体2移动到左边太远,则重置位置
            if (object2.position.x < -5) {
                object2.position.x = 3;
            }
            // 计算两个物体之间的距离
            const distance = object1.position.distanceTo(object2.position);
            
            // 更新标签显示
            distanceLabel.textContent = `距离: ${distance.toFixed(2)} (阈值: ${distanceThreshold})`;
            // 当距离小于阈值时,改变颜色
            if (distance < distanceThreshold) {
                object1.material.color.set(0x00ff00);
                object2.material.color.set(0x00ff00);
            } else {
                object1.material.color.set(0xff0000);
                object2.material.color.set(0x0000ff);
            }
            renderer.render(scene, camera);
        }
        animate();
    </script>
</body>
</html>

在这个示例中,我们创建了两个立方体,一个红色一个蓝色。蓝色立方体会自动向红色立方体移动,我们实时计算它们之间的距离并显示出来。当距离小于设定的阈值时,两个立方体都会变成绿色。

性能优化

在处理大量对象的场景中,计算每个对象之间的距离可能会影响性能。以下是一些优化建议:

  1. 使用 distanceToSquared () 代替 distanceTo (),避免开平方运算
  1. 实现空间分区算法 (如八叉树) 来减少需要计算距离的对象数量
  1. 限制距离计算的频率,不必每帧都计算

更复杂的距离计算

在某些情况下,你可能需要计算更复杂的距离,比如:

  1. 从一个点到一个物体表面的距离
  1. 两个物体边界框之间的距离
  1. 两个物体碰撞体之间的最小距离

对于这些情况,Three.js 提供了 Box3、Sphere 等类,它们也有类似的 distanceTo 方法。你还可以使用 Raycaster 来计算点到物体表面的距离。

通过掌握这些技术,你可以在 Three.js 中实现各种复杂的交互和效果,从简单的距离提示到高级的物理模拟。

nginx流量按比例转发方案对比

Nginx实现流量按比例转发有多种方式,以下是基于最新技术文档的详细配置方法及适用场景解析:

一、基于 split_clients 指令的哈希比例分配

核心原理 通过客户端IP或其他变量计算哈希值,按预设比例将请求分发到不同服务器组。 配置示例

split_clients $remote_addr $version { 
   70% backend1; # 70%流量转发至backend1 
   30% backend2; # 30%流量转发至backend2 
} 
upstream backend1 { server 192.168.1.1:80; } 
upstream backend2 { server 192.168.1.2:80; } 
server { 
    location / { proxy_pass http://$version; } 
}

特点

  • 结果稳定:相同客户端的请求始终分配到同一后端。
  • 适用场景:需要严格按比例分流(如A/B测试、灰度发布)。

二、利用 weight 参数的加权轮询

核心原理upstream 块中为不同服务器设置权重,按权重比例分发请求。 配置示例

upstream backend { 
  server backend1.example.com weight=7; # 接收70%流量 
  server backend2.example.com weight=3; # 接收30%流量 
} 
server { 
  location / { proxy_pass http://backend; } 
}

特点

  • 动态分配:实际分发比例受服务器响应速度和当前负载影响。

  • 适用场景:服务器性能差异较大时(如高配置服务器分配更高权重)。

三、通过 Lua 脚本实现随机比例控制

核心原理 使用Nginx的Lua模块生成随机数,自定义分发逻辑。 配置示例

location / { 
  access_by_lua_block { 
    math.randomseed(os.time()) 
    if math.random(1,10) <= 7 then 
        ngx.var.proxy_pass = "http://backend1" 
    else 
        ngx.var.proxy_pass = "http://backend2" 
    end 
  } 
  proxy_pass $proxy_pass; 
}

特点

  • 高度灵活:支持复杂逻辑(如动态比例调整)。
  • 依赖模块:需安装 ngx_http_lua_module
  • 适用场景:需实时调整流量比例或结合业务规则分发。

四、其他相关策略对比

方法 分配依据 流量稳定性 扩展性 适用场景
split_clients 客户端哈希 高(固定分配) 中等 精准比例分流
weight 参数 权重轮询 中(动态调整) 服务器性能不均
Lua 脚本 自定义逻辑 低(随机性强) 极高 复杂规则分发
ip_hash 客户端IP哈希 会话保持需求 7 19

五、关键注意事项

  1. 路径处理: proxy_pass 后是否加 / 影响URI转发(如 /test/ 转发为 / 或保留路径)。
  2. 配置重载: 修改后需执行 nginx -s reload 使配置生效。
  3. 容灾策略: 可结合 max_fails 和 backup 参数实现故障转移。

参考文献

  1. split_clients加权分流配置 
  2. Nginx官方负载均衡文档
  3. 权重与容灾策略详解

关于google docs的协同编辑

前言

在做云IDE协同编辑器时,其中最重要的功能之一就是多用户协同编辑多用户光标协同等。协同编辑在当下已经非常普遍了,例如金山的wps腾讯文档等。之前因为工作太忙没时间系统性的学习协同,最近刚好有时间,接下来我预计写4篇文档来系统性学习如何实现编辑器协同能力

  1. 关于google docs的协同编辑
  2. OT算法的实现与实现简单的OT协同应用
  3. CRDT协同算法、CRDT与OT的差异性
  4. 协同的一些问题(如何用户如何进行历史文档回退、等)

如下就是云IDE协同编辑器paas平台的光标协同预览,在用户2中能够清晰的看到AKclown用户所在的编辑位置 image.png

旧版本Google Docs协作

旧版本的Goole Docs和许多其他协作文档处理器采用的是文档版本比较机制。假设现在有两个编辑用户AK和clown(两个客户端都已经同步到最新的服务端文档状态)。当服务端接收到AK文档更新,服务端会找其版本与AK版本之间的差异,并且确定如何尽可能的合并这两个版本。然后服务端再把合并之后的版本更新发送至clown客户端,如果clown存在未发送给服务端的变更版本,那么他需要做服务端版本和本地版本的比较以及合并两个版本,然后clown再将合并后的本地版本推送到服务端,反复执行即可

通常这种方式实现的效果不佳,请看如下例子。AK、clown和服务器起始文本为The quick brown fox,AK加粗brown fox同时clown将单词fox改为dog。假设AK的更改操作先到达服务器然后服务端再把该更改发送至clown

7fe955b2-1679-4645-8f44-c543d3dc2671.png

上述AK和clown正确的合并答案为The quick brown dog。由于合并算法没有足够的信息进行正确合并,因此下面三种情况都是合理的“The quick brown fox dog”、“The quick brown dog”、“The quick brown dog fox”

问题也出在这里: 如果只是比较文档版本,就无法确保更改的合并符合编辑者的预期因此我们需要抛弃这种方案。

我们也可以对编辑器引入更多的限制来避免合并问题,例如,追加锁段落以便同一时间只允许一个编辑器编辑一个段落,但是这违背了协同编辑的原始需求,体验层面也要大个差。

新版的Google Doc的协作

从上一节我们了解了单纯靠文档版本比较机制,会出现由于没有足够的操作信息导致无法正确的合并更改。因此新版的Google Doc采用了另外一种方式: 将文档存储为一系列按时间排序的操作更改(operate)。operate类似于insert [10, 'T']标识在文档的位置10插入字符串T。新旧Google doc的区别在于: 不再是通过比较文档版本来计算更改,而是通过向前推进operate的历史来计算更改。这种方式使得编辑者的意图变得清晰,由于我们知道每一项的修订版本,我们可以检查编辑者在进行该变更时看到的内容,并找出如何正确地将该变更与此后所做的任何变更合并

将文档的历史视为一系列的changes,在Google Doc中所有的changes归结于三种基础类型:

  1. inserting text (插入文本)
  2. deleting text (删除文本)
  3. applying styles (更新样式)

当然changes类型可以根据自己的编辑器能力进行扩展,例如update text[0-5,‘hello’] 替换位置0-5为hello

16226064-a5ca-4e91-9e88-dbeb83cd5a41.png
当我们编辑文档时,所有的changes都会以这三种之一的形式追加到文档的修订日志中,当我们打开文档时,文档会从头开始重播这些修订日志直至最新 (可以理解为回放功能,例如起始文档为A,经过B、C的cahnges得到最终D,那么我们知道了起始文档状态A,修订日志也记录了更新过程B、C,是不是最终通过重播我们也就能得到D呢?)
7d57121b-8426-4ee8-86c4-ac7416dc348c.png
接下来看一个例子:

假设AK和clown编辑文档的最初状态为: easy as 123 。如果AK将文档变更为easy as ABC,那么AK的更改分解为如下四步骤:

28982d18-ed10-4ec2-b67d-fa659a817aab.png

在同一时间clown用户在文档的0-1位置添加了it字符串

6f6c7df9-d1d4-4f3f-a6e3-fc72d325ae9b.png

假设AK的del [8-10]操作被clown直接应用了,那么会删除错误的字符串s 1而不是123208a4004-1788-4687-a4ce-99ef3c418fbe.png
原因是:因为ak的本地文档跟clown的本地文档版本不一致,因此需要转换del [8-10]操作使其相对于clown的本地文档。在这种情况下,当clown收到了AK的更改操作时,该更改操作需要知道向后移动两个字符以适应clown在0-1添加it字符。这里的转换算法就是OT(操作转换)。文档的中的OT逻辑必须要处理当前文档下 changes的所有操作类型,例如insertTextdeleteTextapplyText每一种类型操作转换方式都有所不同 09eba89a-a108-4c2a-915f-ac610a0f0f37.png
通过OT转换将他人的changes转换为符合自身本地的changes并应用到本地,最终就实现了所有编辑者的文档最终保持一致。

当changes类型之间不冲突则无需进行OT转换,假设AK给文档字符串进行加粗applyText [bold, 0-10],而clown给文档字符串设置字体颜色为红色applyText [font-color=red, 0-10],虽然范围都是0到10但是两种操作并不冲突因此无需转换,直接应用即可 707f66b5-3002-416b-a411-0bb6426c5cd2.png
Google docs协作将changes从编辑者发送到服务器中,然后再通过服务区广播给在线的其他编辑者,每个编辑者会通过OT算法转换传入changes,使该changes符合本地文档版本

Google Docs协作协议的工作原理

每个客户端维护如下信息

  1. 最后同步修订(id)
  2. 所有尚未发送到服务器的本地更改(待处理的更改)
  3. 所有本地更改已发送到服务器但尚未被确认(已发送更改)
  4. 当前文档状态对用户可见

中央服务端维护如下信息

  1. 所有已收到但尚未处理的更改列表(待处理的更改)
  2. 所有已处理变更的日志(修订日志)
  3. 上次处理变更时文档的状态

接下来通过一个例子来描述协作的工作原理,假设现在有AKclown两个用户在一个空文档开始协同编辑文档

用户AK在文档0的位置插入字符串hello,此更改会被加入到本地待处理更改的队列中,然后再发送到服务端,服务端接收这一次操作将该操作信息(上一次同步修订号、客户端的唯一标识、操作的数据信息)加入到服务端待处理的队列中也将此更改移至已发送更改的队列中。 1.png
紧接着,客户端AK输出“world”与此同时客户端clown在他的空文档中输入“!”(因为此时的客户端clown还没有接收到AK的改动因此文档为空)

客户端AK插入在文档5的位置添加"world"字符串,此更改会被添加到待更新的队列中但还未发送到服务端中,因为AK客户端上一次的更改尚未得到确定并且我们一次只能发送一次更改。另外服务端已处理AK的第一次更改操作将其移至修订日志中,以及clown客户端在空文档插入!字符串,该操作会被添加到clown客户端的待更新队列中并且该操作未发送到服务端中。

2.png

服务端处理了客户端AK的第一次更改并将其移至修订日志中,然后服务端将向客户端AK发送确定事件,并且将客户端AK的更改通过广播的形式同步到客户端clown中。

3.png

客户端clown收到客户端AK的更改操作并对此更改应用转换函数,通过OT转换函数将待处理更改中的字符串!索引从0移至到5。同时AK和clown都将最后同步修订更新为1,客户端AK将已发送的队列中删除了第一次的更改操作

接下来, AK 和 clown同时将未发送的更改发送到服务器中

4.png

服务端先接收到AK的变更,因此会优先对其进行处理以及向AK发送确定事件,并且会将AK的变更操作发送至clown客户端中,clown在使用OT转换函数对本地变更进行转换将其本地的变更索引移至11的位置

接下来是一个重要的时刻,服务端开始处理clown的更改操作,但是由于clown的修订版本ID已经过期了(实际为2,现在为1),服务端会根据clown尚未知晓的所有更改操作(这里即为AK更改操作 insert [5, ‘world’]),通过OT转换函数来转换他的更改,并将其保存为修订版本2

5.png

总结

通过上述内容,我们对协同编辑有了大致清晰的理解了。协同编辑的核心就是OT算法的实现,changes类型定义冲突合并中央服务器分发changes数据库存储各个用户changes等这些问题将在之后进行解答,希望对你有所帮助,learning together

参考文献

协同编辑原理与实践
CRDT 实时协作技术在稿定编辑器中的应用
how-figmas-multiplayer-technology-works
Operational Transformations as an algorithm for automatic conflict resolution
What’s different about the new Google Docs: Working together, even apart
What’s different about the new Google Docs: Conflict resolution
What’s different about the new Google Docs: Making collaboration fast

Vue.mixin 实现原理与风险解析

Vue.mixin 实现原理与风险解析

基于源码:src/core/global-api/mixin.jssrc/core/util/options.js

1. 文件作用与背景

Vue.mixin 是 Vue 提供的全局混入(Global Mixin)API,可以将一组选项(如生命周期、方法、data、computed 等)全局注入到所有组件中。其底层依赖 mergeOptions 实现选项的合并。

2. 主要结构与实现流程

2.1 mixin.js 源码解析

export function initMixin (Vue: GlobalAPI) {
  Vue.mixin = function (mixin: Object) {
    this.options = mergeOptions(this.options, mixin)
    return this
  }
}
  • 通过 Vue.mixin 注册的 mixin,会和全局的 Vue.options 合并。
  • 之后所有通过 Vue.extend 或 new Vue 创建的组件,都会继承这些全局 mixin 的内容。

2.2 mergeOptions 合并机制

export function mergeOptions (parent, child, vm) {
  // ...
  if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }
  // ...
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}
  • 支持递归合并 extendsmixins 字段。
  • 合并策略(strats)根据不同字段(如 data、methods、生命周期钩子)采用不同的合并方式。
  • 生命周期钩子会合并为数组,依次执行。
  • methods、computed、props 等会合并为对象,后定义的会覆盖前面的同名属性。

3. Vue.mixin 的典型用法

Vue.mixin({
  created() {
    console.log('全局 mixin created')
  },
  methods: {
    hello() { console.log('hello from mixin') }
  }
})

// 之后所有组件都会有 hello 方法和 created 钩子

4. 实际项目中可能出现的难以发现的 bug

4.1 命名冲突与覆盖

  • 问题:如果 mixin 和组件本身有同名的 data、methods、computed、props,组件内的会覆盖 mixin 的,但生命周期钩子会全部执行。
  • 风险
    • 组件和 mixin 的 methods/props 重名,可能导致 mixin 的逻辑被无声覆盖。
    • 生命周期钩子混杂,执行顺序难以预期。
  • 示例
    Vue.mixin({
      methods: { save() { /* ... */ } }
    })
    // 某组件也有 save 方法,会覆盖 mixin 的 save
    

4.2 data 数据污染

  • 问题:mixin 的 data 必须是函数,否则所有组件实例会共享同一个对象,导致数据互相污染。
  • 风险
    • 不小心写成对象,所有组件实例的数据会互相影响。
  • 示例
    Vue.mixin({
      data: { count: 0 } // 错误写法
    })
    // 正确写法
    Vue.mixin({
      data() { return { count: 0 } }
    })
    

4.3 生命周期混乱

  • 问题:全局 mixin 的生命周期钩子会注入到所有组件,可能导致所有组件都执行某些逻辑。
  • 风险
    • 某些只想在特定组件执行的逻辑被全局执行,带来副作用。
    • 多个 mixin/组件钩子顺序难以追踪。
  • 示例
    Vue.mixin({
      mounted() { console.log('全局 mounted') }
    })
    // 每个组件 mounted 时都会执行
    

4.4 隐式依赖与调试困难

  • 问题:全局 mixin 让组件的行为变得"隐式",难以追踪和调试。
  • 风险
    • 组件行为受全局 mixin 影响,阅读组件代码时难以发现。
    • 多人协作时,团队成员可能不知道全局 mixin 的存在。

5. 规避建议

  • 谨慎使用全局 mixin,优先使用局部 mixin 或组合式 API。
  • mixin 的 data 必须返回函数,避免数据污染。
  • mixin 内方法、属性命名要有前缀或命名空间,减少冲突。
  • 全局 mixin 只做通用、无副作用的增强(如全局日志、埋点等)。
  • 团队需有统一规范,文档注明全局 mixin 的内容和影响。

6. 总结

Vue.mixin 提供了强大的全局扩展能力,但也带来了隐式依赖、命名冲突、数据污染等风险。理解其实现原理和合并机制,有助于在实际项目中安全、合理地使用 mixin,避免难以发现的 bug。

Vue 全局API初始化分析 (global-api/index.js)

Vue 全局API初始化分析 (global-api/index.js)

文件概述

global-api/index.js 文件是 Vue 框架中负责初始化全局 API 的核心模块。此文件通过 initGlobalAPI 函数为 Vue 构造函数挂载各种全局方法和属性,使开发者能够使用 Vue 提供的各种全局功能。

核心源码分析

export function initGlobalAPI (Vue: GlobalAPI) {
  // config
  const configDef = {}
  configDef.get = () => config
  if (process.env.NODE_ENV !== 'production') {
    configDef.set = () => {
      warn(
        'Do not replace the Vue.config object, set individual fields instead.'
      )
    }
  }
  Object.defineProperty(Vue, 'config', configDef)

  // exposed util methods.
  // NOTE: these are not considered part of the public API - avoid relying on
  // them unless you are aware of the risk.
  Vue.util = {
    warn,
    extend,
    mergeOptions,
    defineReactive
  }

  Vue.set = set
  Vue.delete = del
  Vue.nextTick = nextTick

  // 2.6 explicit observable API
  Vue.observable = <T>(obj: T): T => {
    observe(obj)
    return obj
  }

  Vue.options = Object.create(null)
  ASSET_TYPES.forEach(type => {
    Vue.options[type + 's'] = Object.create(null)
  })

  // this is used to identify the "base" constructor to extend all plain-object
  // components with in Weex's multi-instance scenarios.
  Vue.options._base = Vue

  extend(Vue.options.components, builtInComponents)

  initUse(Vue)
  initMixin(Vue)
  initExtend(Vue)
  initAssetRegisters(Vue)
}

功能模块分析

1. 配置对象定义 (Vue.config)

const configDef = {}
configDef.get = () => config
if (process.env.NODE_ENV !== 'production') {
  configDef.set = () => {
    warn(
      'Do not replace the Vue.config object, set individual fields instead.'
    )
  }
}
Object.defineProperty(Vue, 'config', configDef)

关键设计

  • 使用 Object.defineProperty 定义 Vue.config 对象
  • 创建一个只读属性:getter 返回配置对象,而 setter 在开发环境下会发出警告
  • 防止开发者直接替换整个配置对象,指导正确的修改方式是设置单个字段

用途

  • 提供全局配置入口,如 Vue.config.productionTip = false
  • 确保配置对象的完整性和一致性

2. 工具方法暴露 (Vue.util)

Vue.util = {
  warn,
  extend,
  mergeOptions,
  defineReactive
}

设计意图

  • 暴露内部工具方法供高级用户使用
  • 明确注释这些不是公共 API 的一部分,使用时需自担风险
  • 提供一些核心功能如合并选项、定义响应式属性等

3. 全局实用方法 (Vue.set, Vue.delete, Vue.nextTick)

Vue.set = set
Vue.delete = del
Vue.nextTick = nextTick

功能说明

  • Vue.set:用于向响应式对象添加新属性并确保新属性也是响应式的
  • Vue.delete:用于删除对象的属性并触发视图更新
  • Vue.nextTick:在下次 DOM 更新循环结束后执行延迟回调

这些方法解决了 Vue 响应式系统的一些局限性,特别是对象属性的动态添加和删除。

4. 可观察 API (Vue.observable)

Vue.observable = <T>(obj: T): T => {
  observe(obj)
  return obj
}

特点

  • 2.6 版本新增的显式 API
  • 将一个普通对象转换为响应式对象
  • 不需要通过 Vue 实例就能创建响应式数据
  • 返回原始对象的引用,方便链式调用

5. 全局选项初始化 (Vue.options)

Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
  Vue.options[type + 's'] = Object.create(null)
})

Vue.options._base = Vue

extend(Vue.options.components, builtInComponents)

关键实现

  • 创建无原型的空对象作为 Vue.options
  • 为每种资源类型(组件、指令、过滤器)创建存储容器
  • 设置 _base 属性指向 Vue 构造函数本身,用于组件继承
  • 注册内置组件(如 keep-alive)到全局组件注册表

作用

  • 为全局注册的资源提供存储
  • 作为所有 Vue 实例选项的"基类",通过原型继承传递给所有组件

6. 初始化模块化功能

initUse(Vue)
initMixin(Vue)
initExtend(Vue)
initAssetRegisters(Vue)

模块化设计

  • initUse:初始化插件系统 Vue.use()
  • initMixin:初始化混入系统 Vue.mixin()
  • initExtend:初始化组件继承 Vue.extend()
  • initAssetRegisters:初始化资源注册方法(Vue.component(), Vue.directive(), Vue.filter()

每个初始化函数负责一个特定的功能域,保持了代码的模块化和清晰结构。

设计特点与最佳实践

1. 防御性编程

Vue 在 config 对象上使用了 getter/setter 并添加警告,这是典型的防御性编程示例,保护关键对象不被错误修改。

2. 模块化组织

通过将不同功能的初始化拆分到单独的模块中(use.js, mixin.js 等),Vue 实现了良好的代码组织,便于维护和理解。

3. 渐进式设计

全局 API 的设计体现了 Vue 的渐进式理念:

  • 核心功能(set, delete, nextTick)直接挂载在 Vue 上
  • 高级功能(extend, mixin)通过单独初始化提供
  • 内部工具通过 Vue.util 暴露给高级用户,但有使用风险警告

4. 资源管理

通过在 Vue.options 中创建资源容器,Vue 建立了一个全局注册表系统,为组件、指令和过滤器提供统一的管理机制。

总结

initGlobalAPI 函数是 Vue 全局功能的入口点,通过它 Vue 构造函数获得了丰富的全局方法和属性。这些 API 为开发者提供了强大的工具,从基本的响应式数据操作到高级的组件继承和插件系统。

通过分析这段代码,我们可以看到 Vue 框架在 API 设计上的优雅和周到考虑,既保持了简洁易用的表面,又提供了强大灵活的内部机制,充分体现了 Vue 作为渐进式框架的设计哲学。

Vue 状态初始化分析 (state.js)

Vue 状态初始化分析 (state.js)

文件概述

state.js 是 Vue 中负责处理组件状态初始化的核心文件,它定义了 Vue 实例中各种状态选项的初始化过程。理解这个文件的实现对掌握 Vue 的响应式系统和组件设计至关重要。

主要功能

  • props 初始化:处理父组件传递的属性
  • methods 初始化:初始化组件的方法
  • data 初始化:处理组件的内部状态数据
  • computed 初始化:设置计算属性和其依赖追踪
  • watch 初始化:建立数据观察机制

文件引入的关键依赖

import config from '../config'                   // Vue 全局配置
import Watcher from '../observer/watcher'        // 依赖观察者类
import Dep, { pushTarget, popTarget } from '../observer/dep'  // 依赖收集器
import { isUpdatingChildComponent } from './lifecycle'  // 子组件更新状态检查

import {
  set,                 // Vue.$set 实现
  del,                 // Vue.$delete 实现
  observe,             // 将对象转为响应式的核心方法
  defineReactive,      // 定义响应式属性的方法
  toggleObserving      // 控制是否应配置响应式的开关
} from '../observer/index'

// ... 其他工具函数引入

代码组织结构

文件按照以下逻辑组织:

  1. 辅助函数定义:如 proxy 函数、共享属性定义等
  2. 各种状态的初始化函数:initProps、initData、initMethods 等
  3. 状态相关的实例方法定义datadata、props、setset、delete、$watch 等
  4. 辅助工具:如 getData、defineComputed 等

核心函数分析

1. initState 函数 - 状态初始化的总入口

export function initState (vm) {
  vm._watchers = []  // 存储当前实例的所有 watcher
  const opts = vm.$options  // 获取实例的选项
  
  // 按特定顺序初始化不同的状态
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}
代码解析
  1. 初始化 watcher 容器

    • vm._watchers = [] 创建一个数组用于存储当前实例的所有观察者对象
    • 这些 watcher 将在组件销毁时被清理,防止内存泄漏
  2. 获取实例选项

    • const opts = vm.$options 获取合并后的组件选项
  3. 按顺序初始化各种状态

    • 遵循 props -> methods -> data -> computed -> watch 的严格顺序
    • 这个顺序设计体现了 Vue 数据流的设计哲学:从外部传入 -> 内部方法 -> 内部数据 -> 派生数据 -> 数据监听
初始化顺序的重要性

Vue 的初始化顺序经过精心设计,确保各种数据和方法在需要时已经可用:

  1. props 优先

    • 作为外部数据源,需要最先初始化
    • 确保内部 data 和 methods 可以访问到最新的 props 值
    • 示例:methods 中可能会使用 this.propName
  2. methods 次之

    • 方法需要在数据初始化前完成,因为:
      • data() 函数中可能调用实例方法
      • computed 属性可能依赖这些方法
    • 示例:data() { return { value: this.calculateDefault() } }
  3. data 再次

    • 内部状态数据初始化
    • 在 computed 和 watch 之前,因为它们可能依赖 data 中的值
    • 示例:computed: { fullName() { return this.firstName + ' ' + this.lastName } }
  4. computed 接着

    • 依赖 props、methods 和 data,所以在它们之后初始化
    • 需要在 watch 之前,因为 watch 可能监听计算属性
    • 示例:watch: { fullName(val) { console.log('Name changed:', val) } }
  5. watch 最后

    • 可能观察以上任何数据源,所以最后初始化
    • 确保被监听的数据已经完全设置好
    • 示例:同时监听 props、data 和 computed 的变化

2. proxy 函数 - 属性代理的核心实现

const sharedPropertyDefinition = {
  enumerable: true,    // 可枚举
  configurable: true,  // 可配置
  get: noop,           // 初始为空函数
  set: noop            // 初始为空函数
}

export function proxy (target, sourceKey, key) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]  // 从源对象读取值
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val   // 设置值到源对象
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)  // 定义属性
}
代码分析
  1. 共享属性描述符

    • sharedPropertyDefinition 是一个共享的属性描述符对象
    • enumerable: true:属性可在循环中枚举(如 for...in)
    • configurable: true:属性可被删除或修改
    • get/set: noop:初始为空函数,将被具体实现替换
  2. proxy 函数作用

    • 作为一个属性代理工具,将嵌套属性表面化
    • 目标:让用户可以通过 vm.xxx 代替 vm._data.xxx 访问数据
    • 实质:使用 Object.defineProperty 定义属性拦截器
  3. 参数解释

    • target:目标对象,通常是 Vue 实例 vm
    • sourceKey:源属性键,如 _data_props
    • key:目标属性键,要代理的具体属性名
  4. 代理实现

    • 为目标对象上的每个属性定义 getter/setter
    • getter 从源对象中读取值:this[sourceKey][key]
    • setter 将值设置到源对象:this[sourceKey][key] = val
实际应用示例
// 代理前访问数据
vm._data.message = 'Hello'
console.log(vm._data.message)

// 代理后直接访问
vm.message = 'Hello' 
console.log(vm.message)
代理的好处
  1. 简化访问

    • 不需要通过 _data_props 访问,使 API 更简洁
    • 符合用户直觉,降低使用门槛
  2. 数据访问一致性

    • props、data、computed 等不同来源的数据都能以同样方式访问
    • 屏蔽了内部实现,提供统一接口
  3. 保持私有属性隔离

    • 内部状态(如 _data)仍然可以作为私有存储
    • 可选择性地只暴露需要的属性

3. initProps 函数 - Props 初始化实现

function initProps (vm, propsOptions) {
  const propsData = vm.$options.propsData || {}  // 父组件传入的实际prop值
  const props = vm._props = {}                   // 存储props的内部对象
  
  // 缓存prop键,用于优化后续更新
  const keys = vm.$options._propKeys = []
  
  // 判断是否是根实例
  const isRoot = !vm.$parent
  
  // 非根实例时暂时禁用观察者
  if (!isRoot) {
    toggleObserving(false)
  }
  
  // 处理每个prop
  for (const key in propsOptions) {
    keys.push(key)
    
    // 验证prop并获取其值
    const value = validateProp(key, propsOptions, propsData, vm)
    
    // 开发环境警告检查
    if (process.env.NODE_ENV !== 'production') {
      // ... 属性保留字检查、直接修改props警告
      defineReactive(props, key, value, () => {
        // ... 开发环境警告回调
      })
    } else {
      // 定义为响应式属性
      defineReactive(props, key, value)
    }
    
    // 在实例上进行代理
    if (!(key in vm)) {
      proxy(vm, '_props', key)
    }
  }
  
  // 恢复观察状态
  toggleObserving(true)
}
代码分析
  1. 基础设置

    • propsData:包含父组件传入的实际prop值
    • props = vm._props = {}:创建内部props存储对象,并保持引用一致
    • keys = vm.$options._propKeys = []:缓存prop键列表,用于性能优化
  2. 观察者暂停优化

    • 对于非根实例,先禁用观察者(toggleObserving(false)
    • 原因:props已在父组件中是响应式的,子组件无需再次深度观察
    • 这是一项重要的性能优化,避免重复观察和不必要的响应式转换
  3. 遍历props选项

    • 缓存每个prop的键到keys数组
    • 通过validateProp验证并获取prop值
    • 验证包括:类型检查、默认值处理、必填属性检查等
  4. 响应式处理

    • 使用defineReactive将每个prop定义为响应式属性
    • 开发环境添加警告回调,提醒开发者不要直接修改props
  5. 代理访问

    • 使用proxy函数代理访问,让用户可以通过vm.propName访问
    • 只有在实例上不存在同名属性时才进行代理
  6. 恢复观察

    • 处理完所有props后,恢复观察者状态(toggleObserving(true)
    • 确保后续的响应式处理正常工作
props 处理的关键点
  1. 单向数据流

    • props 是从父组件流向子组件的单向传递
    • Vue 会在子组件内部警告直接修改 props
  2. 类型验证与转换

    • 通过 validateProp 函数处理类型验证
    • 支持类型转换,如字符串转数字
    • 处理默认值和必填检查
  3. 响应式更新

    • 当父组件中的prop值变化时,子组件会自动更新
    • 这个机制依赖于 Vue 的响应式系统
  4. 性能优化

    • 使用 toggleObserving 避免重复响应式转换
    • 缓存 prop 键以优化更新性能
实际应用示例
// 父组件
Vue.component('parent', {
  template: '<child :message="parentMsg"></child>',
  data() {
    return {
      parentMsg: 'Hello from parent'
    }
  }
})

// 子组件
Vue.component('child', {
  props: {
    message: {
      type: String,
      required: true,
      default: 'default message'
    }
  },
  template: '<div>{{ message }}</div>'
})

在这个例子中:

  • 子组件定义了 message prop
  • 父组件通过 :message 绑定传递数据
  • 数据流向是单向的:父组件 → 子组件

4. initData 函数 - 数据初始化实现

function initData (vm) {
  // 获取数据选项
  let data = vm.$options.data
  
  // 处理函数形式的data选项
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)  // 执行data函数
    : data || {}         // 或使用对象形式,若无则用空对象
  
  // 确保data是普通对象
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  
  // 数据代理和检查
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  
  // 检查命名冲突
  while (i--) {
    const key = keys[i]
    // 与methods检查冲突
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(`Method "${key}" has already been defined as a data property.`, vm)
      }
    }
    // 与props检查冲突
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } 
    // 非保留属性进行代理
    else if (!isReserved(key)) {
      proxy(vm, '_data', key)
    }
  }
  
  // 观察数据,使其变为响应式
  observe(data, true /* asRootData */)
}
代码分析
  1. 数据获取与类型处理

    • vm.$options.data 获取原始数据
    • 支持两种形式:函数形式和对象形式
    • 函数形式通过 getData 执行并获取返回值
    • 结果存储在 vm._data 中,保持内部一致性
  2. 函数形式处理

    • 组件的 data 必须是一个返回对象的函数
    • 通过 getData 函数执行,并处理异常
    • 函数形式确保每个组件实例有独立的数据副本
  3. 类型验证

    • 确保 data 是普通对象(isPlainObject
    • 开发环境下对非对象值发出警告
    • 防止异常数据破坏组件状态
  4. 命名冲突检查

    • 检查 data 属性是否与 methods 冲突
    • 检查 data 属性是否与 props 冲突
    • 避免同名属性导致的意外行为
  5. 代理处理

    • 对非保留属性(不以 _$ 开头)进行代理
    • 通过 proxy 函数将 vm._data.xxx 代理到 vm.xxx
    • 保留命名空间的同时提供便捷访问
  6. 响应式转换

    • 通过 observe(data, true) 将数据转为响应式
    • asRootData 标记表示这是根级数据
    • 这一步骤是 Vue 响应式系统的关键
getData 函数解析
export function getData (data: Function, vm: Component): any {
  // 暂停依赖收集
  pushTarget()
  try {
    // 调用数据函数,并绑定this到vm
    return data.call(vm, vm)
  } catch (e) {
    // 处理错误
    handleError(e, vm, `data()`)
    return {}
  } finally {
    // 恢复依赖收集
    popTarget()
  }
}
  1. 依赖收集控制

    • 通过 pushTarget()popTarget() 暂停和恢复依赖收集
    • 防止 data() 函数内的响应式属性访问触发不必要的依赖收集
  2. 错误处理

    • 使用 try/catch 捕获 data() 执行过程中的错误
    • 通过 handleError 统一处理,提供标准错误报告
    • 出错时返回空对象,确保组件不会完全崩溃
  3. 上下文绑定

    • 使用 call 方法绑定 vm 作为 data 函数的 this
    • 同时将 vm 作为参数传入,提供更多访问选项
initData 的实际应用
// 全局组件中的数据初始化
Vue.component('my-component', {
  props: ['initialCounter'],
  data() {
    return {
      counter: this.initialCounter || 0,
      message: 'Hello'
    }
  },
  methods: {
    increment() {
      this.counter++
    }
  }
})

在这个例子中:

  • data() 函数返回组件的内部状态
  • 可以在 data() 中访问 props(this.initialCounter
  • 返回的对象被转换为响应式,支持自动更新视图

5. initComputed 函数 - 计算属性初始化

const computedWatcherOptions = { lazy: true }

function initComputed (vm, computed) {
  // 创建计算属性watcher的存储对象
  const watchers = vm._computedWatchers = Object.create(null)
  
  // 检查是否为服务器渲染环境
  const isSSR = isServerRendering()

  // 遍历计算属性定义
  for (const key in computed) {
    // 获取用户定义
    const userDef = computed[key]
    
    // 获取getter函数
    const getter = typeof userDef === 'function' 
      ? userDef 
      : userDef.get
      
    // 开发环境下检查getter
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(`Getter is missing for computed property "${key}".`, vm)
    }

    // 非服务器渲染环境下创建watcher
    if (!isSSR) {
      // 为计算属性创建专用watcher
      watchers[key] = new Watcher(
        vm,
        getter || noop,  // getter函数
        noop,            // 回调函数(计算属性不需要)
        computedWatcherOptions  // lazy: true 标记
      )
    }

    // 在组件实例上定义计算属性
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      // 检查名称冲突
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      } else if (vm.$options.methods && key in vm.$options.methods) {
        warn(`The computed property "${key}" is already defined as a method.`, vm)
      }
    }
  }
}
代码分析
  1. 初始化设置

    • computedWatcherOptions = { lazy: true }:标记计算属性为惰性求值
    • watchers = vm._computedWatchers = Object.create(null):存储计算属性的观察者
    • 使用 Object.create(null) 创建无原型的纯对象,避免原型链污染
  2. 环境检测

    • 通过 isServerRendering() 检查是否为服务器渲染环境
    • 服务器渲染时计算属性实现有所不同
  3. 遍历计算属性

    • 支持两种定义方式:函数形式和带有 get/set 的对象形式
    • 获取 getter 函数,用于依赖收集和计算
  4. Watcher 创建

    • 为每个计算属性创建专用的 watcher 实例
    • 使用 lazy: true 选项,实现惰性计算和缓存机制
    • 不在服务器渲染环境下创建 watcher(SSR有不同优化)
  5. 属性定义

    • 通过 defineComputed 在实例上定义计算属性
    • 检查命名冲突,防止与 data、props 或 methods 重名
    • 提供开发环境下的警告提示
defineComputed 函数解析
export function defineComputed (target, key, userDef) {
  // 是否应该缓存(服务器渲染时不缓存)
  const shouldCache = !isServerRendering()
  
  // 处理函数形式的定义
  if (typeof userDef === 'function') {
    // 设置getter
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)  // 创建带缓存的getter
      : createGetterInvoker(userDef)  // 创建直接调用getter的函数
    // 设置空setter
    sharedPropertyDefinition.set = noop
  } else {
    // 处理对象形式的定义
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop
    // 设置用户定义的setter或空函数  
    sharedPropertyDefinition.set = userDef.set || noop
  }
  
  // 开发环境下对没有setter的计算属性给出警告
  if (process.env.NODE_ENV !== 'production' &&
      sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        `Computed property "${key}" was assigned to but it has no setter.`,
        this
      )
    }
  }
  
  // 在目标对象上定义属性
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
createComputedGetter 函数分析
function createComputedGetter (key) {
  return function computedGetter () {
    // 获取对应的计算属性watcher
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      // 如果是脏值,重新计算
      if (watcher.dirty) {
        watcher.evaluate()
      }
      // 进行依赖收集
      if (Dep.target) {
        watcher.depend()
      }
      // 返回计算结果
      return watcher.value
    }
  }
}
计算属性的工作原理
  1. 懒计算与缓存

    • 通过 lazy: true 配置,watcher 不会立即计算值
    • 只有在访问计算属性时才会触发计算
    • 计算结果被缓存,多次访问不会重复计算
  2. 依赖追踪

    • 计算属性的 getter 函数会访问响应式数据
    • Vue 追踪这些依赖关系,当依赖变化时标记计算属性为脏值
    • 下次访问时检测到脏值,重新计算
  3. 双向依赖关系

    • 计算属性依赖其他响应式数据
    • 计算属性自身也可以被其他计算属性或监听器依赖
  4. 响应式连接

    • watcher.depend() 确保计算属性的依赖被当前正在收集依赖的 watcher 追踪
    • 这使得计算属性成为其他响应系统的有效部分
实际应用示例
const vm = new Vue({
  data: {
    firstName: 'John',
    lastName: 'Doe'
  },
  computed: {
    // 函数形式
    fullName() {
      return this.firstName + ' ' + this.lastName
    },
    
    // 对象形式
    greeting: {
      get() {
        return `Hello, ${this.fullName}!`
      },
      set(newValue) {
        const names = newValue.replace('Hello, ', '').replace('!', '').split(' ')
        this.firstName = names[0]
        this.lastName = names[1]
      }
    }
  }
})

// 使用
console.log(vm.fullName)  // 'John Doe'
console.log(vm.greeting)  // 'Hello, John Doe!'

// 更新依赖数据
vm.firstName = 'Jane'
// 计算属性自动更新
console.log(vm.fullName)  // 'Jane Doe'
console.log(vm.greeting)  // 'Hello, Jane Doe!'

// 使用setter
vm.greeting = 'Hello, Alice Smith!'
console.log(vm.firstName) // 'Alice'
console.log(vm.lastName)  // 'Smith'

6. initMethods 函数 - 方法初始化实现

function initMethods (vm, methods) {
  // 获取props
  const props = vm.$options.props
  
  // 遍历所有方法
  for (const key in methods) {
    // 开发环境下的检查
    if (process.env.NODE_ENV !== 'production') {
      // 检查方法值是否是函数
      if (typeof methods[key] !== 'function') {
        warn(
          `Method "${key}" has type "${typeof methods[key]}" in the component definition. ` +
          `Did you reference the function correctly?`,
          vm
        )
      }
      
      // 检查方法名是否与props冲突
      if (props && hasOwn(props, key)) {
        warn(
          `Method "${key}" has already been defined as a prop.`,
          vm
        )
      }
      
      // 检查是否与Vue实例内置方法冲突
      if ((key in vm) && isReserved(key)) {
        warn(
          `Method "${key}" conflicts with an existing Vue instance method. ` +
          `Avoid defining component methods that start with _ or $.`
        )
      }
    }
    
    // 添加方法到实例上,并绑定this上下文
    vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm)
  }
}
代码分析
  1. 方法处理流程

    • 遍历组件定义中的所有方法
    • 进行各种安全性检查
    • 将方法绑定到组件实例,确保正确的this上下文
    • 处理无效方法,用空函数代替
  2. 完整性检查

    • 检查方法是否真的是函数类型
    • 防止错误引用或无效值破坏组件功能
    • 在开发环境提供有用的警告信息
  3. 命名冲突检查

    • 检查方法名是否与props冲突
    • 检查是否与Vue实例的内置方法(以_$开头)冲突
    • 避免覆盖重要的实例属性或方法
  4. 方法绑定

    • 使用bind函数将方法的this绑定到Vue实例
    • 确保方法内可以通过this访问实例属性和其他方法
    • 即使在回调或事件处理中也能保持正确的上下文
  5. 错误处理

    • 对非函数值,使用noop(空函数)替代
    • 防止调用无效方法时引发错误
    • 保证组件的健壮性
方法绑定的深入解析

Vue使用bind方法确保组件方法始终有正确的this上下文:

// 从util/index.js
export function bind (fn: Function, ctx: Object): Function {
  function boundFn (a) {
    const l = arguments.length
    return l
      ? l > 1
        ? fn.apply(ctx, arguments)
        : fn.call(ctx, a)
      : fn.call(ctx)
  }
  boundFn._length = fn.length
  return boundFn
}

这个自定义的bind实现有几个特点:

  • 优化了不同参数数量的调用性能
  • 保留了原函数的参数长度(_length属性)
  • 根据参数数量选择callapply方法
实际应用示例
// 组件定义
Vue.component('counter-button', {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++  // 可以通过this访问组件数据
    },
    reset() {
      this.count = 0
    },
    notifyChange(callback) {
      // 在回调中也能保持正确的this
      callback(this.count)
    }
  },
  template: `
    <div>
      <button @click="increment">Increment</button>
      <button @click="reset">Reset</button>
      <span>Count: {{ count }}</span>
    </div>
  `
})

在这个例子中:

  • 方法被安全绑定到实例
  • 事件处理器可以通过this访问组件状态
  • 方法之间可以相互调用,保持上下文一致

7. initWatch 函数 - 侦听器初始化

function initWatch (vm, watch) {
  // 遍历watch选项中的每个监听器
  for (const key in watch) {
    const handler = watch[key]
    
    // 处理数组形式的监听器
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      // 处理单个监听器
      createWatcher(vm, key, handler)
    }
  }
}

// 创建监听器
function createWatcher (
  vm,
  expOrFn,
  handler,
  options
) {
  // 处理纯对象形式的监听器配置
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  
  // 处理字符串形式的handler(方法名)
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  
  // 调用实例方法$watch创建监听器
  return vm.$watch(expOrFn, handler, options)
}
代码分析
  1. 侦听器的多种形式

    • 支持数组形式:同一数据多个监听器
    • 支持对象形式:包含handler、deep、immediate等选项
    • 支持函数形式:直接提供回调函数
    • 支持字符串形式:指定组件方法名
  2. 侦听器标准化

    • 通过createWatcher函数统一处理不同形式
    • 最终都调用vm.$watch方法创建实际的观察者
  3. 处理流程

    • 遍历watch选项中的每个键
    • 根据值的类型进行不同处理
    • 对数组形式递归处理每个元素
    • 标准化handler和options参数
  4. 方法引用处理

    • 支持通过字符串引用组件方法
    • createWatcher中解析并获取实际方法引用
    • 适用于模板编译或简化复杂配置的场景
$watch方法实现
Vue.prototype.$watch = function (
  expOrFn,
  cb,
  options
) {
  const vm = this
  // 处理对象形式的回调
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  
  // 初始化选项
  options = options || {}
  options.user = true  // 标记为用户watcher
  
  // 创建watcher实例
  const watcher = new Watcher(vm, expOrFn, cb, options)
  
  // 如果指定immediate,立即使用当前值调用回调
  if (options.immediate) {
    const info = `callback for immediate watcher "${watcher.expression}"`
    pushTarget()
    invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
    popTarget()
  }
  
  // 返回取消监听的函数
  return function unwatchFn () {
    watcher.teardown()
  }
}
侦听器配置选项
  1. 深度侦听(deep):

    • deep: true 可以侦听对象内部属性变化
    • 实现原理是递归遍历对象的所有嵌套属性
    • 可能影响性能,特别是对于大型对象
  2. 立即执行(immediate):

    • immediate: true 会在创建侦听器后立即使用当前值调用一次回调
    • 适用于需要立即处理初始值的场景
  3. 回调函数(handler):

    • 侦听器触发时执行的函数
    • 接收新值和旧值两个参数
    • 在组件上下文中执行
实际应用示例
const vm = new Vue({
  data: {
    user: {
      name: 'John',
      email: 'john@example.com',
      profile: {
        age: 30
      }
    },
    message: ''
  },
  
  watch: {
    // 简单监听
    'user.name': function(newVal, oldVal) {
      this.message = `Name changed from ${oldVal} to ${newVal}`
    },
    
    // 对象形式监听
    'user.email': {
      handler: function(newVal) {
        console.log(`New email: ${newVal}`)
      },
      immediate: true  // 立即执行一次
    },
    
    // 深度监听
    user: {
      handler: function(newVal) {
        console.log('User object changed')
      },
      deep: true,  // 监听对象内部变化
      immediate: false
    },
    
    // 数组形式
    'user.profile.age': [
      function(newVal) {
        console.log(`Age is now ${newVal}`)
      },
      function(newVal) {
        if (newVal > 30) {
          console.log('User is over 30')
        }
      }
    ],
    
    // 方法引用形式
    message: 'messageChanged'
  },
  
  methods: {
    messageChanged(newVal) {
      console.log(`Message changed: ${newVal}`)
    }
  }
})

// 触发监听器
vm.user.name = 'Jane'  // 触发name监听器
vm.user.profile.age = 31  // 触发age监听器和深度user监听器

状态管理的特点

  1. 响应式处理

    • 所有的状态都会被转换为响应式
    • 使用 Object.defineProperty 进行数据劫持
    • 实现了数据变化的自动追踪
  2. 命名空间管理

    • props、data、methods 等都有自己的命名空间
    • 通过代理统一了访问方式
    • 避免命名冲突
  3. 初始化顺序

    • 遵循依赖关系,确保正确的初始化顺序
    • 保证了数据的可用性和一致性
  4. 性能优化

    • 根实例和子实例采用不同的处理策略
    • 使用代理机制避免过度响应式转换
    • computed 使用惰性求值提升性能

最佳实践启示

  1. 数据来源清晰

    • props 用于外部数据
    • data 用于内部状态
    • computed 用于数据派生
    • methods 用于行为定义
  2. 避免命名冲突

    • 不要在 data 中使用与 props 或 methods 同名的属性
    • 不要使用以 _ 或 $ 开头的私有属性名
  3. 合理使用计算属性

    • 优先使用 computed 而不是复杂的 watch
    • 利用计算属性的缓存特性优化性能
  4. 正确的数据初始化

    • data 必须返回一个对象
    • props 要定义类型和默认值
    • computed 属性要有明确的依赖关系

常见问题解析

1. 初始化顺序问题

初始化顺序不当会导致以下实际问题:

export default {
  data() {
    return {
      // 问题不在这里,data中其实可以使用methods
      counter: 0
    }
  },
  computed: {
    // 错误:此时data可能尚未完全响应式化
    doubleCounter() {
      return this.counter * 2
    }
  },
  // 生命周期钩子中的问题
  beforeCreate() {
    // 错误:此时data和methods都未初始化
    console.log(this.counter) // undefined
    this.increment() // 方法不存在
  },
  methods: {
    increment() {
      // 正确:props在methods之前初始化
      if (this.maxValue && this.counter >= this.maxValue) return
      this.counter++
    }
  },
  props: ['maxValue']
}

常见问题:

  1. 生命周期相关错误

    • beforeCreate 中访问 data、methods、computed(它们尚未初始化)
    • created 中访问 DOM 或 $el(它们尚未挂载)
  2. 数据依赖顺序注意点

    • props 确实最先初始化,在 methods 和 data 中都可以安全使用
    • methods 在 data 之前初始化,在 data 函数中可以调用方法
    • data 需要在 computed 之前完全初始化并响应式化
    • computed 需要在 watch 之前设置好,因为 watch 可能依赖计算属性
  3. 响应式系统限制

    • data 对象初始化后才会被转换为响应式
    • data 中不存在的属性后续需要用 $set 添加才能是响应式的
实际案例和解决方案

案例1: 生命周期钩子中的错误

// 错误做法
export default {
  beforeCreate() {
    this.initialize() // 错误:methods尚未初始化
  },
  data() {
    return { items: [] }
  },
  methods: {
    initialize() {
      this.items = [1, 2, 3] // 错误:data尚未初始化
    }
  }
}

// 正确做法
export default {
  data() {
    return { items: [] }
  },
  created() { // 使用created而非beforeCreate
    this.initialize() // 正确:此时methods和data都已初始化
  },
  methods: {
    initialize() {
      this.items = [1, 2, 3]
    }
  }
}

案例2: 数据初始化中的响应式问题

// 错误做法 - 响应式系统的限制
export default {
  data() {
    const data = {}
    // 动态添加属性
    if (this.useCounter) { // props在data之前可以访问
      data.counter = 0
    }
    return data
  },
  props: ['useCounter'],
  created() {
    // 如果useCounter在实例创建后改变,动态添加属性不会是响应式的
    if (!this.counter && this.useCounter) {
      this.counter = 0 // 添加的新属性不是响应式的
    }
  }
}

// 正确做法
export default {
  data() {
    return {
      // 始终声明可能使用的属性,并给予初始值
      counter: this.useCounter ? 0 : null
    }
  },
  props: ['useCounter'],
  watch: {
    useCounter(val) {
      if (val && this.counter === null) {
        this.counter = 0
      }
    }
  }
}

2. data 的空对象观察

在 initState 中有这样的代码:

if (opts.data) {
  initData(vm)
} else {
  observe(vm._data = {}, true /* asRootData */)
}

为什么要观察空对象?

  1. 一致性保证

    • 确保 vm._data 始终是响应式的
    • 即使没有数据,后续添加的数据也能保持响应式
  2. 接口统一

    • 其他地方可以统一通过 vm._data 访问数据
    • 不需要判断 data 是否存在
  3. 动态数据处理

    • 支持运行时动态添加数据
    • 保证后添加的数据也是响应式的
实际应用场景
// 组件定义时没有data
const MyComponent = Vue.extend({
  template: '<div>{{message}}</div>'
  // 没有data选项
})

// 创建实例
const instance = new MyComponent()

// 动态添加数据
Vue.set(instance, 'message', 'Hello')  // 或 instance.$set(instance._data, 'message', 'Hello')

// 响应式系统正常工作
instance.message = 'Updated'  // 视图会更新

即使没有初始data,Vue仍然创建了响应式的_data对象,使得后续动态添加的属性能够触发视图更新。

3. nativeWatch 判断解析

if (opts.watch && opts.watch !== nativeWatch) {
  initWatch(vm, opts.watch)
}

这个判断的目的:

  1. 平台兼容

    • 在 Firefox 等浏览器中,Object.prototype 有原生的 watch 方法
    • 通过判断避免与原生 watch 方法冲突
  2. 性能优化

    • 避免处理非用户定义的 watch 选项
    • 防止不必要的观察者创建
深入理解

在某些浏览器环境中,Object.prototype上存在原生的watch方法:

// Firefox浏览器中可能存在
Object.prototype.watch = function(prop, handler) {
  // 原生实现...
}

如果不做检查,当获取一个对象的watch属性时,可能会返回这个原生方法而非用户定义的配置。Vue通过比较判断确保只处理用户显式定义的watch选项:

// 定义nativeWatch变量
let nativeWatch = ({}).watch

// 使用严格比较
if (opts.watch && opts.watch !== nativeWatch) {
  // 确保是用户定义的watch,而非原型链上的方法
  initWatch(vm, opts.watch)
}

4. 关键代码详解

props 初始化详解
const props = vm._props = {}

这行代码的深入解析:

  1. 数据存储

    • 创建空对象存储 props 数据
    • 挂载到实例的 _props 属性上
  2. 引用共享

    • vm._props = {} 创建空对象
    • props = vm._props 保持引用一致
    • 后续对 props 的修改直接影响 vm._props
  3. 性能考虑

    • 避免重复创建对象
    • 减少内存占用
    • 优化数据访问路径
computed 初始化详解
const watchers = vm._computedWatchers = Object.create(null)

深入解析:

  1. 使用 Object.create(null)

    • 创建真正的空对象,没有原型链
    • 避免原型链上的属性干扰
    • 提高属性查找性能
  2. watcher 存储

    • 每个计算属性对应一个 watcher
    • 统一管理计算属性的依赖
    • 方便后续清理和更新

5. 外部函数作用解析

文件引入的关键函数说明:

import {
  set,
  del,
  observe,
  defineReactive,
  toggleObserving
} from '../observer/index'
  • observe:将数据对象转换为响应式对象

    • 为对象添加Observer实例
    • 递归处理嵌套属性
    • 返回Observer实例,实现数据劫持
  • defineReactive:定义响应式属性,设置 getter/setter

    • 创建依赖收集器Dep实例
    • 设置属性的getter用于依赖收集
    • 设置属性的setter用于通知更新
  • toggleObserving:控制是否应该将数据转换为响应式

    • 全局开关,用于临时禁用/启用观察者
    • 优化性能,避免不必要的深度观察
  • set:动态添加响应式属性

    • Vue.set/vm.$set的实现
    • 解决Vue无法检测属性添加的限制
    • 触发视图更新
  • del:删除响应式属性

    • Vue.delete/vm.$delete的实现
    • 确保删除属性时触发更新
    • 清理相关的依赖
import Watcher from '../observer/watcher'
  • Watcher:依赖收集和派发更新的核心类
    • 用于实现计算属性(计算watcher)
    • 用于实现数据监听(用户watcher)
    • 用于实现组件更新(渲染watcher)
    • 定义了依赖收集、求值、更新等方法
import Dep, { pushTarget, popTarget } from '../observer/dep'
  • Dep:依赖管理器

    • 收集和管理watcher
    • 在数据变化时通知watcher更新
    • 建立数据与使用数据的地方的连接
  • pushTarget/popTarget

    • 管理当前正在评估的watcher
    • 构建watcher的嵌套结构
    • 控制依赖收集的工作流程

补充说明

在实际开发中,理解这些初始化细节有助于:

  1. 合理安排代码结构

    • 遵循Vue的数据流设计理念
    • 按照初始化顺序组织依赖关系
    • 避免生命周期钩子中的常见错误
  2. 优化性能

    • 减少无效的深度观察
    • 善用计算属性的缓存机制
    • 避免低效的深度watch
  3. 正确使用响应式系统

    • 在data中预声明所有可能用到的属性
    • 合理使用Vue.set/Vue.delete处理动态属性
    • 理解对象和数组的响应式局限性
  4. 理解Vue的实现原理

    • 深入掌握Vue的响应式系统工作机制
    • 了解数据与DOM更新之间的联系
    • 构建更高效、更可维护的应用

Vue状态管理最佳实践

1. 组件数据来源清晰化

根据数据的来源和用途,合理选择不同的状态类型:

export default {
  props: {
    // 来自父组件的数据
    userId: Number,
    initialData: Object
  },
  
  data() {
    return {
      // 组件内部状态
      isLoading: false,
      localData: this.initialData ? {...this.initialData} : {}
    }
  },
  
  computed: {
    // 派生数据
    fullName() {
      return `${this.localData.firstName} ${this.localData.lastName}`
    },
    isComplete() {
      return Object.keys(this.localData).every(key => !!this.localData[key])
    }
  },
  
  methods: {
    // 行为与逻辑
    async fetchUserData() {
      this.isLoading = true
      try {
        const data = await api.getUser(this.userId)
        this.localData = data
      } finally {
        this.isLoading = false
      }
    }
  }
}

2. 响应式陷阱的避免

避免常见的响应式相关陷阱:

export default {
  data() {
    return {
      user: {
        name: 'John',
        settings: {}
      },
      items: []
    }
  },
  
  methods: {
    // 错误:直接赋值新对象,不会触发响应式更新
    badUpdate() {
      this.user.settings = { theme: 'dark' }  // 可能不会触发更新
    },
    
    // 正确:使用Vue.set或this.$set
    goodUpdate() {
      this.$set(this.user, 'settings', { theme: 'dark' })
    },
    
    // 数组操作
    updateItems() {
      // 错误:通过索引直接设置项
      this.items[0] = 'new item'  // 不会触发更新
      
      // 正确:使用数组方法
      this.items.splice(0, 1, 'new item')
      
      // 或使用this.$set
      this.$set(this.items, 0, 'new item')
    }
  }
}

3. 性能优化技巧

export default {
  data() {
    return {
      users: [],
      searchQuery: ''
    }
  },
  
  computed: {
    // 使用计算属性缓存过滤结果
    filteredUsers() {
      return this.users.filter(user => 
        user.name.includes(this.searchQuery)
      )
    }
  },
  
  watch: {
    // 使用防抖优化频繁更新
    searchQuery: {
      handler: 'debouncedSearch',
      immediate: true
    },
    
    // 避免深度监听大型对象
    users: {
      handler: 'handleUsersChange',
      deep: false  // 只监听引用变化
    }
  },
  
  methods: {
    debouncedSearch: debounce(function() {
      this.fetchSearchResults()
    }, 300),
    
    // 手动监听特定嵌套属性
    handleSpecificChange(userId) {
      const user = this.users.find(u => u.id === userId)
      // 处理特定用户变化
    }
  }
}

这些最佳实践可以帮助你更有效地管理Vue组件的状态,避免常见的陷阱和性能问题。

Tauri 实战:秒懂文件拖拽上传,轻松拿到绝对路径!

前言

最近碰到一个小需求,简单记录一下:我想在 Tauri 里实现 将文件拖拽到指定区域,并直接获取其绝对路径。一开始下意识地用了 Web 的常规做法,结果发现事情没那么简单。

Web 实现方案

首先,确保在 tauri.conf.json 中将窗口的 dragDropEnabled 设置为 false,防止系统层级的拖拽行为干扰。

然后,组件部分大致是这样写的:

<script setup lang="ts">
import { ref } from 'vue'

const dragenter = ref(false)

function handleDragEnter() {
  dragenter.value = true
}

function handleDragLeave() {
  dragenter.value = false
}

function handleDrop(event: DragEvent) {
  dragenter.value = false

  const files = event.dataTransfer?.files
  
  console.log(files)
}
</script>

<template>
  <div
    @dragenter.prevent="handleDragEnter"
    @dragleave.prevent="handleDragLeave"
    @dragover.prevent
    @drop.prevent="handleDrop"
  >
    拖动至此区域上传
  </div>
</template>

不过这个方案有个缺点:拿到的是文件对象而不是文件的绝对路径。虽然可以通过其它的方案解决,但我更希望能一步到位,直接拿到文件的路径,方便后续处理。

Tauri 实现方案

<script setup lang="ts">
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { onMounted, ref, useTemplateRef } from 'vue'

const dropRef = useTemplateRef('drop')
const dragenter = ref(false)

onMounted(() => {
  getCurrentWebviewWindow().onDragDropEvent(({ payload }) => {
    const { type } = payload

    if (type === 'over') {
      const { x, y } = payload.position

      if (dropRef.value) {
        const { left, right, top, bottom } = dropRef.value.getBoundingClientRect()

        const inBoundsX = x >= left && x <= right
        const inBoundsY = y >= top && y <= bottom

        dragenter.value = inBoundsX && inBoundsY
      }
    } else if (type === 'drop' && dragenter.value) {
      dragenter.value = false

      console.log('dropped', payload.paths)
    } else {
      dragenter.value = false
    }
  })
})
</script>

<template>
    <div ref="drop">
        拖动至此区域上传
    </div>
</template>

这样一来,不仅可以判断文件是否拖拽到了指定区域,还能直接获取绝对路径

iShot_2025-05-17_18.43.34-ezgif.com-video-to-gif-converter.gif

uni-app小程序登录后…

前情

最近新接了一个全新项目,是类似商城的小程序项目,我负责从0开始搭建小程序,我选用的技术栈是uni-app技术栈,其中就有一个用户登录功能,小程序部分页面是需要登录才可以查看的,对于未登录的用户需要引导用户去登录页面,再back回来重新渲染当前页面,以让用户正常使用

思考

问题也不复杂,就是判断登录状态而已,需要登录的页面没登录就引导去登录再回来,回来后再重新渲染页面数据即可

这里有二个动作:一个是判断登录态,一个是重新渲染页面数据,有动作就有触发时机,对于判断登录态,我们是在跳转前判断,还是在跳转进需要登录的页面再判断,对于重新渲染数据当然是进入页面的时候再重新渲染,但是怎么去实现重新渲染了,对于小程序我们第一时间想到的是通过生命钩子来做,那当然就是onShow了

解决方案

基于上述的思考,我想到如下二种解决方案:

image.png

应该能解决的方案应该有很多,这只是我在实现这个需求的时候想到的二种方案

场景方案1:跳转进需要登录的页面再判断是否是登录态,同时登录回来后通过onShow生命钩子重新渲染页面

此方案优点:就是判断登录态你不需要特定代码去判断,在服务端接口这一块做下处理即可,如果返回状态码是401或者是你和服务端沟通好的错误码时再引导去登录页,这样全局做请求拦截就行,项目中我就是有做这一块的处理,使用的是我封装好的工具方法:常用工具方法 - DCloud 插件市场已分享到插件市场,欢迎使用

此方案的缺点:在未登录的状态下,用户会看到明显的页面跳转,跳转到一个空白页面,突然又跳转到登录页,用户体验不是特别好,同时在onShow生命周期钩子里做数据重新渲染会有一个问题,这样会造成过多的网络请求,如果用户量不小的话会对于服务器造成一些压力

场景方案2:跳转进需要登录的页面前判断登录态,并记录正在跳转的页面,登录后重定向到前面已经记录的跳转页面

此方案优点:避免了onShow频繁触发导致服务器渲染浪费的问题,缺点就是你需要在每一个跳转需要登录的页面前做登录态判断,会导致代码冗余工作量增加,后期维护不是特别好

方案选择

我选择的是场景方案2

二种方案都有优缺点,方案1有一点是用户感受最直接的,就是闪跳的用户体验那一点,至于onShow会导致接口频繁请求问题是有方法解决的,后面会提到;方案2只要想办法解决代码冗余的问题即可

方案实现细节

解决代码冗余问题,我们使用uni-app的拦截api来做下路由拦截即可,根据跳转的URL和当前登录态判断要不要先跳登录页做登录,在main.js中增加路由拦截,关键代码如下:

...

/**
 * 需要登录才能跳转的页面
 */
const needLoginPages = [
  '/orders/detail/detail',
  '/orders/orderList/orderList',
  ...
]

// 要拦截的路由方法
const interceptors = ['navigateTo', 'reLaunch', 'redirectTo']
const globalStoreInstance = globalStore(pinia);

// 路由拦截
interceptors.forEach(interceptor => {
  uni.addInterceptor(interceptor, {
    invoke(e) {
        // 判断当前页面是否是要需要登录才能跳转的页面里
      const needLogin = needLoginPages.findIndex(item => e.url.includes(item)) !== -1;
      if (needLogin && !storage.get(TOKEN)) {
          // 记录要跳转的页面
        globalStoreInstance.setNeedLoginBackPage(e.url);
        uni.navigateTo({
          url: '/other/login/login'
        })
        return false
      }
      return true
    }
  })
})

...

我在写这篇博客的时候,我发现这里代码其实有一个可优化点,你发现了吗?欢迎留言👀讨论

同时在登录页登录成功后需要做一下跳转逻辑,关键代码如下:

...
// 解决登录后跳转的问题
if (globalStoreInstance.needLoginBackPage) {
    uni.redirectTo({
        url: globalStoreInstance.needLoginBackPage,
        complete: () => {
            globalStoreInstance.setNeedLoginBackPage('');
        }
    })
} else {
    // 解决登录回去页面数据丢失的问题
    const pages = getCurrentPages();
    if (pages.length >= 2) {
        // 获取前一个页面实例
        const prevPage = pages[pages.length - 2];
        // 调用前一个页面的onLoad方法
        if (prevPage?.onLoad) {
            prevPage.onLoad(prevPage.options || {}); // 传递原始参数
        }
    }

    uni.navigateBack();
}

...

看代码除了跳转还处理了back,这一段back逻辑也是我在实现的时候发现它可以解决方案1的onShow问题,也就是说方案1也就是只有一个体验问题,所以二种方案我觉得都是可行的,同时我提供方案1接口拦截的代码:

import { Request, storage } from '@/uni_modules/hbxw-utils/js_sdk/hbxw-utils.js';
import { BASE_URL } from '@/config/http';
import { TOKEN } from '@/config/common';

const request = new Request({
    isLogin: true,
});

request.baseUrl = BASE_URL;

/**
 * 请求拦截,可以通过add方法添加多个
 * 参数为请求配置,可以对请求参数做一些特殊处理
 */
request.requestIntercept.add((requestConfig) => {
    // 如果有传就用传的,没有就去取,为了解决登录默认token
    console.log('---- requestConfig ----:', requestConfig)
    if (!requestConfig.header) {
        requestConfig.header = {}
    }
    // 如果header中没有Accept,则设置为application/json
    if (!requestConfig.header?.Accept) {
        requestConfig.header.Accept = 'application/json';
    }
    if (!requestConfig.header?.Authorization) {
        let Authorization = ''
        try {
            Authorization  = storage.get(TOKEN) || '';
        } catch (err) {
            console.log(err)
        }
        // 添加Authorization到header中用于服务端登录判断
        if (Authorization) {
            if (!requestConfig.header) {
                requestConfig.header = {}
            }
            requestConfig.header.Authorization = Authorization;
        }
    }
    // 如果返回true则请求会中断
    // return true;
});

/**
 * 响应拦截,可以通过add方法添加多个
 * 第一个参数为请求响应体
 * 第二个参数为请求配置信息
 */
request.responIntercept.add((response, requestConfig) => {
    console.log('---- response ----:', response)
    // 如果接状态码为401,而且当前接口是需要判断登录状态的
    if (response.statusCode == 401 && requestConfig.isLogin) {
        uni.navigateTo({
            url: '/other/login/login'
        })
        // 返回true 中断后面处理
        return true;
    }
    // 通用错误处理
    if (response.statusCode !== 200 || response.data.code !== 200) {
        uni.showToast({
            title: response.data.message || '请求失败,请稍后再试',
            icon: 'none'
        })
        return true;
    }
});

export default request;

期望

解决问题的方法千千万,上述是我是解决登录跳转逻辑的处理方案,如果在上面二种方案中,你会选择哪一种了?聪明的你也一定有别的更好的方案,期待你的分享和留言👀,共同进步。

一文读懂 SSE

什么是SSE

SSE(Server Sent Events)是一种基于HTTP的轻量级实时通信技术,允许服务端主动向客户端推送数据,适用于需要进行实时数据流的场景。

从表面看,SSE 和 WebSocket 似乎都能实现“双向通信” —— 毕竟数据能在客户端和服务器之间流动。

双向通信的错觉

实际上,SSE 的通信模式是 单向的(服务器 → 客户端),而所谓的“双向”需要额外配合其他技术(如客户端通过普通 HTTP 请求发送数据)。

每次客户端建立 EventSource 时(GET请求),携带自定义参数,服务端收到请求后,解析参数,只要当前的 EventSource 没有关闭,服务端就能持续通过这个通道向客户端发送数据。

流程如下:

sse流程.png

从上图可以看出,整个数据传输的过程,除了客户端开头主动建立 SEE 请求外,后续都是由服务端给客户端发送数据, 这种单向推送机制正是 SSE 的核心设计特点

为了更清晰地理解 SSE,从以下几个维度对 SSE 和 WebSocket 进行对比:

特性 SSE WebSocket
协议基础 HTTP 长连接 独立的 WebSocket 协议
连接开销 低(复用 HTTP ) 中(需升级协议)
通信模式 单向(服务器→客户端) 双向通信
二进制支持 仅文本传输 支持文本与二进制数据
客户端 API 浏览器内置 EventSource 浏览器内置 WebSocket 对象
典型场景 服务器主动推送(如新闻推送、ai智能回复) 双向交互(如聊天、协作)

至于具体要选择 SSE 还是 WebSocket,应根据业务需求决定——是否需要真正的双向通信

如何使用 SSE

客户端建立 SSE 连接

在客户端建立 SSE 连接十分简洁,借助浏览器内置的 EventSource API,只需数行代码,就能向服务器发起连接。

const eventSource = new EventSource('/xxxx?question=hi'); 

eventSource.onmessage = (event) => {
    console.log('收到消息:', event.data);
};

上述代码中,通过 new EventSource 创建一个 SSE 实例,并指定连接的服务器地址,同时可以在 URL 中携带参数。

然后通过 onmessage 事件监听服务器推送的数据。

服务端响应

服务器端在响应 SSE 请求时,必须包含特定的响应头。

这些响应头包括:

  • Content-Type : text/event-stream; :必须将该响应头设置为text/event-stream,这是 SSE 协议规定的内容类型。
  • Connection: keep-alive; :保持长连接,使得服务器能够持续向客户端推送数据。

以 express 框架为例,服务端的实现代码如下:

app.get('/xxxx', async (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Connection', 'keep-alive');

    const question = decodeURIComponent(req.query.question); // 获取参数
    res.write(`data: this is a test data\n\n`); 
    res.write('data: [DONE]\n\n');
});

在这段代码中,首先通过 res.setHeader 设置了必要的响应头,然后使用res.write方法向客户端发送数据。

服务器发送的消息必须严格遵循 SSE 协议格式,常见的格式有以下几种:

  • 单行文本
res.write(`data: 这是一条普通消息\n\n`)

在浏览器开发者工具中的network面板里查看 EventStream,可以清晰地看到每一次服务器返回的数据:

image.png

  • 多行文本
res.write(`data: 这是一条普通消息\n\n`)
res.write(`data: 这是一条普通消息\n\n`)

浏览器查看结果:

image.png

  • JSON字符串:
 res.write(`data: ${JSON.stringify({foo: "hello world"})}\n\n`);

浏览器查看结果:

image.png

注意:收到的是JSON字符串,需要进行JSON解析

  • 自定义事件:

服务端:

  res.write(`event: custom event name\n`)
  res.write(`data: 这是一条消息\n\n`)

前端:

 eventSource.addEventListener("custom event name", e => {
    console.log(e.data); // 输出这是一条消息
});

浏览器查看结果:

image.png

数据遵循 SSE 协议格式,以data:开头,并且每条消息以两个换行符\n\n结尾。

实践

在阅读完上文内容后,我们对 SSE 有了初步的了解,下面就来看看 SSE 的应用。

在 deepseek 的问答中,就使用了 SSE 来传输结果:

屏幕录制2025-05-14 17.48.48.gif

仔细看这个请求,发现 deepseek 在使用 SSE 和 理论上的有区别:它使用了POST请求,而并不是用 EventSource API 来实现

image.png

这是一个典型的 POST 请求 + SSE 响应 的组合,是现代 AI 聊天服务的主流实现方式。

这种组合解决了两个核心需求:

  1. 数据量和数据格式

    • POST 请求允许通过请求体发送大量数据,比 GET 请求灵活。
  2. 实时响应

    • SSE 流式响应允许服务器在生成回复的过程中逐步发送给客户端,提升用户体验。

下面笔者将介绍这种组合的实现方式。

服务端代码:

    app.post('/api/chat/completion', async (req, res) => {
      // 设置 SSE 响应头
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');

      try {

        // 模拟 AI 生成回复
        const replys = 
            "刚进入大学想成为前端开发者,建议从HTML/CSS/JavaScript基础学起,先做静态网页练习。大二重点学习React或Vue框架,掌握组件开发和状态管理,同时熟悉Git和Webpack等工具。大三开始做完整项目,学习TypeScript和性能优化,尝试参与开源或团队协作。大四前要完成3-5个高质量作品,部署上线并整理成作品集。平时多逛GitHub、掘金等技术社区,保持每周20小时以上的编码时间。找实习时重点准备前端面试题,包括CSS布局、JS原理和框架特性等。记住动手实践比只看教程更重要,遇到问题先自己调试再求助。保持对新技术的敏感度,但先深入掌握核心技能再扩展知识面。坚持每天写代码,毕业时就能达到初级前端工程师的水平。"
            .split(''); 

        for (const reply of replys) {
          // 发送每个数据块
          res.write(`data: ${reply}\n\n`);

          // 模拟生成延迟
          await new Promise(resolve => setTimeout(resolve, 20));
        }

        // 结束响应
        res.end();
      } catch (error) {
        res.write(`data: error\n\n`);
        res.end();
      }
    });

前端请求和处理结果:

    const response = await fetch('http://localhost:3000/api/chat/completion', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            // 问题ID
            // 问题等参数
            .....
        })
    });

    // 处理流式响应
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let accumulatedText = '';

    const answerId = 'answer-' + Date.now();

    chatContainer.innerHTML += `<p><strong>问:</strong>${question}</p>`;
    chatContainer.innerHTML += `<p><strong>答:</strong><span id="${answerId}"></span></p>`;

    // 清空输入框
    questionInput.value = '';

    const answerElement = document.getElementById(answerId);

    while (true) {
        const { done, value } = await reader.read();

        if (done) break;

        // 解码数据块
        const chunk = decoder.decode(value, { stream: true });

        // 解析每个 SSE 消息
        const messages = chunk.split('\n\n');
        for (const message of messages) {
            if (message.startsWith('data: ')) {
                try {
                    accumulatedText += message.substring(6);
                    answerElement.textContent = accumulatedText;
                } catch (e) {
                    console.error('解析消息失败:', e);
                }
            }
        }
    }

效果:

屏幕录制2025-05-17 18.44.01.gif

当然,在实际业务中并不会使用这么简单的数据格式,一般会使用自定义事件数据格式或者JSON字符串数据格式,在数据中包含各种类型,方便前端根据类型做出判断。

总结

SSE作为基于 HTTP 的轻量级实时通信技术,凭借其单向推送的核心特性,在服务器主动传输数据的场景中展现出独特优势。

vue2监听 iframe 内部滚动实现

大家好,我是瑶琴呀!

开门见山,这次需要实现的功能: iframe内嵌一个链接,现在需要实现让用户强制滚动到底部阅读后才可以进行下一步操作。 1.iframe是封装作为子组件,2.父组件调用iframe子组件监听滚动.

实现要点:

1.拿到iframe的DOM元素,并给iframe元素绑定滚动事件,

2.监听iframe的滚动不可以跨域,需发到测试环境下测试,本地无法调试成功

子组件实现:components\Iframe.vue

<template>
  <div>
    <iframe
      ref="iframe"
      :src="url"
      @load="handleLoad"
      frameborder="0"
      scrolling="auto"
      style="width: 100%; height: 100%"
    />
  </div>
</template>
<script>
export default {
  name: "iFrame",
  props: {
    url: "",
  },
  data() {
    return {};
  },
  methods: {
    handleLoad(e) {
      this.$emit('mounted', this.$refs.iframe); // 把真实 iframe DOM 传给子组件
    }
  }
};
</script>

父组件调用多个iframe,因为不止一个文档链接需要完整阅读

<div class="content">
        <Iframe
          ref="iframe"
          v-for="(item, index) in data"
          :key="index"
          v-show="active == index"
          :src="item.link"
          style="width: 100%; height: 100%"
          @mounted="(iframeEl) => onIframeMounted(index, iframeEl)"
        ></Iframe>
      </div> 

数据定义:

data() {
    return {
      active: 0,
      data: [],
      currentIframeRef: null, // 保存当前 iframe 引用
      iframeElements: [], // 存储所有 iframe DOM 元素
      isReachBottom: false, // 标识是否拉到底部
      readOverArr: [],// 存储已读的 iframe
    };
  },

父组件存储iframe 的DOM,这一步很重要,因为我们的iframe是以子组件引入的,没法通过父组件表层拿到对应DOM

// 接收iframe子组件传过来的iframe引用,并保存下来
    onIframeMounted(index, iframeEl) {
      // 保存每个 iframe DOM 元素
      this.iframeElements[index] = iframeEl;
      this.$nextTick(() => {
        this.bindIframeScroll();
      });
    },

给 iframe 绑定监听滚动事件,this.active 是用来找到对应的 iframe 的。

bindIframeScroll() {
      let self = this;
      // 移除之前的监听
      if (this.currentIframeRef && this.currentIframeRef.contentWindow) {
        try {
          this.currentIframeRef.contentWindow.removeEventListener("scroll", this.handleIframeScroll);
        } catch (e) {}
      }
      
      const iframeElement = this.iframeElements[this.active];
      if (!iframeElement) return;
      
      this.currentIframeRef = iframeElement;
        try {
          // ✅ 监听 contentWindow.scroll
          const contentWindow = iframeElement.contentWindow;
          
           // 这一步我直接写this.iframeElements[this.active]?.contentWindow 去拿到当前的 iframe,因为前面的contentWindow没有销毁的情况,导致切换到第二个iframe的时候,还没有滚动就显示滚动到底部。
           
          if (contentWindow) {
            contentWindow.addEventListener("scroll", (e) => {
              this.handleIframeScroll(e, this.iframeElements[this.active]?.contentWindow);
            });

            console.log("✅ 已绑定 scroll 事件");
          } else {
            console.warn("⚠️ iframe contentWindow 不存在");
          }
        } catch (e) {
          console.error("🚫 访问 iframe 出错:", e.message);
        }
    },
// 滚动过程
    handleIframeScroll(e,contentWindow) { 
      const iframe = e.target; // 当前滚动的 iframe 元素
      try {
        if (!contentWindow) {
          console.warn("⚠️ contentWindow 不存在");
          return;
        }
        const doc = contentWindow.document;
        if (!doc) {
          console.warn("⚠️ document 不存在");
          return;
        }

      const scrollTop =
        doc.documentElement.scrollTop ||
        doc.body.scrollTop ||
        window.pageYOffset ||
        0;

      const clientHeight =
        doc.documentElement.clientHeight ||
        doc.body.clientHeight ||
        contentWindow.innerHeight ||
        0;

      const scrollHeight =
        doc.documentElement.scrollHeight ||
        doc.body.scrollHeight ||
        0;

      if (scrollTop + clientHeight >= scrollHeight - 5) {
        this.handleReachBottom();
      }
    } catch (e) {
        this.isReachBottom = false;
        return;
    }
    },

    handleReachBottom() {
      this.isReachBottom = true;
      this.readOverList.push({  // 记录每一个链接都被完整阅读
        index: this.active,
        readOver: true,
      });
      
  },

切换不同的 iframe 时需要初始化阅读状态,并且重新绑定滚动监听

onClickTab(val) {
      this.isReachBottom = false;
      this.$nextTick(() => {
        this.bindIframeScroll();
      });
    },

Java中7个处理各类配置文件的工具

在Java应用开发中,选择合适的配置文件格式和处理工具对于提高开发效率和系统灵活性至关重要。

随着技术的发展,配置文件格式已从传统的Properties文件扩展到XML、JSON、YAML等多种形式。

1. Java Properties API

基本介绍

Java Properties API是JDK内置的工具,专门用于处理.properties文件,这是Java中最传统、使用最广泛的配置文件格式。

主要特点

  • JDK原生支持,无需额外依赖
  • 简单的键值对格式
  • 支持从文件、输入流、XML加载
  • 提供默认值机制

使用示例

import java.io.FileInputStream;
import java.io.IOException;
import java.util.Properties;

public class PropertiesDemo {
    
    public static void main(String[] args) {
        Properties properties = new Properties();
        
        // 从文件加载配置
        try (FileInputStream fis = new FileInputStream("config.properties")) {
            properties.load(fis);
            
            // 读取配置项(提供默认值)
            String dbUrl = properties.getProperty("database.url", "jdbc:mysql://localhost:3306/mydb");
            String username = properties.getProperty("database.username", "root");
            String password = properties.getProperty("database.password", "");
            
            System.out.println("Database URL: " + dbUrl);
            System.out.println("Username: " + username);
            System.out.println("Password: " + password);
            
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

适用场景

  • 简单的应用配置
  • 国际化资源文件
  • 传统Java应用
  • 需要向后兼容的系统

优缺点

优点

  • 简单易用,学习成本低
  • JDK内置,无需额外依赖
  • 广泛支持和使用

缺点

  • 不支持层级结构
  • 有限的数据类型支持(主要是字符串)
  • 不适合复杂配置

2. Jackson (JSON处理)

基本介绍

Jackson是当前Java生态系统中流行的JSON处理库之一,提供了完整的JSON序列化和反序列化功能,能够轻松处理JSON格式的配置文件。

主要特点

  • 完整的JSON处理功能
  • 强大的对象映射能力
  • 丰富的注解支持
  • 模块化设计
  • 高性能
  • 支持树模型和流式处理
  • 扩展性强,支持YAML等其他格式

使用示例

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.File;
import java.io.IOException;

public class JacksonConfigDemo {
    
    // 配置类
    public static class AppConfig {
        private String name;
        private DatabaseConfig database;
        private boolean debugMode;
        private List<String> supportedTypes;
        
        // Getters and setters
        // ...
    }
    
    public static class DatabaseConfig {
        private String url;
        private String username;
        private String password;
        private int maxConnections;
        
        // Getters and setters
        // ...
    }
    
    public static void main(String[] args) {
        ObjectMapper mapper = new ObjectMapper();
        
        try {
            // 1. 使用对象绑定方式读取配置
            AppConfig config = mapper.readValue(new File("config.json"), AppConfig.class);
            
            System.out.println("App Name: " + config.name);
            System.out.println("Debug Mode: " + config.debugMode);
            System.out.println("Database URL: " + config.database.url);
            
            // 2. 使用树模型方式读取配置
            JsonNode rootNode = mapper.readTree(new File("config.json"));
            String appName = rootNode.get("name").asText();
            boolean debugMode = rootNode.get("debugMode").asBoolean();
            JsonNode databaseNode = rootNode.get("database");
            String dbUrl = databaseNode.get("url").asText();
            
            // 3. 更新配置并保存
            config.debugMode = !config.debugMode;
            config.database.maxConnections = 20;
            mapper.writerWithDefaultPrettyPrinter()
                  .writeValue(new File("updated-config.json"), config);
            
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

适用场景

  • 复杂的配置结构
  • 需要对象映射的应用
  • 现代Web和微服务应用
  • RESTful API配置
  • 前后端统一的配置方案

优缺点

优点

  • 功能全面且强大
  • 高性能
  • 强大的对象映射和类型转换
  • 丰富的定制选项
  • 活跃的社区和文档支持
  • 与Spring等框架无缝集成

缺点

  • API较为复杂
  • 完整引入会增加依赖大小
  • 配置较为复杂

3. Apache Commons Configuration

基本介绍

Apache Commons Configuration提供了一个统一的接口来访问多种格式的配置文件,包括Properties、XML、JSON等,是一个功能丰富的配置管理库。

主要特点

  • 支持多种配置文件格式
  • 统一的配置接口
  • 配置合并和层次结构
  • 自动类型转换
  • 支持配置重载和变更通知

使用示例

import org.apache.commons.configuration2.Configuration;
import org.apache.commons.configuration2.builder.fluent.Configurations;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.configuration2.JSONConfiguration;
import org.apache.commons.configuration2.builder.FileBasedConfigurationBuilder;
import org.apache.commons.configuration2.builder.fluent.Parameters;

public class CommonsConfigDemo {
    
    public static void main(String[] args) {
        try {
            // 1. 简单用法:加载属性文件
            Configurations configs = new Configurations();
            Configuration propConfig = configs.properties("app.properties");
            
            String appName = propConfig.getString("app.name", "MyApp");
            int maxThreads = propConfig.getInt("app.max-threads", 10);
            boolean debugMode = propConfig.getBoolean("app.debug", false);
            
            System.out.println("Application Name: " + appName);
            System.out.println("Max Threads: " + maxThreads);
            System.out.println("Debug Mode: " + debugMode);
            
            // 2. 加载并处理JSON配置
            Parameters params = new Parameters();
            FileBasedConfigurationBuilder<JSONConfiguration> builder =
                new FileBasedConfigurationBuilder<>(JSONConfiguration.class)
                    .configure(params.fileBased()
                        .setFileName("config.json"));
            
            Configuration jsonConfig = builder.getConfiguration();
            String dbUrl = jsonConfig.getString("database.url");
            String[] supportedFormats = jsonConfig.getStringArray("app.supported-formats");
            
            System.out.println("Database URL: " + dbUrl);
            System.out.println("Supported Formats:");
            for (String format : supportedFormats) {
                System.out.println("- " + format);
            }
            
            // 3. 组合多个配置源
            Configuration compositeConfig = new CombinedConfiguration();
            ((CombinedConfiguration) compositeConfig).addConfiguration(propConfig);
            ((CombinedConfiguration) compositeConfig).addConfiguration(jsonConfig);
            
        } catch (ConfigurationException e) {
            e.printStackTrace();
        }
    }
}

适用场景

  • 需要支持多种配置格式的应用
  • 复杂的配置需求
  • 需要配置热重载的系统
  • 企业级应用

优缺点

优点

  • 统一的API处理多种格式
  • 丰富的功能集
  • 灵活的配置组合
  • 类型安全的配置访问

缺点

  • 相比简单的配置更复杂
  • 额外的依赖
  • 配置较为复杂

4. SnakeYAML

基本介绍

SnakeYAML是一个处理YAML格式文件的Java库。YAML格式因其人类可读性高、支持注释、层级结构清晰等特点,在现代应用配置中越来越受欢迎。

主要特点

  • YAML格式支持
  • 支持复杂的数据结构
  • Java对象与YAML的转换
  • 支持注释和引用
  • 集合和映射支持

使用示例

import org.yaml.snakeyaml.Yaml;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.util.Map;

public class SnakeYAMLDemo {
    
    public static void main(String[] args) {
        Yaml yaml = new Yaml();
        
        try {
            // 1. 加载YAML文件到Map
            Map<String, Object> config = yaml.load(new FileInputStream("application.yml"));
            
            // 访问嵌套配置
            Map<String, Object> server = (Map<String, Object>) config.get("server");
            int port = (int) server.get("port");
            
            Map<String, Object> spring = (Map<String, Object>) config.get("spring");
            Map<String, Object> profiles = (Map<String, Object>) spring.get("profiles");
            
            System.out.println("Server Port: " + port);
            System.out.println("Active Profile: " + profiles.get("active"));
            
            // 2. 直接映射到自定义类
            ServerConfig serverConfig = yaml.loadAs(
                new FileInputStream("server.yml"), ServerConfig.class);
            System.out.println("Max Threads: " + serverConfig.getMaxThreads());
            
            // 3. 处理多文档YAML
            Iterable<Object> documents = yaml.loadAll(new FileInputStream("multi-doc.yml"));
            for (Object document : documents) {
                System.out.println("--- Document ---");
                System.out.println(document);
            }
            
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
    }
    
    // 配置类
    public static class ServerConfig {
        private int port;
        private int maxThreads;
        private boolean ssl;
        
        // Getters and setters
        // ...
        
        public int getMaxThreads() {
            return maxThreads;
        }
    }
}

适用场景

  • 现代云原生应用
  • 复杂配置结构
  • 需要人类易读配置格式的项目
  • Kubernetes和Docker配置

优缺点

优点

  • 可读性强
  • 支持复杂数据结构
  • 支持注释
  • 简洁的表示方式
  • 广泛用于现代应用

缺点

  • 对空格敏感
  • 初学者可能容易出错
  • 解析错误信息有时不够清晰

5. Spring Boot Configuration

基本介绍

Spring Boot提供了强大的配置管理系统,支持多种配置源、配置文件层次结构和属性绑定。这是构建Spring Boot应用的核心功能之一。

主要特点

  • 支持多种配置格式(Properties、YAML)
  • 环境特定配置
  • 配置属性绑定到Java对象
  • 配置属性校验
  • 松散的绑定规则(支持不同命名风格)

使用示例

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;

@SpringBootApplication
@EnableConfigurationProperties(ServerProperties.class)
public class SpringConfigDemo {
    
    public static void main(String[] args) {
        SpringApplication.run(SpringConfigDemo.class, args);
    }
    
    @Bean
    public void displayConfig(ServerProperties serverProps) {
        System.out.println("Server Port: " + serverProps.getPort());
        System.out.println("Server Address: " + serverProps.getAddress());
        System.out.println("Max Threads: " + serverProps.getMaxThreads());
        System.out.println("SSL Enabled: " + serverProps.isSslEnabled());
    }
}

@Component
@ConfigurationProperties(prefix = "server")
class ServerProperties {
    
    @Min(1000)
    @Max(65535)
    private int port = 8080;
    
    @NotEmpty
    private String address = "localhost";
    
    private int maxThreads = 200;
    
    private boolean sslEnabled = false;
    
    // Getters and setters
    // ...
}

application.yml:

server:
  port: 9090
  address: 0.0.0.0
  max-threads: 100
  ssl-enabled: true

适用场景

  • Spring Boot应用
  • 微服务架构
  • 需要大量配置属性的应用
  • 多环境部署

优缺点

优点

  • 与Spring Boot无缝集成
  • 类型安全的属性绑定
  • 灵活的配置源支持
  • 环境隔离
  • 强大的校验功能

缺点

  • 依赖Spring生态系统,不适用于非Spring应用

6. INI4J - INI文件处理

基本介绍

INI4J是一个专门用于处理INI格式配置文件的Java库。INI文件是一种简单的配置文件格式,使用节(sections)和键值对组织数据,在某些场景下仍然非常实用。

主要特点

  • INI文件格式的完整支持
  • 支持节(sections)和子节
  • 简单的API
  • 双向操作(读写)
  • 支持注释
  • 类型转换功能

使用示例

import org.ini4j.Ini;
import org.ini4j.Profile.Section;
import java.io.File;
import java.io.IOException;

public class Ini4jDemo {
    
    public static void main(String[] args) {
        try {
            // 1. 读取INI文件
            Ini ini = new Ini(new File("config.ini"));
            
            // 2. 访问节和键值
            Section databaseSection = ini.get("database");
            String url = databaseSection.get("url");
            String username = databaseSection.get("username");
            String password = databaseSection.get("password");
            
            System.out.println("Database URL: " + url);
            System.out.println("Username: " + username);
            
            // 3. 带类型转换的值获取
            int port = databaseSection.get("port", int.class);
            boolean ssl = databaseSection.get("ssl", boolean.class);
            
            System.out.println("Port: " + port);
            System.out.println("SSL: " + ssl);
            
            // 4. 修改配置
            databaseSection.put("max_connections", 20);
            databaseSection.put("timeout", 30);
            
            // 5. 添加新节
            Section loggingSection = ini.add("logging");
            loggingSection.put("level", "INFO");
            loggingSection.put("file", "/var/log/app.log");
            
            // 6. 保存配置
            ini.store(new File("updated-config.ini"));
            
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

config.ini示例:

; Database configuration
[database]
url=jdbc:mysql://localhost:3306/mydb
username=root
password=secret
port=3306
ssl=true

; Application settings
[app]
name=MyApplication
version=1.0.0
debug=false

适用场景

  • 简单配置需求
  • 遗留系统集成
  • Windows应用程序配置
  • 用户偏好设置
  • 简单的应用设置

优缺点

优点

  • 简单直观的格式
  • 人类可读性好
  • 轻量级
  • 处理逻辑简单
  • 广泛支持,特别是在Windows环境

缺点

  • 不支持复杂数据结构
  • 缺乏标准化
  • 局限于简单的键值对和节

7. Typesafe Config (Lightbend Config)

基本介绍

Typesafe Config是Lightbend公司开发的配置库,支持HOCON (Human-Optimized Config Object Notation)、JSON和Properties格式。它在Akka、Play Framework等项目中广泛使用。

主要特点

  • 支持HOCON格式(JSON的超集)
  • 强大的引用和替换功能
  • 配置文件合并
  • 丰富的类型转换
  • 支持条件包含配置

使用示例

import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;
import com.typesafe.config.ConfigValue;

import java.util.Map;

public class TypesafeConfigDemo {
    
    public static void main(String[] args) {
        // 1. 加载配置(自动查找application.conf, application.json, application.properties)
        Config config = ConfigFactory.load();
        
        // 2. 获取嵌套路径的配置
        String dbUrl = config.getString("database.url");
        int dbPoolSize = config.getInt("database.connection-pool.size");
        
        // 3. 使用路径替换和引用
        String appLogDir = config.getString("app.log-dir");
        String accessLogPath = config.getString("app.log-paths.access-log");
        // 在application.conf中可以这样定义: 
        // app.log-paths.access-log = ${app.log-dir}"/access.log"
        
        // 4. 转换为Java Map
        Config dbConfig = config.getConfig("database");
        Map<String, Object> dbMap = dbConfig.root().unwrapped();
        
        // 5. 获取所有配置键
        for (Map.Entry<String, ConfigValue> entry : config.entrySet()) {
            System.out.println(entry.getKey() + " = " + entry.getValue().render());
        }
        
        // 6. 合并配置
        Config defaultConfig = ConfigFactory.parseResources("defaults.conf");
        Config customConfig = ConfigFactory.parseFile(new File("custom.conf"));
        Config mergedConfig = customConfig.withFallback(defaultConfig).resolve();
        
        // 7. 类型安全的时间和内存大小配置
        java.time.Duration timeout = config.getDuration("app.timeout");
        long maxMemory = config.getBytes("app.max-memory");
        
        System.out.println("Timeout: " + timeout.getSeconds() + " seconds");
        System.out.println("Max Memory: " + (maxMemory / (1024 * 1024)) + " MB");
    }
}

适用场景

  • Scala和Akka项目
  • 需要引用和变量替换的配置
  • 复杂配置结构
  • 现代反应式应用

优缺点

优点

  • 功能强大的HOCON格式
  • 灵活的引用和替换
  • 良好的类型支持
  • 支持条件包含

缺点

  • 项目中使用不如其他库广泛
  • 配置错误可能难以调试
  • 相对更高的学习曲线

总结

随着Java应用架构的演变,配置文件的格式和处理方式也在不断发展。从早期的Properties文件,到XML,再到现在流行的JSON和YAML,每种格式都有其优势和适用场景。

选择合适的配置处理工具应考虑项目的特定需求、团队熟悉度、性能要求和未来扩展性。

无论选择哪种工具,良好的配置管理实践(如分层结构、环境隔离、敏感信息处理)都是构建健壮、可维护应用的关键。

Step

Task

Write a shader that splits the screen into two parts: the left half should be black and the right half should be red. The shader should work for any screen resolution. To achieve this, consider using the iResolution uniform variable and gl_FragColor to control the position of the split.

编写一个着色器,将屏幕分成两部分:左半部分为黑色,右半部分为红色。该着色器应适用于任何屏幕分辨率。为此,请考虑使用iResolutionuniform 变量 和gl_FragColor来控制分割的位置。

Requirements

The shader should avoid using branching or conditional statements in its code, and instead rely on the step function to determine the color of each pixel.

着色器应避免在其代码中使用分支或条件语句,而是依靠step函数来确定每个像素的颜色。

Theory

step是一个阈值函数。如果传递的值小于阈值,step则返回0.0,否则返回1.0edge是阈值

函数

float step(float edge, float x)

  • 如果是x < edge,函数返回0.0
  • 如果是x >= edge,函数返回1.0

示例用法

float edge = 0.5;

float x = 0.3;

float result = step(edge, x); // x < edge ? 0.0 : 1.0

Answer

uniform vec2 iResolution;

void main() {
  vec2 uv = gl_FragCoord.xy/iResolution.xy;
  float result = step(0.5, uv.x);
  gl_FragColor = vec4(result, 0.0, 0.0, 1.0);
}

效果

image.png

练习

Step

最后

如果你觉得这篇文章有用,记得点赞、关注、收藏,学Shader更轻松!!

# Vue 中 provide/inject 与 props/emit 的对比与选择

一、核心设计理念差异

1. 数据流向的明确性

props/emit‌ 遵循严格的单向数据流:

// 父组件
<child-component :message="parentMsg" @update="handleUpdate" />

// 子组件
export default {
  props: ['message'],
  methods: {
    sendToParent() {
      this.$emit('update', newValue) // 明确的数据流向
    }
  }
}

provide/inject‌ 则是隐式的跨层级通信:

// 祖先组件
provide('sharedData', reactive({ value: null }))

// 任意后代组件
const data = inject('sharedData')
data.value = 123 // 来源不直观

二、必须使用 props/emit 的场景

1. 可复用组件开发

组件库中的按钮组件‌:

// 使用props定义明确接口
export default {
  props: {
    type: {
      type: String,
      default: 'default',
      validator: val => ['default', 'primary', 'danger'].includes(val)
    },
    disabled: Boolean
  },
  emits: ['click'], // 显式声明事件
  template: `
    <button 
      :class="['btn', `btn-${type}`]"
      :disabled="disabled"
      @click="$emit('click', $event)"
    >
      <slot></slot>
    </button>
  `
}

2. 父子组件明确契约

表单验证场景‌:

// 父组件
<validated-input
  :rules="[v => !!v || '必填项']"
  @valid="isFormValid = $event"
/>

// 子组件
export default {
  props: ['rules'],
  emits: ['valid'],
  watch: {
    inputValue() {
      const isValid = this.rules.every(rule => rule(this.inputValue))
      this.$emit('valid', isValid) // 明确的状态反馈
    }
  }
}

三、provide/inject 的适用边界

1. 适合使用 provide/inject 的场景

跨多层组件共享配置‌:

// 主题提供者组件
provide('theme', {
  colors: {
    primary: '#409EFF',
    danger: '#F56C6C'
  },
  darkMode: false
})

// 深层嵌套的按钮组件
const theme = inject('theme')
const buttonColor = computed(() => 
  theme.darkMode ? theme.colors.primary : '#333'
)

2. 不适合使用 provide/inject 的情况

列表项与父组件通信‌:

// 错误示范:使用inject修改父级状态
inject('parentMethods').updateItem(item) // 破坏组件独立性

// 正确做法:通过props/emit
props: ['item'],
emits: ['update'],
methods: {
  handleUpdate() {
    this.$emit('update', newItem) // 保持接口明确
  }
}

四、关键对比维度

维度 props/emit provide/inject
组件耦合度 低(明确接口) 高(隐式依赖)
可维护性 容易追踪数据流 调试困难
类型安全 支持完整类型定义 JavaScript中难以类型检查
适用层级 父子/直接关联组件 任意层级组件
测试便利性 可单独测试输入输出 需要构建完整上下文
代码可读性 接口清晰可见 需要查找provide源头

五、实际项目中的混合使用

1. 组合式API最佳实践

// 组件定义
export default {
  props: {
    // 必须的输入
    modelValue: { type: String }
  },
  emits: ['update:modelValue'],
  setup(props, { emit }) {
    // 注入应用级配置
    const appConfig = inject('appConfig')
    
    const handleInput = (e) => {
      // 本地事件处理
      emit('update:modelValue', e.target.value)
      
      // 同时使用注入的方法
      appConfig.trackInput?.(e.target.value)
    }
    
    return { handleInput }
  }
}

2. 设计模式选择指南

graph TD
    A[组件通信需求] --> B{通信方向}
    B -->|父→子| C[props]
    B -->|子→父| D[emit]
    B -->|兄弟组件| E[状态提升/全局状态]
    A --> F{层级深度}
    F -->|1-2层| G[优先props/emit]
    F -->|3+层| H[考虑provide]
    A --> I{复用性要求}
    I -->|高复用组件| J[必须用props/emit]
    I -->|内部实现细节| K[可用provide]

六、典型误用案例分析

1. 滥用 provide 导致的状态混乱

// 问题代码:多个组件通过inject修改同一状态
provide('globalState', reactive({ count: 0 }))

// 组件A
inject('globalState').count++

// 组件B
inject('globalState').count *= 2
// 无法追踪修改来源,调试困难

2. 应该使用 props 的场景

// 错误示范:用inject代替props
provide('userAvatar', avatarUrl)

// 正确做法:头像组件应该通过props接收数据
export default {
  props: {
    avatarUrl: String // 明确接口
  }
}

七、工程化考量

1. 项目可维护性影响

  • props/emit‌ 使组件成为"黑盒",通过接口文档即可理解功能
  • provide/inject‌ 需要查看组件实现才能理解依赖关系

2. 团队协作规范

// 良好的组件接口设计
export default {
  props: {
    // 带验证的props
    size: {
      type: String,
      default: 'medium',
      validator: s => ['small', 'medium', 'large'].includes(s)
    }
  },
  emits: {
    // 带验证的emit
    'size-change': payload => typeof payload === 'string'
  }
}

总结来说,props/emit 提供了组件间明确、可预测的通信方式,是构建可维护、可复用组件的基础;而 provide/inject 是特定场景下的补充方案,适用于真正需要穿透多层级的上下文共享场景。


Vue 中 provide/inject 与传统状态管理的深度对比

一、provide/inject 基础原理

1. 基本用法

// 祖先组件提供数据
export default {
  provide() {
    return {
      theme: 'dark',
      toggleTheme: this.toggleTheme
    }
  },
  methods: {
    toggleTheme() {
      this.theme = this.theme === 'dark' ? 'light' : 'dark'
    }
  }
}

// 后代组件注入使用
export default {
  inject: ['theme', 'toggleTheme'],
  template: `
    <button @click="toggleTheme">
      当前主题: {{ theme }}
    </button>
  `
}

2. 响应式数据传递

// 使用 Vue 3 的 reactive/ref
import { ref, provide } from 'vue'

export default {
  setup() {
    const count = ref(0)
    provide('count', count)
    
    return { count }
  }
}

// 后代组件
export default {
  setup() {
    const count = inject('count')
    return { count }
  }
}

二、provide/inject 的优势

1. 组件树穿透能力

场景‌:多层嵌套组件共享配置

// 根组件
provide('appConfig', {
  apiBaseUrl: 'https://api.example.com',
  features: {
    analytics: true,
    notifications: false
  }
})

// 第5层子组件直接使用
const config = inject('appConfig')
console.log(config.apiBaseUrl) // 直接访问

2. 减少 props 传递

传统方式‌:

// 每层组件都需要传递props
<Parent :config="config">
  <Child :config="config">
    <GrandChild :config="config" />
  </Child>
</Parent>

provide/inject 方式‌:

// 根组件
provide('config', config)

// 任意层级子组件
const config = inject('config')

3. 动态上下文共享

场景‌:表单组件与表单项通信

// Form 组件
provide('formContext', {
  registerField: (field) => { /* 注册字段 */ },
  validate: () => { /* 验证表单 */ }
})

// FormItem 组件
const { registerField } = inject('formContext')
onMounted(() => registerField(this))

三、provide/inject 的劣势

1. 调试困难

// 当多个祖先提供同名key时
const data = inject('settings') // 无法直观确认数据来源

// 解决方案:使用Symbol作为key
const SettingsKey = Symbol()
provide(SettingsKey, { theme: 'dark' })
const settings = inject(SettingsKey)

2. 缺乏状态管理

// 简单的计数器示例
provide('counter', {
  count: 0,
  increment() { this.count++ }
})

// 问题:
// 1. 状态变更无法追踪
// 2. 多个组件修改时可能产生冲突

3. 类型安全缺失(JavaScript中)

// 无法像TypeScript那样进行类型检查
const user = inject('user') // 不知道user的结构

四、与传统状态管理(Vuex)对比

1. Vuex 基本示例

// store.js
export default new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment(state) {
      state.count++
    }
  },
  actions: {
    asyncIncrement({ commit }) {
      setTimeout(() => commit('increment'), 1000)
    }
  }
})

// 组件中使用
export default {
  computed: {
    count() {
      return this.$store.state.count
    }
  },
  methods: {
    increment() {
      this.$store.commit('increment')
    }
  }
}

2. 对比表格

特性 provide/inject Vuex
作用范围 组件树局部 全局
调试工具 不可见 完整的时间旅行调试
响应式 自动响应式 自动响应式
代码组织 分散在各组件 集中式管理
类型安全 需要额外处理 需要类型定义
服务端渲染 天然支持 需要额外配置
性能 按需注入,内存友好 全局存储,初始加载稍慢
适用场景 组件库/局部状态共享 大型应用全局状态管理

五、实际场景选择指南

1. 适合 provide/inject 的场景

场景1:UI组件库开发

// 下拉菜单组件
provide('dropdown', {
  registerItem: (item) => { /* 注册菜单项 */ },
  close: () => { /* 关闭菜单 */ }
})

// 菜单项组件
const { registerItem, close } = inject('dropdown')
onMounted(() => registerItem(this))

场景2:主题切换

// 主题提供者
provide('theme', {
  current: 'light',
  colors: {
    light: { primary: '#fff' },
    dark: { primary: '#000' }
  }
})

// 任意子组件
const { current, colors } = inject('theme')
const bgColor = computed(() => colors[current].primary)

2. 适合 Vuex/Pinia 的场景

场景1:用户全局状态

// store/user.js (Pinia示例)
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    token: ''
  }),
  actions: {
    async login(credentials) {
      const res = await api.login(credentials)
      this.name = res.name
      this.token = res.token
    }
  }
})

// 多个组件共享同一状态
const userStore = useUserStore()
userStore.login({...})

场景2:购物车管理

// store/cart.js (Vuex示例)
{
  state: {
    items: []
  },
  mutations: {
    ADD_ITEM(state, item) {
      state.items.push(item)
    }
  },
  getters: {
    totalPrice: (state) => {
      return state.items.reduce((sum, item) => sum + item.price, 0)
    }
  }
}

// 组件中使用
this.$store.commit('ADD_ITEM', product)
this.$store.getters.totalPrice

六、混合使用模式

1. 全局状态 + 局部增强

// 使用Pinia作为基础
const userStore = useUserStore()

// 在特定组件树中增强功能
provide('enhancedUser', {
  ...userStore,
  // 添加局部方法
  sendMessage() {
    console.log(`Message to ${userStore.name}`)
  }
})

2. 性能优化技巧

javascriptCopy Code
// 避免在provide中直接传递大对象
provide('heavyData', () => fetchHeavyData())

// 组件中按需获取
const getHeavyData = inject('heavyData')
const data = computed(() => getHeavyData())

七、决策流程图

graph TD
    A[需要共享状态?] -->|是| B{状态使用范围}
    B -->|全局多组件| C[Vuex/Pinia]
    B -->|特定组件树| D{状态复杂度}
    D -->|简单配置| E[provide/inject]
    D -->|复杂业务逻辑| C
    A -->|否| F[使用组件本地状态]

总结‌:

  • provide/inject 适合组件库开发和局部状态共享
  • Vuex/Pinia 适合大型应用全局状态管理
  • 在JavaScript项目中,注意通过命名规范和Symbol来避免注入冲突
  • 对于中型项目,可以考虑混合使用两种方案

❌