阅读视图

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

温州商学院校长章晓洪:建议提升直接融资比重

6月28日至6月29日,2025上市公司论坛在温州举行。温州商学院校长、锦天城律师事务所高级合伙人章晓洪在论坛上表示,建议提升直接融资比重。同时,进一步优化市场环境,拓宽风险投资退出路径,减少对单一IPO退出方式的依赖,鼓励发展并购市场等多元化退出方式。(人民财讯)

山东省新能源商用车安全与节能重点实验室揭牌

6月29日,山东省新能源商用车安全与节能重点实验室(筹)第一届理事会暨学术委员会第一次会议在中通客车召开。 各方一致强调,省重点实验室聚焦新能源商用车战略需求和行业面临的共性技术问题,对于突破产业化技术瓶颈,构建和完善新能源技术创新链有重大意义。中国北方车辆研究所研究员毛明,聊城市政府党组成员、副市长周涛共同为“山东省新能源商用车安全与节能重点实验室(筹)”揭牌。(人民财讯)

🍀封装个指令实现复制内容到剪切板上

核心代码

import { ElMessage } from 'element-plus'
function copyToClipboard(textToCopy) {
  // navigator clipboard 需要 https 等安全上下文
  if (navigator.clipboard && window.isSecureContext) {
    return navigator.clipboard.writeText(textToCopy)
  }

  // 兼容性处理
  const textArea = document.createElement('textarea')
  textArea.value = textToCopy

  textArea.style.position = 'absolute'
  textArea.style.opacity = '0'
  textArea.style.left = '-9999px'
  textArea.style.top = '-9999px'
  document.body.appendChild(textArea)
  textArea.focus()
  textArea.select()
  return new Promise((resolve, reject) => {
    document.execCommand('copy') ? resolve('复制成功') : reject('复制失败')
    textArea.remove()
  })
}

export default {
  mounted(el, binding) {
    el.dataset.value = binding.value
    el.addEventListener('click', async () => {
      console.info('el.dataset.value', el.dataset.value)
      try {
        await copyToClipboard(el.dataset.value)
        // 可以在这里添加复制成功的提示
        ElMessage.success('复制成功')
      } catch (err) {
        console.error('Failed to copy text: ', err)
      }
    })
    el.classList.add('copy-btn')
  },
  updated(el, binding) {
    el.dataset.value = binding.value
  },
  unmounted(el) {
    el.removeEventListener('click', () => {})
  }
}

注册指令

directives.js

import copy from './copy'
export default app => {
  app.directive('copy', copy)
}

main.js

import { createApp } from 'vue'
import App from './App.vue'
import installDirective from '@/directives'

const app = createApp(App)
installDirective(app)

使用

<el-card>
  <adminTitle title="复制内容到剪切板" />
  <div>
    <span>{{ copyConent }}</span>
    <el-icon>
      <CopyDocument v-copy="copyConent" />
    </el-icon>
  </div>
  <el-input v-model="copyedContent" placeholder="ctrl + v 粘贴"></el-input>
</el-card>
<script setup>
const copyConent = ref('这是一段待复制的内容')
const copyedContent = ref('')
</script>

效果

动画6.gif

面试题:页面dom元素太多,如何优化

当DOM节点过多时,可能会导致页面渲染性能下降、内存占用增加等问题。以下是一些针对这种情况的优化方法:

减少DOM节点数量

  • 按需渲染:仅在用户实际需要时才渲染DOM节点。例如,对于页面中的折叠面板内容,初始时只渲染标题,用户点击展开时再动态创建并添加面板中的详细内容。可以使用JavaScript监听点击事件,然后动态创建和添加相关DOM节点。
  • 虚拟列表:在处理大量列表数据时,采用虚拟列表技术。只渲染当前可见区域以及附近的少量DOM节点。例如,在滚动加载列表中,只渲染屏幕可视范围内的列表项,当用户滚动时,动态调整渲染的列表项。像一些知名的电商商品列表页,向下滚动时不断加载新的商品DOM元素就是利用的这种思想。

优化DOM结构

  • 简洁的嵌套层级:保持DOM结构尽可能扁平简单,减少不必要的嵌套层级。例如,将一些不必要的div或span标签去掉,合并它们的样式和功能。
  • 合理使用DocumentFragment:DocumentFragment是文档片段,在内存中操作它,然后一次性将其附加到DOM树中。这样可以减少DOM更新的次数,提高效率。比如说要向一个容器中添加多个列表项,可以先将这些列表项创建到DocumentFragment中,最后再将DocumentFragment append到DOM节点上,从而减少浏览器重排和重绘的次数。

优化CSS操作

  • 批量更新CSS:通过将多个CSS属性合并成一个类,一次性应用到DOM节点上,而不是多次单独修改CSS属性。例如,如果要修改一个元素的颜色、字体大小和背景颜色,创建一个包含这些样式的类,然后直接应用这个类到元素上,而不是分别设置每个属性。
  • 避免内联CSS:内联CSS会增加HTML的体积,且不易维护和缓存。尽量将CSS样式提取到外部样式表中。

懒加载

  • 图片懒加载:对于页面中不在可视区域的图片,采用懒加载技术。当图片进入可视区域时,再加载图片内容。可以使用第三方库如LazyLoad或Intersection Observer API来实现 。例如在一个长网页中,用户在滚动浏览过程中,图片组件在快显示的时候触发加载逻辑,这样就不会一次性加载大量图片,减少了DOM和资源使用。
  • 脚本懒加载:非必要的脚本可以设置为懒加载。在HTML中的<script>标签上使用asyncdefer属性,async属性会使脚本在下载完成后立即执行,不会阻塞文档的解析;defer属性会使脚本在文档解析完成后、DOMContentLoaded事件触发前执行,这样可以防止脚本加载影响页面渲染,尤其是在脚本数量较多的情况下。

性能优化工具

  • 分析工具:使用浏览器开发者工具(如Chrome DevTools)来分析页面性能。利用Performance面板可以看到各项性能指标,比如时间线分析,能查看渲染过程中的瓶颈。通过这些工具判断性能问题主要出现在DOM加载、渲染还是脚本执行环节,针对性进行优化。
  • 代码压缩工具:在项目构建过程中,使用代码压缩工具(如UglifyJS、CSSNano等)对HTML、CSS和JavaScript代码进行压缩,减少文件体积,提高加载速度。

附带-如何监控DOM节点的数量

在JavaScript中,有多种方法可以监控DOM节点的数量。以下是一些常见的方式:

通过原生JavaScript

  1. 直接计算
  • 可以使用 document 对象的 querySelectorAll 方法选择所有的DOM节点,然后获取其 length 属性。例如:
function countAllNodes() {
 const allNodes = document.querySelectorAll('*');
 const nodeCount = allNodes.length;
 console.log(`当前DOM节点数量为: ${nodeCount}`);
}
// 调用函数以获取当前数量
countAllNodes();
  • 如果只关心特定类型的节点,可以在 querySelectorAll 的选择器中指定类型。比如,要统计所有 div 元素的数量:
function countDivNodes() {
  const divNodes = document.querySelectorAll('div');
  const divNodeCount = divNodes.length;
  console.log(`当前div节点数量为: ${divNodeCount}`);
}
countDivNodes();
  1. MutationObserver 监听节点变化
  • MutationObserver 是HTML5新增的特性,它可以观察DOM的变化。通过监听 childNodes 的变化,就可以监控节点数量的增减。例如:
const observer = new MutationObserver((mutationsList) => {
    for (const mutation of mutationsList) {
        if (mutation.type === 'childNodes') {
            const allNodes = document.querySelectorAll('*');
            const nodeCount = allNodes.length;
            console.log(`当前DOM节点数量变为: ${nodeCount}`);
        }
    }
});

// 配置观察选项
const config = { childList: true, subtree: true };
// 开始观察
observer.observe(document.documentElement, config);

在上述代码中,MutationObserver 监听 document.documentElement(即HTML根元素)及其子树的 childNodes 变化。一旦有变化发生,就重新计算所有DOM节点的数量并打印出来。

创纪录!巴菲特再捐60亿美元伯克希尔股票,总额超600亿美元

当地时间6月27日,巴菲特宣布将向比尔及梅琳达·盖茨基金会以及四个家族慈善机构,捐赠价值60亿美元的伯克希尔·哈撒韦股票。这也是巴菲特自近二十年前开始捐赠其财富以来最大规模的一次年度捐赠。 这将使巴菲特对这些慈善机构的累计捐赠总额超过600亿美元大关。(澎湃新闻)

中国广电5G应急通信技术系列试点工作完成

近日,中国广电集团组织中广电移动、广东广电网络在广东汕尾市完成模拟“三断”场景下的5G应急通信技术系列试点,实现三项行业首创:全国首个广播电视大塔5G 700MHz应急通信堡垒基站、业界首个5G 700MHz便携应急通信背包站、全国首例广播电视大塔堡垒基站保障下的高可靠5G RedCap应急广播,为极端灾害场景打造“新型广电应急通信方案”。(财联社)

上海发布大型游乐设施安全运营合规指引,包含多条禁止性规定

6月29日,“全国特种设备安全日”上海主场活动在普陀区长风湾党群服务中心举行。活动上,上海市市场监管局发布《上海市大型游乐设施使用单位安全运营合规指引》。 《指引》规定:不得采购超过设计使用年限的大型游乐设施;禁止使用国家明令淘汰和已经报废的大型游乐设施;未经检验或者检验不合格的大型游乐设施,不得继续使用;运营过程中如发生异常情况,应当停机检查、严禁带病运营。(澎湃新闻)

红板科技主板IPO获受理

6月28日,上交所官网显示,江西红板科技股份有限公司(以下简称“红板科技”)主板IPO获得受理。 据了解,红板科技专注于印制电路板的研发、生产和销售,产品定位于中高端应用市场,具有高精度、高密度和高可靠性等特点,是行业内HDI板收入占比较高、能够批量生产任意互连HDI板和IC载板的企业之一。 本次冲击上市,红板科技拟募集资金约20.57亿元,扣除发行费用后拟投资于年产120万平方米高精密电路板项目。(北京商报)

银河证券下半年大类资产展望:COMEX黄金价格中枢极端风险场景下具备冲击3500美元/盎司可能性

中国银河证券发布2025年下半年大类资产展望:(1)黄金:COMEX黄金价格中枢或将稳步突破3300美元/盎司,在极端风险场景下甚至具备冲击3500美元/盎司的可能性。(2)原油:若三季度地缘局势继续发酵,运输瓶颈及季节性需求旺季影响,不排除WTI油价冲击75美元/桶的可能;四季度随着需求转弱及OPEC+恢复增供兑现,WTI油价中枢有望回归60美元/桶附近。(人民财讯)

玉禾田启用新总部,正式进军机器人领域

6月28日,玉禾田环境发展集团股份有限公司正式启用新总部——深圳福田区玉禾田大厦,并举办与智元机器人的合作签约仪式。据了解,玉禾田与智元机器人共同发起“玉禾田智创中心”,聚焦人工智能、多模态交互等前沿领域。另外,玉禾田同期宣布成立深圳玉树智能机器人有限公司(注册资本5000万元),聚焦无人驾驶清扫车、智能分拣机器人等装备研发。(界面新闻)

一周市场盘点 | A股创年内新高;曹操出行登陆港交所成港股最大出行平台;石头科技等多家企业寻求港股上市

一周市场回顾

A股

本周(6月23日~27日)A股大盘强势反弹,上证指数重回3400点上方。整体来看,周内上证指数收涨1.91%;深证成指收涨3.73%;创业板指收涨5.69%;沪深300收涨1.95%;科创50收涨3.17%;中证1000收涨4.62%。板块方面,周内31个申万一级行业中25个出现上涨,其中,非银金融、计算机、通信表现居前;石油石化、煤炭、交通运输表现落后。

港股

本周港股迎来全面升温,各关键指数均录得不错涨幅。恒生指数周内上涨3.20%;恒生科技指数在本周上涨4.06%。板块方面,12个恒生行业指数中除了公用事业外,其他行业均录得上涨。

美股

美股三大指数在本周全面上涨,道琼斯工业指数周内上涨3.82%、纳斯达克上涨4.25%,标普500上涨3.44%。

一周利好

国家发改委:培育壮大新兴产业、未来产业,深入开展“人工智能+”行动

国家发展改革委党组指出,加快建设现代化产业体系。推动科技创新和业创新融合发展。加快培育发展新质生产力,改造提升传统产业,培育壮大新兴产业、未来产业,深入开展“人工智能+”行动。做强做优做大创新型企业。设立国家创业投资引导基金,聚焦人工智能、量子科技、氢能储能等前沿领域,投资种子期、初创期企业,适当兼顾早中期中小微企业,畅通资金市场化管理与退出机制。加力破除“内卷式”竞争。(学习时报)

国家发改委:第三批消费品以旧换新资金将于7月下达

6月26日,国家发展改革委政策研究室副主任李超表示,将于今年7月份下达今年第三批消费品以旧换新资金。同时,将协调有关方面,坚持更加注重持续性和均衡性原则,分领域制定落实到每个月、每一周的国补资金使用计划,保障消费品以旧换新政策全年有序实施。(证券时报)

高盛:中国经济增长仍较有韧性,维持对中国股市超配建议

“市场配置方面,我们维持对A股和港股的超配建议,预计沪深300目标点位为4600点,MSCI China目标点位为84点,隐含约10%的上行空间。”在6月23日举行的媒体见面会上,高盛中国股票策略分析师付思表示。行业配置方面,付思表示,高盛近期调高了银行和地产板块的评级,主要受益于国内政策支持,同时继续超配以消费为主的板块,包括医疗器械、消费服务、传媒和电商零售等细分领域。高盛中国经济学家王立升指出,中国经济增长短期仍较有韧性,今年下半年政策对冲会明显加码。

宁德时代:公司凝聚态电池能量密度最高可达500Wh/kg,正在进行民用电动载人飞机项目的合作开发

36氪获悉,宁德时代在互动平台表示,公司凝聚态电池能量密度最高可达500Wh/kg,正在进行民用电动载人飞机项目的合作开发,执行航空级的标准与测试,满足航空级的安全与质量要求。

小米MIX Flip 2折叠手机、REDMI K80至尊版等10余款新品天猫首发

36氪获悉,6月26日晚,小米MIX Flip 2折叠手机、小米AI眼镜、小米Watch S4智能手表、小米手环10、小米开放式耳机Pro、REDMI K80至尊版等十余款新品天猫首发,部分新品首发即享受国补及分期免息等优惠。当晚,小米天猫官方旗舰店来访量再创新高,650万人涌入抢购。

小米集团发布小米AI眼镜

36氪获悉,小米集团发布小米AI眼镜,支持第三方App视频通话、直播。定价1999元起。

我国自主研发新一代国产通用处理器发布

处理器(CPU)是计算机的核心组成部分,就像“大脑”一样指挥各个部件的运行。今天(26日),我国自主研发的新一代国产通用处理器——龙芯3C6000在北京发布。最新发布的龙芯3C6000采用我国自主设计的指令系统龙架构,无需依赖任何国外授权技术,是我国自主研发、自主可控的新一代通用处理器,可满足通算、智算、存储、工控、工作站等多场景的计算需求。目前,3C6000系列处理器已获《安全可靠测评公告》当前最高等级二级认证,可确保关键领域应用安全。(央视新闻)

小马智行纳入纳斯达克中国金龙指数

36氪获悉,近日,纳斯达克中国金龙指数对其成分股进行新一轮调整,中国Robotaxi公司小马智行正式纳入其中。金龙指数是中概股投资标的风向标,纳入该指数意味着以小马智行代表的中国自动驾驶科技进入主流投资视野,吸引ETF基金、对冲基金、长线投资者的投资,公司股票流动性和资本市场地位将进一步提升。

苹果官网首次支持国补,最高补贴不超2000元

36氪获悉,苹果官网首次支持国补,活动页面显示,指定产品享国家补贴至高省2000元。其中,符合资格的消费者购买参与活动的iPhone、iPad、Apple Watch时,可享商品到手价基础上再立减15%的国家补贴,每件补贴不超过500元,仅限到手价不超过6000元的单件商品;购买参与活动的Mac时,可享商品到手价基础上再立减20%的国家补贴,每件补贴不超过2000元。需要注意的是,活动仅限Apple Store在线商店(北京收货地址)和Apple Store零售店(上海地区)。

浪潮信息:拟以2亿元-3亿元回购公司股份

36氪获悉,浪潮信息公告,公司拟使用自有资金和自筹资金以集中竞价交易方式回购公司部分A股股份,用于减少公司注册资本。本次回购资金总额不低于人民币2亿元(含)且不超过人民币3亿元(含),回购价格不超过人民币75.59元/股。回购期限为自公司股东大会审议通过本次回购股份方案之日起12个月内。公司近日取得中国工商银行股份有限公司山东省分行出具的《上市公司股票回购贷款承诺函》,贷款金额不超过人民币2.7亿元,贷款期限不超过3年。

一周利空

下周68股面临解禁

下周(6月30日—7月6日)将有68只股解禁,按照最新收盘价计算,合计解禁市值816.71亿元。中无人机下周将有4.03亿股上市流通,主要为首发原股东限售股份,解禁市值达200.2亿元。迪哲医药-U的解禁规模次之,下周将有2.23亿股上市流通,解禁股主要为首发原股东限售股份,解禁市值达132.72亿元。相比之下,盖世食品、智明达、福莱特、衢州发展等6股的解禁压力较小,解禁市值均不足千万元。(证券时报)

博纳影业:中信证投等股东拟减持公司不超5.0016%股份

36氪获悉,博纳影业发布公告,股东中信证券投资有限公司及其一致行动人拟减持公司股份不超过4097.64万股(占公司总股本的3%)。股东浙江东阳阿里巴巴影业有限公司拟减持公司股份不超过2733.89万股(占公司总股本的2.0016%)

锦盛新材:因涉嫌信息披露违法违规,证监会决定对公司立案

36氪获悉,锦盛新材发布公告,公司于2025年6月27日收到证监会下发的立案告知书,因公司涉嫌信息披露违法违规,根据相关法律法规,证监会决定对公司立案。

黄仁勋近期已减持22.5万股英伟达股票,累计套现近3320万美元

 当地时间6月26日,英伟达向美国证券交易委员会(SEC)提交的文件显示,公司CEO黄仁勋6月20日以来已累计减持22.5万股英伟达股票,套现近3320万美元。(界面)

国家统计局:多重因素影响工业企业利润下降

36氪获悉,国家统计局工业司统计师于卫宁表示,多重因素影响工业企业利润下降。1—5月份,规模以上工业企业实现利润总额27204.3亿元,比1—4月份增加6034.1亿元,但是受有效需求不足、工业品价格下降及短期因素波动等多重因素影响,同比下降1.1%。从利润构成看,投资收益等短期因素的上年同期基数较高,下拉1—5月份规上工业企业利润增速1.7个百分点。

IPO留言板

新股上市

曹操出行

6月25日,曹操出行在港交所主板挂牌上市,成为港股最大的出行平台。

招股书显示,截至2025年3月31日,曹操出行覆盖城市增至146座,一季度总GTV同比增长54.9%,订单量同比增长51.8%,取得收入42亿元,毛利率升至8.5%,各项核心指标较2024年同期均实现大幅提升。

截止到首日收盘,曹操出行收跌14.2%,报36.00港元/股,总市值196亿港元。

周六福

6月26日,周六福正式登陆港交所。周六福集珠宝产品的开发设计、采购供应、加盟、品牌运营为一体。 截至2024年末,公司有4129家门店,包括4038家加盟店和91家自营店,加盟店占比达97.80%。 

2022年-2024年,公司收入分别为31.02亿元、51.5亿元、57.18亿元;同期年内溢利分别为5.75亿元、6.6亿元、7.06亿元,业绩持续攀升。

截止到首日收盘,周六福收涨25.00%,报30.00港元/股,总市值115亿港元。

上市进行时

安井食品

36氪获悉,安井食品在港交所公告,公司拟全球发售3999.47万股H股(视乎超额配股权行使与否而定),中国香港发售股份399.95万股,国际发售股份3599.52万股;2025年6月25日至6月30日招股,预期定价日为7月2日;发售价将不高于66.00港元/股,每手买卖单位为100股,高盛及中金公司为联席保荐人;预期股份将于2025年7月4日开始在联交所买卖。

紫光展锐

紫光展锐(上海)科技股份有限公司上市辅导备案材料获备案登记,辅导机构为国泰海通。该公司从事芯片设计,拟在A股IPO。(科创板日报)

亚电科技

36氪获悉,据上交所,江苏亚电科技股份有限公司(简称“亚电科技”)科创板IPO获受理。

紫金黄金国际

IFR援引知情人士报道称,紫金黄金国际计划在未来几周申请香港IPO,融资约10亿至20亿美元。(财联社)

瑞为技术

厦门瑞为信息技术有限公司(Reconova)据悉计划赴港IPO。(新浪财经)

富卫集团

36氪获悉,6月26日,李泽楷旗下富卫集团在港交所公告,于2025年6月26日-2025年7月2日招股,拟全球发售9134.21万股,其中香港公开发售占10%,国际发售占90%,另有15%的超额配股权。每股发售股份38.00港元,每手100股,预期股份将于2025年7月7日(星期一)上午九时正开始在联交所买卖。

镁伽机器人

据知情人士透露,北京镁伽机器人科技有限公司计划最早今年在香港进行首次公开募股 (IPO)。知情人士表示,这家将机器人和人工智能应用于生命科学研究的公司正在与顾问合作,希望通过上市筹资3亿美元或更多。知情人士称,相关考虑仍在进行中,发行规模和时间等细节可能还会调整。(新浪财经)

斯坦德机器人

36氪获悉,近日,斯坦德机器人(无锡)股份有限公司向港交所主板提交上市申请书,中信证券、国泰君安国际为联席保荐人。爱企查App显示,斯坦德机器人(无锡)股份有限公司成立于2016年6月,法定代表人为王永锟,注册资本约2210.52万元人民币,经营范围包括智能机器人的研发、人工智能应用软件开发、智能机器人销售等。融资信息显示,该公司已完成多轮融资,投资方包括博华资本、小米集团、中信建投资本等。

禾元生物

上交所上市审核委员会发布审议会议公告,定于7月1日召开上市审核委员会审议会议,审议科创板拟上市企业武汉禾元生物科技股份有限公司的发行上市申请。根据禾元生物招股书上会稿,该公司拟采用科创板第五套标准上市。这是6月18日中国证监会主席吴清在2025陆家嘴论坛宣布在科创板设置科创成长层,并且重启未盈利企业适用科创板第五套标准上市以来,首家上会的科创板第五套标准企业。(证券时报)

沐曦

36氪获悉,证监会官网显示,国产GPU厂商沐曦集成已经完成IPO辅导,辅导机构华泰联合证券。

大普微

6月27日,深圳大普微电子股份有限公司(简称“大普微”)首发上市申请获深交所受理,成为创业板首家获受理的未盈利企业。创业板支持优质未盈利企业上市,有助于进一步加大板块对优质科技企业的包容性、适应性和精准支持力度,引导更多先进生产要素向科技领域聚集。深交所相关负责人表示,深交所严格落实创业板各套上市标准,为初盈利、未盈利企业提供更多包容性,支持优质创新企业拓宽融资渠道。(证券时报)

蓝思科技

据媒体报道,蓝思科技计划在香港上市筹资6亿-7亿美元。(财联社)

石头科技

36氪获悉,据港交所文件,北京石头世纪科技股份有限公司向港交所递交上市申请书,联席保荐人为摩根大通和中信证券。

荣耀终端

36氪获悉,据中国证监会网站,荣耀终端股份有限公司获上市辅导备案,辅导券商为中信证券。

壁仞科技

市场消息:壁仞科技开展新一轮融资,计划在香港上市。(新浪财经)

合合信息

36氪获悉,据港交所文件,上海合合信息科技股份有限公司向港交所提交上市申请书,独家保荐人为中金公司。

蓝色光标

36氪获悉,北京蓝色光标数据科技集团股份有限公司向港交所提交上市申请书,联席保荐人为华泰国际、国泰君安国际、华兴资本。 

市场消息

6月A股IPO受理迎小高峰,上半年超百家公司“赶考”

据初步统计,截至6月26日晚,沪深北三大交易所今年上半年合计新受理拟上市企业数量113家。记者采访了解到,6月IPO受理升温有两大动因:一是受财报更新影响,拟IPO企业的招股书财务报表在其最近一期截止日后6个月内有效,为避免后续补充更新财报数据,部分发行人和保荐机构集中在6月申报IPO,为后续IPO争取更长有效期;二是受政策驱动,新一轮科创板改革增强科创板制度包容性、适应性,更好服务科技创新和新质生产力发展,进一步向优质硬科技企业释放了积极信号。(上证报)

港股机器人板块的“生态圈”正在快速扩容

港股机器人板块的“生态圈”正在快速扩容。一方面,优必选、地平线机器人、越疆等代表公司构建起龙头地位,另一方面,卧安机器人、云迹科技、乐动机器人等一批内地机器人企业正加速冲刺港股IPO,此外,埃斯顿、石头科技等A股公司在积极推进A+H上市计划。(证券时报)

美国SEC与交易所考虑放宽上市公司监管规则

美国各大证券交易所与证券交易委员会(SEC)正考虑放宽上市公司监管要求,旨在重振IPO市场活力并遏制上市公司数量持续下滑的趋势。SEC表示正在评估相关改革方案以促进资本形成,纳斯达克与纽约证券交易所已证实正与监管机构进行磋商。(新浪财经)

北交所IPO受理节奏加速,一日新增受理13家

北交所IPO受理节奏加速,6月25日共有晨光电机、华大海天等13家公司IPO获北交所集中受理,单日受理家数创2024年以来新高,跻身北交所单日受理数量前五。本周以来,北交所累计受理27家公司IPO申请。(证券时报)

试点IPO预先审阅机制,提升服务优质科技型企业质效

降低优质科技型企业过早或长期披露业务技术信息、上市计划可能对企业生产经营造成的不利影响,是不少科技企业的现实诉求。《关于在科创板设置科创成长层增强制度包容性适应性的意见》(下称《科创板意见》)明确“面向优质科技型企业试点IPO预先审阅机制”,标志着中国资本市场借鉴境外成熟的“秘密递交”制度,并将其作为IPO预沟通服务的一项内容,进一步提升服务科技型企业的质效。在市场人士看来,IPO预先审阅机制回应了部分关键核心技术攻关企业的关切,可减少相关企业在发行上市阶段的“曝光”时间,更好保护企业信息安全、技术安全。同时,优质企业上市周期或进一步压缩,审核效率有望进一步提升。(中证报)

vue3+node后台管理系统实战——1.利用小程序实现web登录

有任何问题,都可以私信博主,共同探讨学习。

项目示例地址:中二少年学编程的示例项目

本项目适合熟悉vue3、nodejs基础,并希望了解实战应用的同学;适合想要学习web全栈开发的同学;适合大学生作业、毕业设计参考,有任何问题,请随时联系博主。


一、技术选型

前端:vue3+viewui 后端:nodejs+midwayjs+typeorm 数据库:mysql

二、设计方案

大部分网站的登录模块包含两个功能:注册和登录。

注册主要目的是在系统中申请一个账户和密码,作为后续登录的凭证。但是出于防止恶意注册、便于找回密码、防脚本批量注册等多方面考虑,大部分注册行为还会考虑邮箱验证、手机号验证等功能。

如果同学们开发的网站是面向普通用户(to C),并且有巨大市场潜力的项目,那么可以按照常规注册模式开发项目。

但是很多后台管理系统其实面向的是特定的企业用户(to B),并不需要开发注册功能,只需要超级管理员创建并管理用户即可。

我开发的demo网站,属于后台管理系统,是解决面向企业用户的场景,但是我做出这个demo又是为了让所有同学都可以体验参考。所以我为demo网站设计了两种登录模式:一是便捷的注册登录功能;二是通过超管创建用户后,常规的登录功能。

注册功能想要便捷,就不能沿用手机号验证那套规则,那样可能会吓跑大部分嫌麻烦的同学,利用微信登录又需要每年支付300元认证费用,所以我就设计实现了利用微信扫码跳转小程序,通过小程序验证后,实现登录的功能。

这套方案非常适合想要低成本、便捷地通过微信实现扫码登录的中小企业、学生、个人开发者等群体。

至于登录的逻辑就很简单了,不管利用哪种方式实现,本质都是将前端采集的用户和密码发送到后端,后端与数据库中保存的用户信息匹配,如果匹配成功,则登录成功,如果匹配失败,则返回登录失败。

三、小程序扫码登录

小程序的实现方式略微复杂,而且这是博主自己思考的方案,网上应该是没有太多参考资料。如果仅仅是学习基础的登录技术原理,来应付企业管理系统开发,并不需要学习本章节。

3.1 后端调用小程序官方api生成小程序码

后端调用小程序的官方api生成带参数的小程序码,参数的key值我们设定为cId,后面要用。生成小程序码,可以指定扫描后进入的页面,我们设置为扫码进入“我的”页面。

在这里插入图片描述

代码实现:

 /**
   * 根据token获取微信小程序码
   * @Param access_token - 微信token
   * @Param env_version - 环境版本:develop,release
   * */
  async getWXACodeUnlimited(access_token: string, env_version: string) {
    const url = 'https://api.weixin.qq.com/wxa/getwxacodeunlimit';
    const cId = nanoid()
    const data = {
      scene: 'cId=' + cId,
      page: 'pages/about/about',
      env_version
    }
    const params = {
      access_token
    }
    const result = await this.httpService.request({
      url,
      method: 'POST',
      responseType: 'arraybuffer', // 指定响应类型为二进制数据
      data,
      params
    })
    return {
      imgData: result.data,
      cId
    }
  }

代码讲解:

1.api.weixin.qq.com/wxa/getwxac…

2.const cId = nanoid():使用nanoid插件生成随机的参数

3.await this.httpService.request:使用内置的服务调用接口,同学们不论使用什么工具都是可以的。

3.2 前端请求接口,获取小程序码

3.1章节返回的是小程序码的图片数据和cId参数,前端请求对应的后端接口,获取小程序码图片并显示。

在这里插入图片描述转存失败,建议直接上传图片文件

代码实现:

  /**
     * 获取小程序码
     * 返回值-小程序码参数*/
    const miniAppSceneCodeUrl = ref('') //带参数的二维码
    async function getWXACodeUnlimited() {
        miniAppSceneCodeUrl.value = ''
        let imgRs = {}
        try {
            imgRs = await getWXACodeUnlimitedApi({
                env_version: 'release',
            });
        } catch (e) {
            imgRs = {
                success: false
            }
        }
        // 将 Buffer 数据转换为图片 URL
        // 1. 将数组转换为 Uint8Array
        const uint8Array = new Uint8Array(imgRs.data.imgData.data);

        // 2. 将 Uint8Array 转换为 Blob
        const blob = new Blob([uint8Array], {type: 'image/png'}); // 根据实际图片类型设置 MIME 类型

        // 3. 生成图片 URL
        miniAppSceneCodeUrl.value = URL.createObjectURL(blob);
        return imgRs.data.cId
    }

对应的html代码:

           <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
            </div>

代码解释:

1.getWXACodeUnlimitedApi:调用后端接口,获取图片信息和cId参数值。

2.后面操作buffer的三行代码可以省略,我的后端返回的图片数据是buffer二进制,如果同学们直接返回base64数据,会更简单,直接在前端src中赋值即可显示。之所以返回buffer,是因为我的服务器带宽很差,只能尽量压缩前后端交互数据的大小。

3.miniAppSceneCodeUrl:小程序码的url,获取后直接在前端html中显示。

3.3 小程序监听扫码登录行为

用户扫描小程序码时,会进入小程序并直接跳转“我的”页面,小程序的“我的”页面监听当页面渲染后,是否携带了参数cId。

如果小程序监听用户跳转行为携带了cId,说明用户在触发扫码登录行为,则保存cId到用户信息,如果没有携带cId,属于普通跳转行为,不做任何操作。

代码示例:

onLoad(async (option) => {
if (option.scene) {
const scene = decodeURIComponent(option.scene)
const params = parseScene(scene); // 解析为键值对
saveCId(params.cId)
}

})

代码解释:

1.onLoad:小程序端是用uniapp开发的,onLoad是页面加载的生命周期。

2.option.scene :监听页面加载后,页面携带的参数。

3.saveCId:保存cId到数据库中的用户信息表中。这部分代码涉及小程序部分功能,略微复杂,涉及很多复杂的判断。比如如果用户第一次登录小程序,还需要先等待小程序创建新用户后,再保存cId到数据库中的用户信息。这些属于小程序的功能开发了,不在本次登录功能的介绍中,所以不再赘述。同学们如果想要实现类似效果,只要能做到在这一步将cId保存到数据库中用户信息即可。

3.4 以cId为筛选条件轮询用户信息

前端在3.2章节中获取小程序码时,getWXACodeUnlimited方法同时返回了cId,详见3.2章节中的代码。

获取到cId后,前端即可以cId为筛选条件,对用户信息表执行轮询操作。因为每一个cId都是随机生成的,当发现用户信息表中出现符合的数据时,说明用户已经扫码登录成功,前端页面就可以放行,显示登录成功了。

代码实现:

    const showRefreshCode = ref(false)
 /**
     *根据小程序码参数轮询用户信息 */
    function getUserInfoBySceneCode(sceneCode) {
        if (!sceneCode) return
        let getApiCount = 0 //当前轮询次数
        let maxApiCount = 3  //最大的轮询次数
        let getApiSuccess = false //轮询是否成功

        getUserInfoInterval.value = setInterval(async () => {
            if (getApiCount >= maxApiCount) {
                // 超过10次,停止轮询
                clearInterval(getUserInfoInterval.value);
                showRefreshCode.value = true
                if (getApiCount === maxApiCount && !getApiSuccess) {
                    // 十次轮询都未成功,显示错误信息
                    console.error('十次轮询均失败:', getApiSuccess);
                    Message.error('扫码登录失败,请重试');
                    showRefreshCode.value = true;
                }
            }
            getApiCount++
            try {
                const userInfoRs = await getUserInfoBySceneCodeApi({
                    sceneCode: sceneCode
                })
                if (userInfoRs.success) {
                    getApiSuccess = true
                    Message.success('扫码登录成功')
                    // 登录成功后,维护全局变量
                    await handleLogIn(userInfoRs)
                    router.push({name: 'home'})
                    // 成功后停止轮询
                    clearInterval(getUserInfoInterval.value);
                }
            } catch (error) {
                console.error('根据小程序码参数获取用户信息失败:', error);
            }
        }, 3000); // 每3秒轮询一次
    }

上面代码就是简单的轮询代码,并没有什么技术点需要讲解。

3.5增加小程序码过期机制

前端无限制地轮询请求,可能会影响性能,所以应该设置一个机制,轮询一定次数后,则认定本次登录行为过期。停止轮询,并隐藏过期小程序码,用户点击刷新后,重新开启新的轮询。

在这里插入图片描述

3.4章节中的showRefreshCode变量就是显示刷新图标的开关。当轮询次数超出限制,则显示刷新图标,并且阻止用户扫码。因为已经不再轮询小程序码中携带的cId参数,再扫码已经没有意义。

当用户点击刷新图标,则重新获取小程序码,并重新开启轮询。

代码实现:

在前面显示小程序码的html代码中,增加刷新图标的判断:

            <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
              <div v-if="showRefreshCode" class="refresh-icon">

                <Icon style="cursor: pointer;opacity: 0.8" @click="loginBySceneCode" type="md-refresh" size="80"/>
              </div>
            </div>

代码解释:

1.showRefreshCode:是否显示刷新图标

2.loginBySceneCode:点击刷新图标的方法,包含请求小程序码、刷新图标隐藏、开启轮询用户信息等操作。

四、常规的用户+密码登录

除了小程序扫码登录,示例项目还提供了普通的用户+密码的登录方式。用户由超级管理员创建,创建后的用户使用默认密码登录。现在的示例项目角色管理功能尚未完善,所以所有用户均可创建用户。

4.1 账户登录前端实现

账户登录页面的前端主要由一个Form表单构成,表单包含用户名和密码的输入,登录按钮的实现。并且为账户和密码的输入框增加不能为空的规则。

效果如下:

在这里插入图片描述

代码实现:

<Form ref="loginForm" :model="form" :rules="rules" @keydown.enter.native="handleSubmit">
    <FormItem prop="userCode">
      <Input v-model="form.userCode" placeholder="请输入用户名">
        <span slot="prepend">
          <Icon :size="16" type="ios-person"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem prop="password">
      <Input type="password" v-model="form.password" password  placeholder="请输入密码">
        <span slot="prepend">
          <Icon :size="14" type="md-lock"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem>
      <Button @click="handleSubmit" class="lz-btn-primary" long>登录</Button>
    </FormItem>
  </Form>

大部分ui框架都提供了表单校验规则的功能,viewui的表单校验通过rules定义。示例项目仅仅设置了必填验证,实际项目中还应该增加长度验证。

登录按钮功能代码实现:

async function handleSubmit() {
  const validRs=await loginForm.value.validate()
  if (validRs) {
    const data = {
      userCode: form.value.userCode,
      password: form.value.password
    };
    const res = await login(data)
    if (res.success) {
      // 登录成功后,维护全局变量
      await handleLogIn(res)
      // 路由跳转
      router.push({
        name: '_home'
      })
    }

  }
}

代码解释:

1.validRs:判断表单校验是否通过,通过校验后,才能执行登录逻辑

2.await login(data):调用后端接口,判断用户名和密码是否合法,后端通过检查数据库信息,返回判断结果。

3.await handleLogIn(res):如果成功后,维护全局变量。这个方法是pinia中定义的,主要将一些后续会经常使用的关键信息维护在全局状态管理。下文详细讲解。

4.router.push:上面所有方法都实现后,则跳转到网站首页。

handleLogin方法代码实现:

       /**
         * 登录时维护全局变量
         * @param loginRs 登录成功后的返回值
         * @param loginRs.accessToken token
         * @param loginRs.refreshTokenId refreshTokenId
         * @param loginRs.data 用户信息*/
        async handleLogIn(loginRs) {
            // debugger
            const {accessToken, refreshTokenId, data} = loginRs
            console.log('userInfo', data)
            setToken(accessToken, refreshTokenId)
            this.token = accessToken
            // 维护全局用户信息
            this.userInfo = data
            setUserInfoLocal(data)
            //     维护路由信息
            await this.setRouters()
        },

如果同学的项目也采用了token鉴权,那么就在这里维护token信息,我的demo项目采用了双token,但是这部分内容对于前端初学者来说过于复杂,学习曲线过陡不利于长期学习,所以不打算在此赘述。

如果只是简单的项目,不涉及token,则采用下面的代码:

        async handleLogIn(loginRs) {
            // debugger
            const { data} = loginRs
            console.log('userInfo', data)
            // 维护全局用户信息
            this.userInfo = data
            localStorage.setItem('userInfo', JSON.stringify(userInfoData))
            //     维护路由信息
            await this.setRouters()
        },

代码解释:

1.userInfo :保存到全局状态管理的用户信息。userInfo必须要同步保存到缓存中,因为全局状态管理中的userInfo在刷新页面后会失效,很多场景仍然需要缓存中的userInfo。

2.setRouters:从远端获取路由,并维护路由信息到全局状态管理时,才需要此方法。路由信息只需要保存到全局状态管理,并不需要localstorage缓存,因为如果路由是从远端获取的,则说明在做权限和路由的管理,路由与权限有关,属于变化相对频繁的数据,就不能从缓存中简单获取。而应该当监控到失去路由信息时,均重新获取路由。如果项目路由全部由前端维护,不需要做权限管理,则不需要此方法。

4.2 账户登录后端实现

如果不考虑token鉴权,那么最简单的用户密码验证,就是简单的增删改查。后端接口根据前端发送的用户名,查询数据库用户信息表中是否存在该用户,如果存在,再比对密码,如果用户和密码都合法,则用户成功登录。

代码实现:

async login(entity: { userCode: string, password: string }): Promise<any> {
    let userData = await this.baseUserModel.findOne({
      where: {userCode: entity.userCode}
    });
    if(!userData){
      return {
        success: false,
        msg: '用户不存在',
      }
    }
    let hasAuth = false
    if(userData && userData.userCode.trim()==='test'){
      // 判断游客用户-test登录
      hasAuth =  entity.password===this.commonService.getPassCode()
    }else{
      hasAuth = bcrypt.compareSync(entity.password, userData?.password)
    }

    if (!hasAuth) {
      return {
        success: false,
        msg: '用户名或密码错误',
        data:userData
      }
    }
    return {
      success: true,
      data: userData,
    }
  }

代码解释:

1.this.baseUserModel.findOne:这是typeorm的语法,根据userCode查找用户信息。如果不存在,则返回前端结果,如果存在,则继续。

2.我的项目里存在test特殊账户,同学们查看项目示例中二少年学编程的示例项目就能看到,test账户的密码就是个我自己加盐加密的随机数,没有什么知识点。在同学们的实战项目中,这部分可以省略。

3.bcrypt.compareSync:判断传入的密码和用户信息中保存的密码是否相同。bcrypt是一个加解密的插件,如果我们保存到用户信息表中的密码为123456,那么一旦被人攻破服务器,造成的损失就会非常大,所以在创建用户的时候,一般都会使用加密工具进行加密后,再保存。加密后的密码是一串看不出意义的字符串,只要再用bcrypt比对这个字符串和123456,就能确定它俩是否一致。


总结

项目示例地址:中二少年学编程的示例项目。戳链接,查看示例效果。如果链接失效,请手动输入地址:lizetoolbox.top:8080/#/

本文知识点总结:

1.注册和登录的原理

2.小程序扫码登录实现

3.普通账户密码登录实现

有任何前端项目、demo、教程需求,都可以联系博主,博主会视精力更新,免费的羊毛,不薅白不薅!~

解决VSCode中Vue项目路径别名(@)跳转与代码提示问题

在 Vue 项目开发过程中,VSCode 作为主流的代码编辑器,为我们提供了强大的开发体验。然而,我最近在使用过程中会遇到两个常见问题:

  1. 在Vue文件中引用其他JS文件时,VSCode没有智能提示
  2. 使用@路径别名导入模块时,无法通过点击跳转到源文件

这些问题看似简单,但实际上涉及到VSCode配置、项目结构、插件选择等多个方面。这里主要是记录一下解决方案


问题描述

起因是当我在 .vue 文件的 <script> 标签中编写 JavaScript 代码并尝试引用其他 JS 文件的方法时,我发现VSCode 无法提供智能提示(IntelliSense),这极大地影响了开发效率。

另外一个问题是在使用 import request from '@/utils/request' 这样的路径别名导入时,点击 @/utils/request 无法跳转到对应的文件,这让代码导航变得困难。

这两个问题可能存在一定的相似性,我猜测是 VSCode 无法正确识别项目的模块解析配置和路径映射。

我们这里从以下几个方面进行排查问题:

  1. 清理缓存和重启 VSCode,毕竟重启能解决88%的问题
  2. 确定自己引入的文件路径是正确的,确保文件在指定的位置
  3. 正确配置 jsconfig.json 或 tsconfig.json有可能是这个问题?
  4. 安装并配置正确的 VSCode 扩展 - 使用 Vue-Official 替代 Vetur

✅ 解决方案

一、配置和使用正确的 VSCode 扩展

1、安装 Vue-Official

对于 Vue 3 项目,强烈推荐使用 Vue-Official 扩展:

  1. 在 VSCode 扩展市场搜索并安装 Vue-Official
  2. 禁用 Vetur 扩展(如果已安装),避免冲突
  3. 重启 VSCode

image.png

2、安装 Path Intellisense 扩展

为了增强路径提示功能,安装 Path Intellisense 扩展,并在 VSCode 设置中添加:

{
  "path-intellisense.mappings": {
    "@": "${workspaceRoot}/src"
  },
  "path-intellisense.autoSlashAfterDirectory": true,
  "path-intellisense.extensionOnImport": true,
  "path-intellisense.showHiddenFiles": true
}

二、确保正确的导入方式

在 Vue 单文件组件中,确保使用正确的导入语法:

<template>
  <div>{{ message }}</div>
</template>

<script setup>
// ✅ 推荐:使用路径别名
import { formatDate } from '@/utils/dateHelper'
import { apiRequest } from '@/utils/request'
import MyComponent from '@/components/MyComponent.vue'

// ✅ 也可以使用相对路径
import { localHelper } from '../utils/localHelper'

// 使用导入的方法
const message = formatDate(new Date())
</script>

对于 Options API 语法:

<script>
import { formatDate } from '@/utils/dateHelper'
import MyComponent from '@/components/MyComponent.vue'

export default {
  name: 'MyComponent',
  components: {
    MyComponent
  },
  data() {
    return {
      message: formatDate(new Date())
    }
  }
}
</script>

三、添加 jsconfig.jsontsconfig.json

创建 jsconfig.json(JavaScript 项目)

在项目根目录创建 jsconfig.json 文件,这是解决问题的关键步骤:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["src/**/*"],
  "exclude": [
    "node_modules",
    "dist"
  ]
}

创建 tsconfig.json(TypeScript 项目)

如果您的项目使用 TypeScript,则创建 tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue"
  ]
}

配置后重启VSCode使更改生效。

这里也有需要注意的地方,这里需要确保构建工具配置一致

确保构建工具配置一致

Vite 项目配置

如果使用 Vite,确保 vite.config.js 中的别名配置与 jsconfig.json 一致:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  }
})

Webpack 项目配置

对于使用 Webpack 的项目,确保别名配置正确:

const path = require('path')

module.exports = {
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src/')
    }
  }
}

高级配置和优化

配置工作区设置

在项目根目录创建 .vscode/settings.json 文件:

{
  "typescript.preferences.importModuleSpecifier": "relative",
  "typescript.suggest.autoImports": true,
  "typescript.updateImportsOnFileMove.enabled": "always",
  "javascript.suggest.autoImports": true,
  "javascript.updateImportsOnFileMove.enabled": "always",
  "vue.updateImportsOnFileMove.enabled": true,
  "editor.codeActionsOnSave": {
    "source.organizeImports": true
  },
  "path-intellisense.mappings": {
    "@": "${workspaceRoot}/src"
  }
}

"typescript.preferences.importModuleSpecifier": "relative":控制 TypeScript 自动导入时使用的路径风格。

  • "relative":使用相对路径(如 ../utils/helper)。
  • "non-relative":优先使用别名路径(如 @/utils/helper)。
  • "auto":由 TypeScript 自动决定。

"typescript.suggest.autoImports": true:启用 TypeScript 的自动导入建议功能。输入代码时,VSCode 会自动提示可导入的模块并补全 import 语句。


"typescript.updateImportsOnFileMove.enabled": "always":在重命名或移动文件时,自动更新所有引用该文件的 import 路径。

  • "always":总是自动更新。
  • "prompt":询问是否更新。
  • "never":禁用此功能。

"javascript.suggest.autoImports": true:与 typescript.suggest.autoImports 类似,但针对 JavaScript 文件。在 JS 文件中输入代码时,自动提示可导入的模块。

"javascript.updateImportsOnFileMove.enabled": "always":与 TypeScript 的配置类似,但针对 JavaScript 文件。

"vue.updateImportsOnFileMove.enabled": true:在 Vue 项目中移动文件时,自动更新 .vue 文件中的 import 路径。(需配合 Volar 插件使用)

"editor.codeActionsOnSave": {"source.organizeImports": true}:在保存文件时,自动整理(排序、去重)import 语句。删除未使用的导入,按字母顺序排序导入语句。

"path-intellisense.mappings": {"@": "${workspaceRoot}/src"}:配置路径别名 @ 的解析规则,需配合 Path Intellisense 插件使用。输入 @/ 时,插件会提示 src/ 目录下的文件,支持点击跳转到目标文件。(需要和 jsconfig.json/tsconfig.json 中的 paths 配置保持一致

为 JavaScript 文件添加类型声明

为了获得更好的代码提示,可以在 JavaScript 文件中使用 JSDoc 注释:

/**
 * 格式化日期
 * @param {Date} date - 要格式化的日期
 * @param {string} format - 日期格式
 * @returns {string} 格式化后的日期字符串
 */
export function formatDate(date, format = 'YYYY-MM-DD') {
  // 实现代码
  return formattedDate
}

/**
 * API 请求封装
 * @param {string} url - 请求地址
 * @param {Object} options - 请求选项
 * @returns {Promise<Object>} 请求响应
 */
export async function apiRequest(url, options = {}) {
  // 实现代码
  return response
}

创建类型声明文件

对于复杂的工具函数,可以创建 .d.ts 声明文件:

typescript
// src/types/utils.d.ts
declare module '@/utils/request' {
  export interface RequestOptions {
    method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
    headers?: Record<string, string>
    data?: any
  }
  
  export function request(url: string, options?: RequestOptions): Promise<any>
  export default request
}

declare module '@/utils/dateHelper' {
  export function formatDate(date: Date, format?: string): string
  export function parseDate(dateString: string): Date
}

每个开发人员都应该遵循的 10 个 React 最佳实践

前言

如今,前端开发的行业领导者仍然是 React。它强大的抽象、活跃的社区和丰富的生态系统,让开发者能够创建从企业级程序到业余项目的各种应用。

然而,强大的功能也伴随着诸多的复杂性。如果你忽视了久经考验的最佳实践,React 应用很容易变得难以管理、不一致或难以维护。

为了在 2025 年创建可扩展、稳定、有效且干净的 React 应用程序,每个认真的开发人员都应该遵循这十条 React 最佳实践。

1. 构建小型、专注且可重用的组件

为什么重要:

React 是基于组件的。你的用户界面本质上是由可复用的小部件组成的树状结构。但只有当这些元素职责单一且高度集中时,它才能成功。

避免使用臃肿的“上帝组件”,将 UI、逻辑和副作用都放在一个文件中处理。

你应该做什么:

  • 一个组件=一个目的
  • 将大组件分解成较小的组件
  • 在整个应用程序中重复使用组件以避免重复

例子

完成所有的事情,将其分解为:

<DashboardHeader />
<DashboardStats />
<DashboardActivity />

而不是

<Dashboard />

✅好处:

  • 更易于调试和测试
  • 鼓励重复使用
  • 保持文件简短易读

2. 使用带有 Hooks 的函数组件(优于类组件)

演变:

在 React 16.8 之前,类组件很常见。然而,目前,带有 hooks 的函数式组件被认为是最好的。

好处:

  • 更清晰的语法
  • 无需此绑定
  • Hooks 使状态和生命周期管理模块化且可重用

需要掌握的常见钩子:

| Hook                      | Purpose                                             |
|---------------------------|-----------------------------------------------------|
| `useState`                | Local state                                         |
| `useEffect`              | Side effects (API calls, subscriptions)             |
| `useRef`                 | Persisting mutable values without re-renders        |
| `useContext`             | Access global data without prop drilling            |
| `useReducer`             | More complex state logic (Redux-like)               |
| `useCallback`, `useMemo` | Performance optimization                            |
| **Custom Hooks**         | Abstract logic into reusable pieces                 |

3. 慎重提升状态(避免重复 Prop)

什么是提升状态?

当两个或多个组件需要访问相同状态时,应该解除最近的共同祖先。

需要避免的事情:

道具钻孔是通过几个不必要的中间组件插入道具的过程。

帮助工具:

  • React Context 用于轻量级共享状态
  • Zustand、Jotai 或 Redux Toolkit 适用于跨多个组件的复杂状态。

使用:

<UserContext.Provider value={user}>
  <App />
</UserContext.Provider>
而不是:
<App user={user}>
  <Sidebar user={user}>
    <Profile user={user} />
  </Sidebar>
</App>

4. 拥抱类型安全:TypeScript

为什么类型检查至关重要:

这种 JavaScript 是动态的。随着应用的增长,这种适应性可能会变成一个缺点。TypeScript(或 PropTypes)提供了一层保护。

TypeScript 的优点:

  • 在运行之前捕获错误
  • 强制一致的接口
  • 通过自动完成和内联文档改善开发人员体验

TypeScript 示例:

type User = {
  id: number;
  name: string;
  email: string;
};

function UserCard({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

仅将 PropTypes 用于简单的应用程序。对于任何实际应用,请使用 TypeScript。

5.创建可扩展的文件/文件夹结构

为什么结构很重要:

杂乱的结构变得难以控制。可扩展的设计实现了无缝入职,并促进了合作。

流行图案:

  • 基于特征的结构
  • 领域驱动结构
  • 原子设计结构(原子、分子、生物体等)

建议设置:

src/
├── components/         # Reusable UI components
├── features/           # Feature-specific modules
│   └── auth/
│   └── dashboard/
├── hooks/              # Custom hooks
├── contexts/           # Context providers
├── pages/              # Route-level pages (Next.js)
├── utils/              # Helper functions
├── assets/             # Images, fonts, etc.

在主要文件夹中保留 README.md 来解释其结构。

6. 优化渲染:记忆重要内容

为什么要优化?

当组件的 props 或 state 发生变化时,React 会重新渲染它们。对于大型组件树来说,这可能会很昂贵。

记忆工具:

  • React.memo()– 跳过纯函数组件的重新渲染
  • useMemo()– 缓存昂贵的计算
  • useCallback()– 记忆回调函数以防止子进程重新渲染

专业提示:

不要盲目优化。使用React Profiler来识别瓶颈。

7. 编写可重用的自定义钩子

为什么要使用自定义钩子?

钩子是可重复使用的逻辑容器。如果您需要在多个组件之间重复逻辑,请使用自定义钩子。

例子:

function useFetch(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch(url).then(res => res.json()).then(setData);
  }, [url]);
  return data;
}

用法如下:

const userData = useFetch('/api/user');

最佳实践:

前缀为use
保持钩子纯粹且可组合
放置在hooks/ 文件夹内

8. 实现错误边界和回退 UI

React 并非万无一失

某个部分未解决的问题可能会导致用户界面崩溃。React 错误边界通过捕获错误提供温和的回退。

例子:

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  render() {
    if (this.state.hasError) return <h1>Something went wrong.</h1>;
    return this.props.children;
  }
}

还可以使用:

  • Suspense延迟加载
  • 异步函数中的 Try/catch
  • 外部库如react-error-boundary

9. 测试组件:关注行为,而不是实现

为什么测试在 React 应用中很重要

测试的目标是建立对应用的信任,而不仅仅是发现缺陷。在现代 React 应用程序中进行测试可以确保您的用户界面 (UI) 在各种设备、状态和用户交互下都能正常运行。随着项目的进展,它可以保留您的逻辑,提供基于代码的文档,并避免回归。

思维转变: 将测试视为安全网,而不是琐事。它们让你能够大胆地重构,确保核心逻辑仍然有效。

您应该了解的测试类型

| Test Type      | Purpose                                           | Tools to Use                                 |
|----------------|---------------------------------------------------|----------------------------------------------|
| Unit Tests     | Test isolated components and logic                | Jest, React Testing Library                  |
| Integration    | Test how components work together                 | React Testing Library, Vitest                |
| E2E Tests      | Test full app behavior as a user would            | Cypress, Playwright, TestCafe                |
| Snapshot       | Detect unintended UI changes (with caution)       | Jest (with snapshot plugin)                  |

推荐的工具和生态系统

  • React 测试库 (RTL):使用用户交互测试您的应用程序,重点关注事件、可访问性角色和 DOM。
  • Jest:一个经过实战检验的 JavaScript 测试框架,具有内置的模拟、断言和快照。
  • Cypress 或 Playwright:在真实的浏览器设置中进行彻底的端到端 (E2E) 测试。真正实现用户流程的自动化。
  • MSW(模拟服务工作者):测试可以模拟 API 答案而不需要后端。

React 测试的最佳实践

1. 为用户行为编写测试,而不是内部实现
  • 错误:检查内部状态或类名
  • 好:点击按钮并检查结果是否显示
2. 使用getByRole, getByLabelText,getByText结束getByTestId
  • 这确保您的应用程序可访问且可测试
3.测试边缘情况:
  • 空状态
  • API 响应缓慢
  • 无效输入
  • 移动视口尺寸(E2E)
4.保持测试快速且确定——通过模拟 API 并在需要时使用伪计时器来避免不稳定的测试

示例测试用例(React 测试库)

jsx
复制编辑

test("increments counter on click", () => {
  render(<Counter />);
  const button = screen.getByRole("button", { name: /count/i });
  fireEvent.click(button);
  expect(button).toHaveTextContent("1");
});

黄金法则:

  • 测试用户关心的内容——行为、可访问性、交互。
  • 避免测试组件内部——结构、状态变量或 DOM 树形状。

想要在所有平台上打造高性能应用?

10. 无障碍功能(a11y)是必须的,而不是可能的

为什么无障碍设施如此重要

无障碍设计是一项基本职责,而非次要考虑因素。每个人都应该能够使用您的应用,包括那些行动不便的人。包容性设计确保了用户能够平等地使用您的产品,无论他们是否有视力障碍、行动障碍或认知障碍。

在许多国家,无障碍设施也是一项法律要求(例如美国的 ADA、欧洲的 EN 301 549)。

a11y React 开发者最佳实践

1. 使用语义HTML
  • 偏好本土元素
  • 避免使用 div 进行交互式 UI — 屏幕阅读器会跳过它们
2. 确保键盘导航性
  • 每个交互元素都应该可以通过 Tab 和 Enter 访问和操作
  • 慎重使用 tabindex (避免 tabindex="0" 过载)
3. 需要时添加 ARIA 属性
  • 使用 aria-label、aria-hidden、aria-live 为屏幕阅读器提供上下文
  • 但是当语义 HTML 可以完成工作时,不要过度使用 ARIA
4. 为图片提供替代文本
  • 对重要图片使用有意义的 alt=""
  • 使用 alt="" 来隐藏装饰性的
5. 颜色对比度和焦点指示器
  • 确保文本具有高对比度(根据 WCAG AA/AAA 检查)
  • 不要删除焦点轮廓——如果需要,可以自定义它们
6. 表单错误处理
  • 使用 aria-scribeby 链接表单错误
  • 在模糊或提交时进行验证,而不仅仅是在更改时

确保年度合规性的工具

  • axe DevTools(Chrome 扩展程序)——实时分析 WCAG 违规行为
  • eslint-plugin-jsx-a11y — 查找缺失的角色、替代文本、标签陷阱
  • Lighthouse (Chrome/CI) — 审核中的 a11y 评分
  • 屏幕阅读器:NVDA(Windows)、VoiceOver(macOS)、ChromeVox

现实世界的可访问性审计技巧

  • 仅使用键盘即可导航整个应用程序
  • 使用屏幕阅读器浏览常见流程
  • 使用 contrast-ratio.com 等工具测试颜色对比度
  • 避免可能引发运动障碍的动画(尊重prefers-reduced-motion

最终要点:以人为本,确保代码面向未来

React 为你提供工具。这些实践赋予你纪律。

  • 清洁、模块化的组件。
  • 现代功能(钩子、TypeScript、上下文)。
  • 自信的、基于行为的测试。
  • 无障碍且包容的体验。
  • 可扩展的架构和文件结构。
  • 安全的错误处理和性能调整。

当您将这些方法纳入到您的流程中时,您将创建可扩展、持久且令人愉悦的高质量软件。

每日一题-满足条件的子序列数目🟡

给你一个整数数组 nums 和一个整数 target

请你统计并返回 nums 中能满足其最小元素与最大元素的 小于或等于 target非空 子序列的数目。

由于答案可能很大,请将结果对 109 + 7 取余后返回。

 

示例 1:

输入:nums = [3,5,6,7], target = 9
输出:4
解释:有 4 个子序列满足该条件。
[3] -> 最小元素 + 最大元素 <= target (3 + 3 <= 9)
[3,5] -> (3 + 5 <= 9)
[3,5,6] -> (3 + 6 <= 9)
[3,6] -> (3 + 6 <= 9)

示例 2:

输入:nums = [3,3,6,8], target = 10
输出:6
解释:有 6 个子序列满足该条件。(nums 中可以有重复数字)
[3] , [3] , [3,3], [3,6] , [3,6] , [3,3,6]

示例 3:

输入:nums = [2,3,3,4,6,7], target = 12
输出:61
解释:共有 63 个非空子序列,其中 2 个不满足条件([6,7], [7])
有效序列总数为(63 - 2 = 61)

 

提示:

  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 106
  • 1 <= target <= 106

前端技术大放送,SSE 流式传输全攻略

探索前端新境界:SSE 流式传输的魅力

在前端开发的浩瀚宇宙中,总有一些技术如璀璨星辰,吸引着我们不断探索。今天,就让我们一同走进 SSE 流式传输的世界,感受它的独特魅力。

一、初遇 SSE

你还记得在使用某些智能应用,如 DeepSeek 时,输入问题后,答案仿佛具有魔法,能一点点地呈现在页面上吗?起初,我猜测这可能是 WebSocket 的神奇推送,但经过仔细检查网络请求,我发现并非如此,而是一种基于 HTTP 的特殊技术 —— SSE(Server-Sent Events)。

与 WebSocket 不同,SSE 并非基于 TCP 协议,而是依附于 HTTP 协议。它是一种单工通信模式,专为服务端向浏览器端实时推送消息而生。虽然功能相对有限,但却以其简洁轻便、易于实现而备受青睐。

二、SSE 与 WebSocket 的对比

为了更清晰地理解 SSE 的特点,让我们把它和 WebSocket 放在一起比较一番:

特性 SSE WebSocket
基础协议 HTTP 协议 TCP 协议
通信模式 单工(服务端到浏览器) 全双工(可双向通信)
复杂程度 轻量级,简单易用 相对复杂
断线重连 内置断线重连功能 需手动实现
消息类型 文本或经 Base64、gzip 处理的二进制消息 支持多种类型消息
自定义事件 支持自定义事件类型 不支持自定义事件类型
连接数量 HTTP/1.1 最多 6 个,HTTP/2 默认 100 个 无限制

从表中可见,若项目需求仅为服务端向浏览器端推送消息,且希望快速实现,SSE 当然是更优之选。

三、浏览器中的 SSE API

在浏览器端,我们主要借助 EventSource API 来与 SSE 服务器进行交互。

  1. 1. 建立连接 :通过 new EventSource(url) 创建实例,其中 url 为服务器端提供的接口地址。还可以传入可选的 options 参数,如 {withCredentials: true} ,用于处理跨域凭据问题。
  2. 2. 监听事件 :EventSource 实例提供了多种事件供我们监听,包括 onopen (连接建立)、onmessage (接收到消息)、onerror (出现错误)。除了系统预设事件,还能自定义事件类型,只需在服务器端指定事件名称即可。

四、实战封装 SSEService 类

为了方便在项目中使用 SSE,我们可以对其进行封装,创建一个 SSEService 类。

class SSEService {
    constructor() {
        this.eventSource = null;
        this.isClosing = false;
    }
    connect(url, options = {}) {
        if (this.eventSource) {
            this.disconnect();
        }
        this.eventSource = new EventSource(url);
        // 连接打开时的处理
        this.eventSource.onopen = () => {
            console.log('SSE 连接已建立');
            this.isClosing = false;
            if (options.onOpen) {
                options.onOpen();
            }
        };
        // 接收到消息时的处理
        this.eventSource.onmessage = event => {
            try {
                const data = JSON.parse(event.data);
                if (options.onMessage) {
                    options.onMessage(data);
                }
                // 假设 status 为 1 表示最后一条消息
                if (data.status === 1) {
                    this.isClosing = true;
                    this.disconnect();
                }
            } catch (error) {
                console.error('解析 SSE 消息失败:', error);
            }
        };
        // 出现错误时的处理
        this.eventSource.onerror = error => {
            if (this.isClosing) {
                return;
            }
            console.error('SSE 连接错误:', error, new Date().toLocaleString());
            if (options.onError) {
                options.onError(error);
            }
            this.disconnect();
        };
    }
    // 断开连接
    disconnect() {
        if (this.eventSource) {
            this.eventSource.close();
            this.eventSource = null;
            console.log('SSE 连接已关闭', new Date().toLocaleString());
        }
    }
}
export default SSEService;

在实际使用时,只需创建该类的实例,调用 connect 方法传入相应的 url 和回调函数即可。

const sseService = new SSEService();
const url = `/api/stream?query=${encodeURIComponent(query)}`;
sseService.connect(url, {
    onMessagedata => {
        // 在此处处理接收到的数据,如更新页面显示
        console.log('收到数据:', data);
    },
    onError() => {
        // 处理连接错误情况
        console.error('连接出错');
    }
});

五、应对流式传输的“小插曲”

在开发过程中,有时会遇到使用 webpack 打包后,流式传输效果不理想的情况 —— 本应逐步显示的数据却等到全部接收完后才一次性呈现。不用担心,这通常是因为 webpack 的代理服务器配置中 compress 设置为 true 导致的。只需将其改为 false,即可恢复正常的流式传输效果。

总之,SSE 流式传输技术在特定场景下能为我们带来流畅、实时的用户体验,其简洁的实现方式也为前端开发增添了一份便利。希望本文能帮助你开启 SSE 的精彩之旅,让前端应用更具活力。

three.js基础入门(一)

入门(three.js使用、相机设置、材质定义、光照设置、自定义geometry等,大概分成三章)

进阶(shader学习、自定义材质、部分源码阅读,大概两三章)

如果还有想要了解three.js其他相关的内容,欢迎留言补充


image.png

一、Three.js 是什么?

Three.js 是一个基于 WebGL 的 JavaScript 3D 库。

WebGL 本身功能强大但复杂,即使一个简单的绘制三角形都需要很多代码;Three.js 把这个常用的三维渲染需要用到的能力进行了封装,简单调用组合就能快速实现 3D 场景。

为什么选择 Three.js?

  • 门槛低:不懂 WebGL 也能上手
  • 功能全:支持光照、阴影、动画等
  • 社区活跃:教程和案例非常多
  • 跨平台:只要是浏览器,就能运行

它被广泛用于产品展示、游戏、可视化等场景


二、Hello World 示例

下面我们写一个最简单的 three.js 程序,在网页上可以看到一个旋转的立方体。

示例代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Three.js Hello World</title>
  <style>body { margin: 0; }</style>
</head>
<body>
  <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
  <script>
    // 创建一个场景(Scene)
    const scene = new THREE.Scene();
    // 创建一个相机(Camera)
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
    //创建一个渲染器(Renderer)
    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    // 创建一个立方体(Box)
    const geometry = new THREE.BoxGeometry();
    const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);

    camera.position.z = 5;

    // 动画,每一帧都会旋转
    function animate() {
      requestAnimationFrame(animate);
      cube.rotation.x += 0.01;
      cube.rotation.y += 0.01;
      renderer.render(scene, camera);
    }
    animate();
  </script>
</body>
</html>

三、材质类型

在现实中,砖和玻璃就算形状一样,视觉效果也完全不同。在 Three.js 中,这种视觉差异由“材质(Material)”控制。

常用材质介绍:

材质名 是否受光照影响 简介
MeshBasicMaterial ❌ 否 最简单,不受光影响,常用于调试
MeshStandardMaterial ✅ 是 现代物理材质,效果真实,推荐使用
MeshPhongMaterial ✅ 是 支持高光、光照,但不如 Standard 拟真
MeshLambertMaterial ✅ 是 柔和漫反射,适合基础物体
MeshNormalMaterial ❌ 否 表面法线可视化,常用于开发调试

如果场景中物体需要考虑光照影响推荐MeshStandardMaterialMeshBasicMaterial 材质更简单,但是因为不受光看起来很平

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>body { margin: 0; }</style>
</head>
<body>
  <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
  <script>
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
    camera.position.z = 5;

    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    // 创建两种材质的球体,左边为 MeshBasicMaterial(不受光照),右边为 MeshStandardMaterial(受光照)
    const geometry = new THREE.SphereGeometry(1, 32, 32);

    const basicMat = new THREE.MeshBasicMaterial({ color: 0x44aaff});
    const standardMat = new THREE.MeshStandardMaterial({ color: 0x44aaff, metalness: 0.7, roughness: 0.3 });

    const basicSphere = new THREE.Mesh(geometry, basicMat);
    const standardSphere = new THREE.Mesh(geometry, standardMat);
    
    basicSphere.position.x = -1.5;
    standardSphere.position.x = 1.5;
    scene.add(basicSphere);
    scene.add(standardSphere);

    // 平行光
    const light = new THREE.DirectionalLight(0xffffff, 1);
    light.position.set(5, 5, 5);
    scene.add(light);

    // 环境光
    const ambient = new THREE.AmbientLight(0xffffff, 0.3);
    scene.add(ambient);

    function animate() {
      requestAnimationFrame(animate);
      basicSphere.rotation.y += 0.01;
      standardSphere.rotation.y += 0.01;
      renderer.render(scene, camera);
    }
    animate();
  </script>
</body>
</html>

image.png 效果是不是差异很大,需要注意的是使用MeshStandardMaterial一定要添加光照(重要的事情说三遍),上面的代码添加了平行光DirectionalLight和环境光AmbientLight,如果不添加光照的话就是漆黑一片

不加光照效果 image.png

示例代码:

const material = new THREE.MeshStandardMaterial({
  color: 0xff0000,
  roughness: 0.5,
  metalness: 0.8
});

四、Geometry与 Mesh

几何体相当于一个 3D 模型的“骨架”,只负责定义形状,不负责外观材质。Three.js 提供了多个内置几何体类,常见包括:

常用几何体:

new THREE.BoxGeometry()        // 立方体
new THREE.SphereGeometry()     // 球体
new THREE.PlaneGeometry()      // 平面
new THREE.ConeGeometry()       // 圆锥
new THREE.TorusGeometry()      // 圆环

几何体定义形状,但还不能被渲染。只有当几何体与材质(Material)结合,才能生成真正可见的 3D 物体,这个组合称为 Mesh

组合方式:几何体 + 材质 = 网格(Mesh)

const geometry = new THREE.SphereGeometry(1, 32, 32);
const material = new THREE.MeshStandardMaterial({ color: 0x00ffff });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

scene.add(mesh) 把它们添加到场景中

Geometry 就像“模具”,Material 就像“颜料”,Mesh 则是已经上色后的成品

BufferGeometry

Three.js 中的大多数几何体都继承自 BufferGeometry

// 自定义 BufferGeometry
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
  -1.0, -1.0, 0.0,
   1.0, -1.0, 0.0,
   0.0,  1.0, 0.0
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

关于自定义geometry后续可以详细介绍,我们会自己定义一些顶点来绘制three.js默认不支持的图形


五、相机与光源

即使你在网页上放了 100 个模型,没有相机和光,啥也看不到,

相机类型:

  • PerspectiveCamera(透视相机)——真实世界的视角,近大远小,是最常用的 3D 相机
  • OrthographicCamera(正交相机)——常用于 2D,平行投影,没有透视效果
// 创建透视相机
const perspectiveCamera = new THREE.PerspectiveCamera(
  75, window.innerWidth / window.innerHeight, 0.1, 1000
);
perspectiveCamera.position.set(0, 0, 5);

// 创建正交相机
const aspect = window.innerWidth / window.innerHeight;
const orthoCamera = new THREE.OrthographicCamera(
  -aspect * 5, aspect * 5, 5, -5, 0.1, 1000
);
orthoCamera.position.set(0, 0, 5);
camera.position.z = 5;

光源种类:

光源类型 说明
AmbientLight 环境光,提供均匀照明
DirectionalLight 平行光,如太阳光
PointLight 点光源,如灯泡
SpotLight 聚光灯,可以打出圆锥区域
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);

示例代码: 这个示例中用了AmbientLight、DirectionalLight和PointLight,我还加了一个gui面板可以控制光线,可以粘贴代码跑一下,体验光照对场景的影响

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>body { margin: 0; }</style>
</head>
<body>
  <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/dat.gui@0.7.9/build/dat.gui.min.js"></script>
  <script>
    const scene = new THREE.Scene();

    const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
    camera.position.set(4, 3, 6);
    camera.lookAt(0, 1, 0);

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.shadowMap.enabled = true;
    document.body.appendChild(renderer.domElement);

    const sphereGeo = new THREE.SphereGeometry(1, 64, 64);
    const sphereMat = new THREE.MeshStandardMaterial({ color: 0x66ccff, metalness: 0.5, roughness: 0.4 });
    const sphere = new THREE.Mesh(sphereGeo, sphereMat);
    sphere.castShadow = true;
    sphere.position.y = 1;
    scene.add(sphere);

    const planeGeo = new THREE.PlaneGeometry(10, 10);
    const planeMat = new THREE.MeshStandardMaterial({ color: 0xeeeeee });
    const plane = new THREE.Mesh(planeGeo, planeMat);
    plane.rotation.x = -Math.PI / 2;
    plane.receiveShadow = true;
    scene.add(plane);

    const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
    scene.add(ambientLight);

    const dirLight = new THREE.DirectionalLight(0xffffff, 1);
    dirLight.position.set(5, 5, 5);
    dirLight.castShadow = true;
    scene.add(dirLight);

    const pointLight = new THREE.PointLight(0xffaa00, 1, 15);
    pointLight.position.set(-3, 2, 2);
    pointLight.castShadow = true;
    scene.add(pointLight);

    // gui控制面板
    const gui = new dat.GUI();

    const ambientFolder = gui.addFolder('Ambient Light');
    ambientFolder.add(ambientLight, 'visible').name('开启');
    ambientFolder.add(ambientLight, 'intensity', 0, 1, 0.01).name('强度');

    const dirFolder = gui.addFolder('Directional Light');
    dirFolder.add(dirLight, 'visible').name('开启');
    dirFolder.add(dirLight, 'intensity', 0, 2, 0.01).name('强度');
    dirFolder.add(dirLight.position, 'x', -10, 10).name('位置X');
    dirFolder.add(dirLight.position, 'y', -10, 10).name('位置Y');
    dirFolder.add(dirLight.position, 'z', -10, 10).name('位置Z');

    const pointFolder = gui.addFolder('Point Light');
    pointFolder.add(pointLight, 'visible').name('开启');
    pointFolder.add(pointLight, 'intensity', 0, 2, 0.01).name('强度');
    pointFolder.add(pointLight.position, 'x', -10, 10).name('位置X');
    pointFolder.add(pointLight.position, 'y', -10, 10).name('位置Y');
    pointFolder.add(pointLight.position, 'z', -10, 10).name('位置Z');

    ambientFolder.open();
    dirFolder.open();
    pointFolder.open();

    function animate() {
      requestAnimationFrame(animate);
      sphere.rotation.y += 0.01;
      renderer.render(scene, camera);
    }
    animate();
  </script>
</body>
</html>

image.png


下一章

  • 加载 3D 模型(GLTF)
  • 添加 OrbitControls 实现相机交互
  • 学会使用动画系统
  • 尝试后处理(模糊、发光、景深等)
  • 欢迎留言补充
❌