普通视图

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

福然德:实控人之一致行动人拟减持公司不超3%股份

2025年12月1日 18:31
36氪获悉,福然德发布公告,公司控股股东、实控人的一致行动人暨持股5%以上股东宁波人科创业投资合伙企业(有限合伙)计划15个交易日后的3个月内,通过集中竞价交易方式及大宗交易方式,减持公司股份合计不超过1478.49万股,减持比例不超过公司股份总数的3%。

中电环保:签订1.257亿元陕煤煤化工水处理项目合同

2025年12月1日 18:23
36氪获悉,中电环保发布公告,公司与东华科技签署陕煤榆林化学1500万吨/年煤炭分质清洁高效转化示范项目烯烃、芳烃及深加工工程一阶段脱盐水站、换热站装置EPC脱盐水系统成套装置采购合同,合同总额1.257亿元(含税),约占2024年度营业收入总额的15.01%。

antd 4.x Tabs 点击阻止冒泡

2025年12月1日 18:20

一、场景

image.png

如上图所示,tab1-未回复,tab2-已回复+筛选条件仅看未处理
当在tab1时,点击tab2的仅看未处理checkbox,此时需要进行tab2的数据请求(请求已回复&未处理的数据)

二、基础实现

const [activeKey, setActiveKey] = useState('replied');

const defaultPageParams = {
    page: 1,
    rows: 5,
};

const getRepliedData = (params: any) => {
    const _params = {
        ...params
    }
    if (_params.handleStatus == null) {
        delete _params.handleStatus;
    }
    // 存一份params
    //...
}

const getNotReplyData = (params: any) => {
    //...
    // 存一份params
}

const handleFilterRepliedData = (e: CheckboxChangeEvent) => {
    const params: any = {
        ...defaultPageParams,
        handleStatus: e.target.checked ? 0 : null,
    };
    getRepliedData(params);
}

const getData = (key: string) => {
    const params: any = {
        ...defaultPageParams
    };
    if (key === 'replied') {
      getRepliedData(params);
    }
    if (key === 'not-reply') {
      getNotReplyData(params);
    }
};
<Tabs
    defaultActiveKey={'not-reply'}
    activeKey={activeKey}
    onChange={(key) => {
        setActiveKey(key);
        getData(key);
    }}
    items={[
        {
          key: 'not-reply',
          label: '未回复',
          children: (
            <div>未回复内容</div>
          ),
        },
        {
          key: 'replied',
          label: (
            <Space>
                <div>已回复</div>
                <Checkbox onChange={handleFilterRepliedData}>
                  僅看未處理
                </Checkbox>
            </Space>
          ),
          children: (
            <div>已回复内容</div>
          ),
        },
    ]}
/>

三、基础实现存在的问题

在tab1直接点击tab2的checkbox,会执行Checkbox的onChange事件,也会执行Tabs的onChange事件,会导致请求了两次接口同时页面上会有数据闪现现象,若Tabs的请求更慢,可能还会导致数据查询异常。

四、优化实现

关键代码:

  1. Checkbox包一层:
    <span style={{ pointerEvents: 'none' }} onClick={(e) => e.stopPropagation()}>
  2. Checkbox加style:
    style={{ pointerEvents: 'auto' }}
  3. Checkbox onChange方法添加代码:
    e.stopPropagation();
    setActiveKey('replied');
// 1、改Checkbox的onChange方法
const handleFilterRepliedData = (e: CheckboxChangeEvent) => {
    // 避免在未命中該tab的情況下,直接點擊該checkbox請求了兩次接口導致的頁面內容閃現問題
    e.stopPropagation();
    setActiveKey('replied');
    
    //...
}

<Tabs
    //...
    items={[
        //...
        {
          key: 'replied',
          label: (
            <Space>
                <div>已回复</div>
                {/* 2、改Checkbox视图,解決事件冒泡到tabs的onChange事件 */}
                <span style={{ pointerEvents: 'none' }} onClick={(e) => e.stopPropagation()}>
                    <Checkbox style={{ pointerEvents: 'auto' }} onChange={handleFilterRepliedData}>
                      僅看未處理
                    </Checkbox>
                </span>
            </Space>
          ),
          children: (
            <div>已回复内容</div>
          ),
        },
    ]}
/>

首销备货 50 万台,豆包 AI 手机要卖给谁?

2025年12月1日 18:13
作者|连冉  张勇毅
编辑|郑玄

头图来源:字节跳动

 

12月1日,字节跳动豆包团队发布豆包手机助手技术预览版。

 

据介绍,豆包手机助手,是在豆包 APP 的基础上,和手机厂商在操作系统层面合作的 AI 助手软件。基于豆包大模型的能力和手机厂商的授权,豆包手机助手能够为用户带来更方便的交互和更丰富的体验。

 

现阶段,开发者和科技爱好者可以在豆包与中兴合作的工程样机 nubia M153 上,体验豆包手机助手的技术预览版本。目前该版本已面向开发者和科技爱好者少量发售,售价 3499 元。

 

豆包手机助手的出现,是在试图用 AI Agent 打通APP 之间,重构移动互联网的交互逻辑。

 

尽管当前的演示仍需面对技术「不确定性」的免责声明,但这种深入操作系统底层、追求「意图直达服务」的尝试,可能比单纯的 Chatbot 更具革新意义。

 

豆包手机外观设计 |图片来源:豆包官方

 

或许,谁能率先解决「操作手机」的稳定性难题,谁就能定义 AI 时代的「iPhone 时刻」。

 

此前,据前中兴手机硬件产品经理向「极客公园」介绍,字节跳动与努比亚为这款手机的首销备货量为 50 万台,并为此订购了对应数量的手机关键元器件。

 

目前的手机市场中,国产品牌主流旗舰,首销期备货通常在 200-300 万台级别。因此豆包手机的这个数据虽然无法与年出货量超千万的一线手机厂商旗舰手机相提并论。但豆包手机摆脱「极客玩具」,走向更广泛的用户市场的目标已经足够清晰。

 

首销备货 50 万台的量级,如果全部投放向市场的话,仍然是一个足以给行业带来一定影响的数字:作为对比,曾经游戏手机垂直市场下的头部玩家 —— 黑鲨,在 2022-2023 年的手机出货量为 100-150 万台。

 

1 从「对话框」到「行动派」

 

过去两年,我们见惯了能写诗、能画图的 Chatbot,但对于普通用户而言,手机上最痛的痛点往往是繁琐的操作流。豆包手机助手这次的大看点,在于它试图从「对话」跨越到「行动」。

 

在技术预览版的演示中,豆包展示了一种在此前 GUI Agent(图形用户界面代理)研究中常被提及的能力——它能像人类一样「看懂」屏幕,并直接模拟点击操作。

 

这种「看懂屏幕」并模拟人类操作的底气,则源自豆包大模型在多模态能力上的积累。

 

据官方透露,该模型在视觉理解、推理以及图像创作等维度的性能已处于国际第一梯队。正是因为模型具备了精准的图形界面(GUI)识别能力,它才能在多项权威评测中拿到高分,从而像人类一样理解「按钮」和「输入框」的含义,而不仅仅是识别一堆代码。

 

据豆包手机官方使用文档介绍,豆包会根据意图自动判断是否调用 AI Agent 能力,若用户对话开头包含「帮我操作手机」,则会 100% 通过 AI 操作手机完成任务。

 

任务描述越详细,它的执行效率越高,执行效果越好。例如:“打开美团外卖帮我把最近几个订单的好评写了”。此外,AI 操作手机是在虚拟屏操作的,不会在前台默认展开,也不会影响正在进行的其他任务,你可以随时返回桌面使用其他应用。

 

用户也可以直接跟豆包对话,讲出需求,豆包可根据需求自动判断是否通过操作手机功能完成,以及在豆包对话框底部功能按钮中找到“操作手机”,点击按钮可手动描述需求,也可以设定定时等条件任务。

 

试想这样一个场景:你在社交媒体上被种草了一款好物,过去你需要截图、退出应用、打开电商平台、搜索、比价。

 

而在豆包的演示中,你只需说一句「帮我在全平台比价下单」,AI 就能自动跨应用跳转,搜索同款、对比价格规格、领券,甚至帮你选好最低价的商品填入购物车。

 

图片来源:豆包手机使用指南文档

 

虽然出于安全考虑,支付环节仍需人工确认,但前面那一系列机械的点击和切换,AI 已经代劳了。

 

甚至复杂任务也可以执行。在官方演示的旅行规划场景中,当用户提出「下个月去巴黎,帮我把收藏的餐厅标在地图上,看看哪天有展并订票」这样一句包含多重意图的指令时,AI 能够迅速将需求拆解为 6 个子任务:从查询社交媒体收藏、到高德地图标记、再到携程订票,最后整理进备忘录。

 

这种跨应用、多步骤的「任务链」执行能力,可以说是 AI 从「玩具」迈向「工具」的关键分水岭之一。

 

为了实现这种「类人」的交互,豆包打通了系统层面的多项权限。

 

在系统层面,豆包手机为 AI 能力设计了多种交互方式,用户可以通过侧边键、语音甚至耳机唤醒它;在相册里,它能直接听懂「把路人 P 掉」的指令并执行。

 

图片来源:豆包手机使用指南文档

 

在更复杂的「Pro 模式」下,它还能调用系统工具,结合记忆功能,直接完成「推荐礼物并放入购物车」这种需要多步推理的复杂任务。

图片来源:豆包手机使用指南文档

 

当然,将屏幕控制权和个人喜好交给 AI,隐私安全始终是绕不开的话题。所以豆包团队也强调,这一功能支持按需开启,并承诺严格保护数据隐私。

 

作为「技术预览版」,豆包团队也在视频结尾特别提示,受限于大模型技术的不确定性,演示中的「丝滑」体验目前还无法百分百复现,产品距离团队的最终预期仍有差距。

 

这也体现了 AI Agent 目前最真实的状态:方向极度性感,但落地仍需时间打磨。

2 不造硬件的「第三条路」

 

在 AI 手机的浪潮中,一直存在两种流派:一种是像 Google / Pixel 手机这样,自研模型以及整套 AI 软件产品体验,并植入自家系统;另一种则是纯软件厂商,试图通过超级 APP 抢占入口。

 

图片来源:Google

 

豆包选择了第三条路:不做硬件,只做生态。

 

在发布预览版的同时,豆包方面明确表示「没有自研手机计划」。他们的策略非常务实——通过与多家手机厂商洽谈,以「操作系统层面合作」的形式,将豆包的大模型能力植入不同品牌的机型中。

 

这种「手机厂商 + 大模型厂商」的深度耦合,正在成为行业的一股新趋势。

 

就像谷歌 Gemini 与三星的合作一样,术业有专攻正逐渐成为共识。

 

对于手机厂商而言,从零打造一个具备顶级推理、视觉理解和复杂任务规划能力的模型成本极高;而对于字节跳动这样的互联网巨头,缺乏硬件载体则会让 AI 始终隔着一层 APP 的玻璃墙,无法触达用户最核心的数据和场景。

 

目前的 nubia M153 工程机只是一个开始。售价 3499 元的门槛或许更多是面向开发者和极客人群的「邀请函」,旨在验证这种跨界合作的技术可行性与用户反馈。

 

3 光做一个APP,在 AI 时代已经不够了

 

豆包手机助手的出现,本质上或许是一次对移动互联网交互逻辑的重构。

大模型的能力越来越强,单纯做一个 APP,在 AI 时代已经不够了。

AI Agent 需要接管更复杂的任务、感知更丰富的上下文,发挥一些真实的功能,才有更落地的价值,这意味着它必须走出软件的围墙,向下沉淀,与操作系统的底层权限和硬件能力进行深度整合。

过往,字节跳动一直是一支强大的“空军”——拥有极致的算法和庞大的应用生态,但在操作系统和终端硬件上,相比拥有 Android 的谷歌或拥有全场景终端的华为,字节始终缺少一块落地的“阵地”。

在移动互联网时代,这或许不是问题,但在 AI 需要深度介入用户场景的当下,缺乏硬件载体可能意味着丧失对场景的感知力。

豆包手机助手的推出,像是字节在当下阶段抛出的一次探索

从 Pico 到 Ola Friend,再到如今深入手机 OS 层的助手,字节正在小心补齐“硬件触点”这块短板。

这或许并不是未来两三年行业的最终形态,但至少可以确认的是:字节已经意识到,想要让 AI 真正跑通,必须迈出“软硬结合”的这关键一步。

 

GeoJSON 介绍:Web 地图数据的通用语言

作者 东东233
2025年12月1日 18:12

GeoJSON 介绍:Web 地图数据的通用语言

引言

GeoJSON 是一套基于 JSON 格式的地理空间数据编码标准,具有轻量、易读、易于在 Web 应用中解析和传输等优势,它是 Web 地图库(如 Leaflet, Mapbox, OpenLayers)事实上的标准数据格式,我最近在看 OpenLayers,在加载外部数据的时候都是用 GeoJSON,于是便了解了一下,这里是最新规范的英文文档、英语好的可以直接跳转这里

GeoJSON 基本构成

GeoJSON 本质上就是一个标准的 JSON 对象,所有 GeoJSON 对象必须有一个 "type" 成员,"type"表示当前 JSON 描述的类型,这里分为基本几何类型和特征类型。

基本几何类型快速理解就是描述地图上形状的类型,“type” 取值包括 点 (Point)、线(LineString)、区域(Polygon)以及他们的多重类型 MultiPoint, MultiLineString, MultiPolygon,其“coordinates” 属性用来标注地理坐标位置(经纬度基于 WGS84 坐标系)

特征类型即带有属性(properties)的类型,“type” 取值包括 Feature 和 FeatureCollection

基本几何类型

Point(点)

表示地图上的一个点,结构如下

{
  "type": "Point",
  "coordinates": [106.56, 29.57]
}

LineString (线)

表示地图上的一条线,可以理解为有多个点连接组成,“coordinates” 为一个二维数组

{
  "type": "LineString",
  "coordinates": [
    [106.51398305678325, 29.523171668355733],
    [106.51453664249686, 29.523092142346467],
    [106.51566579820047, 29.522995404990354]
  ]
}

Polygon (多边形)

表示地图上的一个多边形,“coordinates” 由多个环组成、环即由多个点组成的闭合的路径、最后一个点表示闭合点,注意这里可能包含多个环形元素,第一个环表示外部环、其余表示内部环,比如空心圆就由一个内部环和一个外部环组成、外部环通常由逆时针顺序定义,内部的洞应以顺时针方向定义。

一个包含环的多边形示例如下

{
  "type": "Polygon",
  "coordinates": [
    [
      [106.50, 29.60],
      [106.50, 29.50],
      [106.60, 29.50],
      [106.60, 29.60],
      [106.50, 29.60] 
    ],
    [
      [106.53, 29.57],
      [106.57, 29.57],
      [106.57, 29.53],
      [106.53, 29.53],
      [106.53, 29.57]
    ]
  ]
}

把第二段JSON删掉就是没有环的矩形

{
  "type": "Polygon",
  "coordinates": [
    [
      [106.50, 29.60],
      [106.50, 29.50],
      [106.60, 29.50],
      [106.60, 29.60],
      [106.50, 29.60] 
    ]
  ]
}

MultiPoint(多点)

表示一组不相连的点、Point的复数形式、多个点组成的二维数组

{
  "type": "MultiPoint",
  "coordinates": [
    [106.50, 29.60],
    [106.50, 29.50],
    [106.60, 29.50],
    [106.60, 29.60]
  ]
}

MultiLineString (多线串)

表示一组不相连的线串,LineString的复数形式,多条线组成的三层数组

{
  "type": "MultiLineString",
  "coordinates": [
    [
      [106.51398305678325, 29.523171668355733],
      [106.51453664249686, 29.523092142346467],
      [106.51566579820047, 29.522995404990354]
    ],
    [
      [106.51398305678325, 29.533171668355733],
      [106.51453664249686, 29.533092142346467],
      [106.51566579820047, 29.532995404990354]
    ]
  ]
}

MultiPolygon (多多边形):

表示一组不相连的多边形、坐标是四层数组,每组坐标代表一个独立的 Polygon(每个 Polygon 内部仍可包含洞)。

{
  "type": "MultiPolygon",
  "coordinates": [
    [
      [
        [106.50, 29.55],
        [106.50, 29.50],
        [106.55, 29.50],
        [106.55, 29.55],
        [106.50, 29.55]
      ]
    ],
    [
      [
        [106.65, 29.65],
        [106.65, 29.60],
        [106.70, 29.60],
        [106.70, 29.65],
        [106.65, 29.65]
      ]
    ]
  ]
}

GeometryCollection (几何集合)

用于将不同类型(Point, LineString, Polygon, Multi*)的几何图形封装到一个对象中, 包含一个 "geometries" 成员,其值是一个数组,数组中的每个元素都是一个完整的 GeoJSON 几何对象

一个包含 Point, LineString, Polygon 的 对象

{
  "type": "GeometryCollection",
  "geometries": [
    {
      "type": "Point",
      "coordinates": [106.52, 29.53]
    },
    {
      "type": "LineString",
      "coordinates": [
        [106.50, 29.50],
        [106.53, 29.50],
        [106.53, 29.55]
      ]
    },
    {
      "type": "Polygon",
      "coordinates": [
        [
          [106.55, 29.55],
          [106.55, 29.50],
          [106.60, 29.50],
          [106.60, 29.55],
          [106.55, 29.55]
        ]
      ]
    }
  ]
}

特征类型

这个可以说是GeoJSON 的灵魂,它将几何形状与属性数据关联起来,使得图形有了意义,包括两个类型:“Feature” 和 “FeatureCollection”

Feature

基本结构如下:

  • "type": "Feature"
  • "geometry":包含一个几何对象(Point, Polygon, etc.)。
  • "properties":包含任何非地理属性数据(例如:名称、人口、年份、颜色等)。

下面为一个LineString、属性描述其为一条高速公路

{
  "type": "Feature",
  "geometry": {
    "type": "LineString",
    "coordinates": [
      [106.50, 29.50],
      [106.55, 29.52],
      [106.60, 29.54],
      [106.65, 29.56]
    ]
  },
  "properties": {
    "id": "G5001",
    "name": "重庆绕城高速(部分)",
    "speed": 100,
    "length": 15.5
  }
}

FeatureCollection

FeatureCollection 表示Feature的集合,几乎网上下载的GeoJSON文件都是 FeatureCollection 结构的 基本结构:

  • "type": "FeatureCollection"
  • "features":一个包含零个或多个 Feature 对象的数组。

一个表示线路和服务区的 GeoJSON

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [106.40, 29.50],
          [106.45, 29.52],
          [106.50, 29.54],
          [106.55, 29.56],
          [106.60, 29.58]
        ]
      },
      "properties": {
        "id": "H-1234",
        "name": "城市快速通道A段",
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [106.45, 29.52]
      },
      "properties": {
        "id": "SA-001",
        "name": "龙溪服务区",
      }
    }
  ]
}

OpenLayers 中使用

在OpenLayers中写了两个Demo巩固、一个用于绘制线路后导出GeoJSON文件,另一个加载导出的文件根据渲染线路和服务区

绘制线路以及站点

涉及主要功能点:

  • 绘制线路,并为香炉加上线路表示以及名字
  • 绘制站点,并为站点加上站点标识以及名字
import { useEffect, useRef, useState } from 'react'
import * as layer from 'ol/layer'
import * as source from 'ol/source'
import { Map } from 'ol'
import View from 'ol/View.js'
import OSM from 'ol/source/OSM'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Fill from 'ol/style/Fill'
import * as format from 'ol/format'
import CircleStyle from 'ol/style/Circle'

import * as interaction from 'ol/interaction'
import type { DrawEvent } from 'ol/interaction/Draw'
const projection = 'EPSG:4326'

let i = 0

const createDrawStyle = () => {
  return new Style({
    image: new CircleStyle({
      radius: 4,
      fill: new Fill({
        color: '#000000'
      })
    }),
    stroke: new Stroke({
      width: 2,
      color: 'red'
    })
  })
}
const DrawLine = () => {
  const mapRef = useRef<Map>(null)
  const drawRef = useRef<interaction.Draw>(null)
  const drawSource = useRef<source.Vector>(null)
  const [drawType, setDrawType] = useState<string>('Point')

  useEffect(() => {
    const vectorSource = new source.Vector()
    drawSource.current = vectorSource
    const vectorLayer = new layer.Vector({
      source: vectorSource,
      style: createDrawStyle()
    })

    const osmLayer = new layer.Tile({
      source: new OSM()
    })

    const view = new View({
      zoom: 10,
      projection,
      center: [106.56, 29.57]
    })

    const map = new Map({
      target: 'draw',
      layers: [osmLayer, vectorLayer],
      view,
      controls: []
    })
    const draw = new interaction.Draw({
      type: 'Point',
      source: vectorSource
    })
    map.addInteraction(draw)
    mapRef.current = map
    drawRef.current = draw
  }, [])

  const handleClick = (event: React.ChangeEvent<HTMLInputElement>) => {
    setDrawType(event.target.value)
  }

  useEffect(() => {
    const map = mapRef.current!
    const source = drawSource.current!
    source.getFeatures().forEach(item => {
      if(item.getGeometry()?.getType() === drawType) {
        source.removeFeature(item)
      }
    })
    map.removeInteraction(drawRef.current!)
    const handleDrawEnd = (event: DrawEvent) => {
      const feature = event.feature
      feature.setProperties({
        type: drawType === 'Point' ? 'station' : 'line',
        name: drawType === 'Point' ? '站点' + i : '高速'
      })
      i++
    }
    const draw = new interaction.Draw({
      type: drawType,
      source: source,
      style: createDrawStyle()
    })
    draw.on('drawend', handleDrawEnd)
    drawRef.current = draw
    map.addInteraction(draw)
    return () => {
      draw.un('drawend', handleDrawEnd)
    }
  }, [drawType])

  const handleExport = () => {
    const source = drawSource.current!
    const features = source.getFeatures()
    const featureProjection = mapRef
      .current!.getView()
      .getProjection()
      .getCode()
    const jsonFormat = new format.GeoJSON({
      featureProjection,
      dataProjection: projection
    })

    const json = jsonFormat.writeFeatures(features, {
      featureProjection,
      dataProjection: projection
    })
    // 导出
    console.log(json, '>>>>')
  }

  return (
    <>
      <div
        style={{
          width: '800px',
          height: '400px',
          position: 'relative',
          display: 'flex'
        }}
      >
        <div id="draw" style={{ width: '800px', height: '400px' }}></div>
      </div>
      <input
        type="radio"
        checked={drawType === 'Point'}
        value={'Point'}
        onChange={handleClick}
      />{' '}
      添加站点
      <input
        type="radio"
        checked={drawType === 'LineString'}
        value={'LineString'}
        onChange={handleClick}
      />{' '}
      添加线路
      <button onClick={handleExport}>导出</button>
    </>
  )
}

export default DrawLine

当绘制好后可以点击导出、然后可以看到控制台有我们的JSON数据,这里我示例了一下,本来想找条真实的路,结果定位重庆就找不到一条直的路,算了。还有这里我们也可以手动便利features自己写json,能进一步巩固了解!

这是我绘制的供后面使用效果图如下:

4c2d8a92240a8a7ebe53730fd8dd0d35.png

OpenLayers 中使用刚才导入的数据

我们可以导入刚才的数据并加入一些交互,这里我对数据做了一些加工,这一步可以在编辑完成,但我们的编辑比较粗糙,我就手动对JSON做了编辑,主要功能:

  • 支持路线选中、显示路线信息
  • 支持站点选中、查看站点信息
import { useEffect, useRef, useState } from 'react'
import * as layer from 'ol/layer'
import * as source from 'ol/source'
import { Map } from 'ol'
import View from 'ol/View.js'
import OSM from 'ol/source/OSM'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Fill from 'ol/style/Fill'
import * as format from 'ol/format'
import CircleStyle from 'ol/style/Circle'

import * as interaction from 'ol/interaction'
import { pointerMove } from 'ol/events/condition'
const projection = 'EPSG:4326'


const createDrawStyle = () => {
  return new Style({
    image: new CircleStyle({
      radius: 4,
      fill: new Fill({
        color: 'red'
      })
    }),
    stroke: new Stroke({
      width: 2,
      color: '#000'
    })
  })
}
const DrawLine = () => {
  const mapRef = useRef<Map>(null)
  const wrapperRef = useRef<HTMLDivElement>(null)
  const drawSource = useRef<source.Vector>(null)
  const [active, setActive] = useState<any>(null)

  useEffect(() => {
    const vectorSource = new source.Vector({
      url: '/geo/cq.json',
      format: new format.GeoJSON()
    })
    drawSource.current = vectorSource
    const vectorLayer = new layer.Vector({
      source: vectorSource,
      style: createDrawStyle()
    })

    const osmLayer = new layer.Tile({
      source: new OSM()
    })

    const view = new View({
      zoom: 10,
      projection,
      center: [106.56, 29.57]
    })

    const map = new Map({
      target: 'draw',
      layers: [osmLayer, vectorLayer],
      view,
      controls: []
    })
    const select = new interaction.Select({
      condition: pointerMove,
      style: new Style({
        image: new CircleStyle({
          radius: 8,
          fill: new Fill({
            color: 'red'
          })
        }),
        stroke: new Stroke({
          width: 4,
          color: '#000'
        })
      })
    })
    map.addInteraction(select)
    map.on('pointermove', event => {
      const pixel = event.pixel;
      const features = map.getFeaturesAtPixel(pixel);
      if(features.length) {
        const feature = features.find(item => item.getGeometry()?.getType() === 'Point') || features[0]
        setActive({
          pixel,
          properties: feature.getProperties()
        })
      } else {
        setActive(null)
      }
    })
    mapRef.current = map
  }, [])

  return (
    <>
      <div
        style={{
          width: '800px',
          height: '400px',
          position: 'relative',
          display: 'flex'
        }}
      >
        <div id="draw" ref={wrapperRef} style={{ width: '800px', height: '400px', cursor: active ? 'pointer' : 'auto'  }}></div>
        {active && <div style={{
          width: '100px',
          background: '#fff',
          padding: '4px',
          borderRadius: '4px',
          position: 'absolute',
          left: active.pixel[0] + 20 + 'px',
          top: active.pixel[1] + 20 + 'px'
        }}>
          <h5>名称:{active.properties.name}</h5>
        </div>}
      </div>
    </>
  )
}

export default DrawLine

效果图如下

6b9ba06ac2c949da917c36dbe5e48919.png

这是我使用的JSON

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [
            106.40801847988175,
            29.57298086250036
          ],
          [
            106.56685851097549,
            29.580326066250358
          ],
          [
            106.69999032894425,
            29.54084559609411
          ],
          [
            106.80098688050674,
            29.43066753984411
          ],
          [
            106.83220399644425,
            29.410468229531606
          ],
          [
            106.88362042269425,
            29.403123025781607
          ],
          [
            106.91116493675675,
            29.406795627656606
          ],
          [
            106.96074506206925,
            29.417813433281605
          ]
        ]
      },
      "properties": {
        "type": "line",
        "name": "成渝高速"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.70012461444661,
          29.539872075705606
        ]
      },
      "properties": {
        "type": "station",
        "name": "安康"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.56685698617343,
          29.57991493114919
        ]
      },
      "properties": {
        "type": "station",
        "name": "巴中"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.88282014240801,
          29.40285042973459
        ]
      },
      "properties": {
        "type": "station",
        "name": "渝北"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.96102884444625,
          29.417240830909627
        ]
      },
      "properties": {
        "type": "station",
        "name": "简阳"
      }
    }
  ]
}

总结

通过本文的深入探索,我们理解了 GeoJSON 作为 Web 地理空间数据通用语言的核心优势:

  • 简洁: 基于 JSON 格式,结构清晰,易于人机阅读和编写。
  • 灵活: 强大的 Feature 和 FeatureCollection 结构允许我们将复杂的地理几何图形(如 LineString, Polygon, MultiPolygon)与丰富的非地理属性数据 (properties) 完美结合。
  • 标准化: 统一的 WGS84 坐标系和严格的规范(如右手法则),确保了 GeoJSON 文件在不同平台和 Web 地图库之间的互操作性。

一码难求的动画 Agent 导演,离「疯狂动物城」还有多远?|AI 上新

2025年12月1日 18:11

作者| 金光浩

编辑| 靖宇

最近 AI 圈出了一款有趣的产品:「OiiOii」,一款专注 AI 生成动画的 Agent。

而它异常火爆,7210 个内测名额很快被抢光,闲鱼上免费邀请码被炒到 30 块,甚至据说内测用户里还出现了全网 2000w 的顶级创作者。

OiiOii 网站首页|图片来源:OiiOii 网站

 

等我进入内测群,发现群号已经到了 50 几了,按照 500 人一个群粗略算下,大概有 2 万多人在排队内测。

作为产品经理,我的直觉告诉我, 当一个还在内测的工具被某鱼盯上时,它一定切中了某类人群的「刚需」。

但它能顶替一个小型动画工作室吗?还是只是 mock 了一些工作流?

我带着这个好奇,在闲鱼上花 32 块钱买了一个邀请码,拿到了账号,打算深度体验一下。

我想知道,AI 做的动画,离《疯狂动物城》到底还有多远?

 

01

不是工具,是导演团队

 

刚进 OiiOii 体验,我就发现它很有趣。

传统的 AI 视频工具,像在教一个笨徒弟:你输入 Prompt,机器生成视频,效果不好你得自己改 Prompt,反复跟 AI 沟通。

但在 OiiOii 里,我感觉自己更像是,一个只需要提需求的「甲方爸爸」。

它没让我写复杂的图像提示词,而是给我派了一个「动画团队」:艺术总监、场景设计师、编剧、分镜师……

有 7 个 AI Agent 作为乙方,为我效劳。

这种感觉真好。

第一个任务,我给它了一个有意思的提示词: 一个充满神圣光芒的殿堂,一对兄妹正与一位自称为神的对手进行一场赌局。风格要史诗感,带有日式幻想。

当我开始,接下来的流程,让我有点意外。

艺术总监 Agent 没有急着出片,而是先拉编剧 Agent 进群,拆解我的需求,编写剧本。

OiiOii 网站 agent 沟通|图片来源:OiiOii 网站

 

紧接着,角色设计师和分镜师 Agent 介入,开始设计角色和规划音乐。

这给我一种很强烈的「既视感」:这不就是真实世界里短剧公司的作业流程吗?

OiiOii 网站 agent 沟通|图片来源:OiiOii 网站

 

整个流程里我选择了「托管模式」,很多内容只需要点击确认

(虽然也有对话模式,但作为甲方,我当然想一键直出)。

OiiOii 网站交互引导|图片来源:OiiOii 网站

 

几分钟后,一段包含音频、画面、转场的 60 秒短片生成了。

有趣的是,作为甲方,虽然完成视频的是 AI,但是我还是感受到了创作的乐趣,这可能就是这个产品的神奇之处吧。

OiiOii 生成的 60s 动漫|视频来源:OiiOii 网站

 

接着,我让它生成一个音乐 MV:以欧阳娜娜的新歌《暮色森林》为意境,让 deepseek 生成一个 mv,然后给到 OiiOii。

然后,它做出了这样的效果。

OiiOii 生成的音乐 mv|视频来源:OiiOii 网站

 

接着我测试了一种古风武侠的风格,可以看到画风一致性很棒:

OiiOii 网站生成的分镜图片和视频|图片来源:OiiOii 网站

 

 

02

惊艳与遗憾并存

 

看完生成的成片,我有两个直观的感受。

第一个,它给人的「导演感」很强。

大多数 AI 视频只是让画面动起来,但 OiiOii 生成的内容,能明显看出镜头语言设计:推拉摇移、景别变化、甚至景深与焦点的转换,都非常符合影视逻辑。

比较让我惊喜的是人物的一致性,以往用 Midjourney 做短片,最头疼的就是上一秒主角是圆脸,下一秒变成了方脸。

但 OiiOii 通过 Agent 之间的协同,以及 Sora2 的强大能力,很好地锁住了角色特征。

虽然还没到 100% 完美,但至少在那 30 秒里,那对兄妹没有突然「变异」。

第二,它确实还很「糙」。

虽然意境到了,镜头感有了,但必须诚实地说,目前的画质像素还不够高(当前可能是出于降低成本,没用最好的模型),如果你仔细看,细节处还是有些不清晰,而且创作时间比我想象中要慢。

当然,如果要给这个视频打分,在工业级动画电影面前,它可能是不及格的。但如果放在自媒体短视频、MV 概念片这个维度,它确实能打 80 分以上。

这就引出了一个非常有意思的思考:

为什么一个画质还没做到顶尖的产品,能让 2 万多人趋之若鹜?

 

03

选择赛道比努力更重要

 

OiiOii 的爆火验证了一个 AI 创业铁律: Agent 创业,一定要垂!

我们来算一笔账。

如果你做的是通用视频生成(像 Sora 这种),你的竞争对手是真实世界:大众对真实世界的期待值是 100 分,而你的能力可能只有 80 分,且人物稍有扭曲或者变化,就会给用户一种「恐怖谷效应」,让用户体验瞬间掉到 60 分以下。

但 OiiOii 极其聪明地选择了「动画」这个垂直赛道。

在动画的世界里,逻辑变了,观众对动画的宽容度极高。这里没有恐怖谷效应,稍微夸张、变形一点,大家会觉得这是「艺术风格」,不影响看「剧情」。

目前市面上的同类产品,如果让普通玩家自己折腾,可能只能做出 40 分的作品。而 OiiOii 这个团队,虽然团队技术上限可能只有 90 分,但它通过 Agent 的流程化封装,让一个小白也能稳定输出 70 分以上的作品。

这就是选择好的赛道的重要性!这就叫:选择大于努力。

OiiOii 做对了什么?

它把原本需要懂分镜、懂三视图、懂 Prompt 的专业门槛,降低到了「会打字」就行,吸引了无数对动画创作好奇的小白。

OiiOii 网站生成的剧本、分镜描述|图片来源:OiiOii 网站

 

其次就是成本降低,以前,用户找 AI 做一个 30 秒的动画 MV,没个几十几百下不来。

现在?给大家免费体验(内测期间)。

对于那些做短 MV、视频号的自媒体来说,这就是生产力革命。

 

04

距离《疯狂动物城》,不是技术的距离

 

标题问了个很有意思的问题:OiiOii 生成的动画,离《疯狂动物城》还有多远?

如果只看画质,AI 生成的动画确实在以极快的速度(nanobanana2 可以生成 4K 的作品),在逐渐逼近院线水准。

但如果看创作动画的本质,可能还远远到不了:因为它们根本不是一种东西。

《疯狂动物城》的核心是什么?不是技术,是角色、情节和故事:

是朱迪与尼克的角色张力,是关于偏见的隐喻,是非常多的艺术家一起打磨出的审美。

但,换个角度看,如果对比的是「让普通人也能像迪士尼导演一样指挥团队讲故事」,那 OiiOii 已经推开了那扇门。

OiiOii 做的是什么?是让一个非专业人士,在 30 分钟内,通过对话,把脑子里的想法变成 7、80 分的动画。

而这种 AI 动画,又会创造出什么新的内容形态?

我最近刷小红书,发现越来越多博主开始用 AI 做短动画,不算特别精致,更多的是 15 秒的治愈的、搞笑的、甚至猎奇的故事,这些作品给人的感觉是:画面糙点没关系,重要的是快、是个性化、是能精准击中某个小众群体。

这可能才是 AI 动画的意义,不是替代皮克斯,而是让每个人都能成为自己创意的导演。

纵观历史,技术的发展,更多带来的是机会。

就像摄影的历史。胶片时代,摄影是少数人的艺术;数码时代,人人都能拍照;手机时代,每个人都是视觉创作者。技术发展,没有让专业摄影师失业,反而让专业的人能更容易创造作品,同时,由于越来越多参与者的涌入,整个视觉内容生态变得更丰富。

我想,动画可能也一样,AI 带来的,是更多的可能性。

现在的 AI,正在让「动画表达」从专业技能变成一种基础能力,让每个人都能创作 70 分的作品。

专业人才依然有自己的一席之地,像《疯狂动物城》那种 95 分的顶尖作品,还需要专业艺术家才能创作出来。

 

05

真正的护城河,还是 Know How

 

用完 OiiOii,我跟几个做 AI 的创业者聊了聊。

大家都在问:这个产品的壁垒在哪?

技术上看,底层模型的 api 人人都可以接入,且据创始人说,目前这个版本两个月就开发完,凭什么别人抄不走?

我认为, Know How(行业经验) 才是壁垒。

什么是 OiiOii 的 Know How?

就是那些藏在创作团队脑海里,决定产品能不能用的「隐性知识」:

1、比如镜头语言,什么时候该用特写、该用全景,这不是 AI 模型能自己学会的,是团队里必须有懂导演的人。

2、比如节奏感,30 秒的视频,前 10 秒抓眼球、中间 10 秒讲清楚、后 10 秒留钩子,这是短视频时代的创作技巧。

3、比如角色一致性,怎么让同一个角色在不同镜头里看起来是同一个人,这背后有大量的工程经验。

这些 Know How,才是 OiiOii 真正的壁垒。

这个产品给我的感悟是:ai agent 的竞争,不是谁的人更多、谁接入的模型更强,而是谁更懂行业—— 做 AI 产品,应该先找到你的 Know How,再考虑技术实现。

模型会越来越开源,算力会越来越便宜,真正稀缺的是「懂动画+懂 AI+懂产品」的复合型团队。

OiiOii 的团队里肯定有真正做过动画的人,这是那些纯技术团队学不来的。

 

06

内测 2 万人,说明了什么?

 

最后聊聊 OiiOii 的爆火。

闲鱼炒到 30 块的邀请码、2 万人的内测群,这些数字说明了什么?

表面上看,是产品做得好,是团队有 know how,是赛道选对了。

但深层次,有两个更重要的原因。

第一个,是 AI 视频动画的风口到了

过去,AI 生成视频最大的痛点在于「人物一致性」难以保障。行业内通常的解决思路是:先由 AI 生成一张角色图,再基于这张图生成多个不同视角或姿态的版本;然后在不同分镜中选用对应的视图,分别生成短视频片段(这非常考验图像大模型的一致性能力),最后将这些片段拼接起来,形成一段连续动画。

早期,由于各类 AI 图像模型在一致性上普遍较弱,这种方法效果有限。

而随着 Sora2 的出现,人物一致性实现了质的飞跃,这已经是比较好的入场时机。

随着技术的进一步迭代,最近发布的 nanobanana2,将这个赛道推向了全新高度,它的任务一致性表现尤为出色。

眼下,技术窗口已经打开,OiiOii 有望成为这一技术红利的首批受益者。

这其实说明一件事:谁能够最快地将 AI 能力的提升转化为产品,谁就能抢占先机。就像过去,因为 claude 能力提升,cursor 和 manus 产品能力获得大幅提升类似。

第二,更深层的原因,我认为是需求侧的变化

短视频时代,每个人都需要视觉表达:博主要做片头、创业者要做 demo、ai 产品要做宣传片、自媒体需要做动画。

需求爆炸了,但供给端还卡在专业工具和专业人才上。

OiiOii 做的,就是把这个缺口补上。

当缺口补上,这就带来一种强大的市场扩大效应:

原本只有 1 万人的专业创作圈,瞬间扩大到了 20 万人的泛创作者圈。

这也预示着,我们来到了 一个人人都可以创作动画的时代。

 

07

产品不免费还会有热度吗?

 

但说到这里,让我泼个冷水:OiiOii 的爆火真的是可持续的吗?

现在大家愿意排队拿邀请码,可能更多是因为新鲜、免费、好奇。

等新鲜劲过了,真正留下来的会是谁?而留下来的,他们付费意愿有多强?

从商业视角看, OiiOii 现在最大的挑战不是技术,而是找到自己的 PMF。

这是因为,OiiOii 内测期,为了更好的调试工程化,免费给用户使用,这很合理。但当 OiiOii 内测结束,开始尝试收费,其定价是否被普通用户接受,还是较难判断的。

拿字节最新发布的 doubao-seedance-1.0-pro-fast 模型来说,一条 10 秒的 720p 视频,成本接近 1 元,而如果短视频 20s,那么成本就接近两元,那产品定价 5 元,用户能接受吗?

我不知道答案。

但能确定的是,只有等到那一天真正到来,当用户真的愿意为视频效果付费时,OiiOii 才真正验证了 PMF。

 

08

未知,更让人兴奋

 

体验 OiiOii 这几天,我反复在想一个问题:AI 到底在改变什么?

技术圈喜欢谈 AGI 什么时候来,科技圈喜欢谈 AI 到底是否在产生泡沫,辛顿老师喜欢谈 AI 对人类的影响。

我看到的是一种更具体的变化: AI 在重新定义「专业」的边界。

以前,做动画是专业技能,需要看各种专业知识、需要学和用各种工具、需要投入 1 万小时在里面。

现在,OiiOii 把这个门槛降到了「会打字」,这让更多人获得了「刚好够用」的专业能力。

OiiOii 的意义,在于它打开了一扇门:动画表达,从此不再是少数人的特权。

至于这扇门后面是什么我不知道:

新形态的内容?甚至诞生新的「动画」品类?

这种未知,才是最让人兴奋的部分。

JavaScript 原型链:理解对象继承的核心机制

作者 Tzarevich
2025年12月1日 18:07

JavaScript 原型链:理解对象继承的核心机制

在 JavaScript 中,原型链(Prototype Chain) 是实现对象继承和属性查找的核心机制。与传统面向对象语言(如 Java、C++)基于“类”的继承不同,JavaScript 采用的是 基于原型的继承模型。本文将结合 Promise 实例和普通构造函数示例,深入浅出地解析原型链的工作原理。


一、什么是原型链?

每个 JavaScript 对象(除 null 外)内部都有一个隐藏属性 [[Prototype]](可通过 __proto__ 访问),它指向另一个对象——这个对象就是该对象的“原型”。当试图访问一个对象的属性时,如果该对象自身没有这个属性,JavaScript 引擎会沿着原型链向上查找,直到找到该属性或到达原型链的末端(即 null)。

关键点:JavaScript 的继承不是靠“血缘”,而是靠“链条”——原型链。


二、构造函数、原型对象与实例的关系

以自定义构造函数为例:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.species = '人';

let zeng = new Person('jw', 18);
  • Person 是一个构造函数。
  • Person.prototype 是一个普通对象,所有通过 new Person() 创建的实例都会以它为原型。
  • zeng.__proto__ === Person.prototype成立
  • Person.prototype.constructor === Person成立

这种结构形成了经典的“三角关系”:

  • 实例(zeng)通过 __proto__ 指向原型对象(Person.prototype
  • 原型对象通过 constructor 指回构造函数(Person

🚂 比喻:可以把 constructor 看作火车头,prototype 是车身,而每个实例是挂在车身后的车厢。它们通过“挂钩”(__proto__)连接在一起。


三、动态修改原型:打破常规

JavaScript 的原型是可变的。我们可以随时修改对象的 __proto__

const kong = {
  name: 'kong',
  hobbies: ['篮球', '足球'],
};

zeng.__proto__ = kong;
console.log(zeng.hobbies); // ['篮球', '足球']
console.log(zeng.species); // undefined

此时:

  • zeng 不再从 Person.prototype 继承属性;
  • 而是从 kong 对象继承;
  • 因此 species 找不到了,但 hobbies 可以访问。

⚠️ 注意:虽然技术上可行,但不推荐随意修改 __proto__ ,因为它会破坏性能优化,并可能导致代码难以维护。


四、内置对象也遵循原型链:以 Promise 为例

ES6 引入的 Promise 同样遵循原型链规则:

const p = new Promise((resolve, reject) => {
  setTimeout(() => reject('失败1'), 3000);
});
  • p 是一个 Promise 实例;
  • p.__proto__ === Promise.prototypetrue
  • Promise.prototype 上定义了 .then(), .catch(), .finally() 等方法;
  • 所以 p.then(...) 实际上调用的是 Promise.prototype.then

执行流程:

  1. new Promise(...) 立即执行 executor 函数(同步)→ 输出 '111'
  2. 主线程继续执行 → 输出 '222'p 的初始状态(pending)
  3. 3 秒后,reject('失败1') 触发状态变为 rejected
  4. 微任务队列中安排 .catch() 回调 → 输出 '失败1'
  5. .finally() 总是执行 → 输出 'finally'

这再次印证:所有对象的行为都依赖于其原型链上的方法


五、原型链的本质:属性查找机制

当你写 obj.method() 时,JavaScript 引擎会:

  1. obj 自身查找 method
  2. 若无,则查找 obj.__proto__
  3. 若仍无,继续查找 obj.__proto__.__proto__
  4. ……直到 Object.prototype(最顶层)
  5. 若最终找不到,返回 undefined

例如:

zeng.toString(); // 虽然 zeng 自身没有 toString,但 Object.prototype 有

因为:

zeng 
→ __proto__ = kong 
→ __proto__ = Object.prototype 
→ has toString()

六、总结

概念 说明
__proto__ 实例指向其原型的链接(非标准但广泛支持)
prototype 构造函数的属性,用于被实例的 __proto__ 引用
constructor 原型对象上的属性,指回构造函数
原型链 属性/方法查找的路径,从实例 → 原型 → 原型的原型 → … → null

JavaScript 的面向对象不是靠“类继承”,而是靠“对象委托”——你没有的,我帮你问我的原型要。这种灵活而强大的机制,正是 JavaScript 动态特性的基石。

✅ 牢记:一切皆对象,万物皆可链。理解原型链,就掌握了 JavaScript 面向对象的灵魂。

Promise.resolve(x) 等同 new Promise(resolve => resolve(x))?

作者 之恒君
2025年12月1日 18:05

Promise.resolve(x)return new Promise((resolve) => resolve(x)) 在多数场景下行为一致,但不能完全等同理解,需从规范定义的细节差异区分,具体分析如下:

一、核心行为的一致性(基础场景)

在处理“非Promise类型的x”或“非当前构造函数生成的Promise实例x”时,两者逻辑高度一致,均会创建一个新的Promise实例并以x为结果决议:

  1. 若x是普通值(如数字、字符串、对象等非Promise/非thenable类型),Promise.resolve(x) 会创建新Promise并直接决议为fulfilled状态,结果为x;new Promise((resolve) => resolve(x)) 也会通过调用resolve回调,让新Promise以x为结果fulfilled,这符合文档中PromiseResolve抽象操作对普通值的处理逻辑。
  2. 若x是thenable对象(含then方法的非Promise对象),两者都会触发“thenable同化”逻辑:调用x的then方法,用其结果决议新Promise,这与文档中Promise Resolve Functions处理thenable的步骤一致。

二、关键差异(不能完全等同的场景)

根据文档规范,Promise.resolve(x) 存在特殊优化逻辑,而 new Promise(...) 无此处理,导致两者在特定场景下行为不同:

  1. x是当前构造函数生成的Promise实例时

Promise.resolve(x) 规范文档:tc39.es/ecma262/mul…

文档明确规定,若x是Promise实例且其构造函数与当前Promise.resolve的this值(即构造函数C)相同,Promise.resolve(x) 会直接返回x,而非创建新Promise。
例如:

const p = new Promise((resolve) => resolve(1));
// Promise.resolve(p) 直接返回p,不新建Promise
console.log(Promise.resolve(p) === p); // true
// new Promise(...) 始终新建Promise,与p不是同一实例
console.log(new Promise(resolve => resolve(p)) === p); // false

这是Promise.resolve的核心优化,目的是避免对已存在的Promise实例重复包装,而new Promise(...)会强制新建实例,无法复用原有Promise。

  1. 构造函数为Promise子类时

Promise.resolve的this值是Promise子类(如class MyPromise extends Promise {}),Promise.resolve(x) 会通过NewPromiseCapability创建子类的Promise实例(若x非子类实例);而new Promise(...)始终创建原生Promise实例,无法关联子类构造函数。
例如:

class MyPromise extends Promise {}
// MyPromise.resolve(x) 创建MyPromise实例
console.log(MyPromise.resolve(1) instanceof MyPromise); // true
// new Promise(...) 始终创建原生Promise实例
console.log(new Promise(resolve => resolve(1)) instanceof MyPromise); // false

三、结论:可近似理解,但需注意规范差异

  • 日常开发简化理解:在不涉及“Promise实例复用”和“Promise子类”的场景下,可将Promise.resolve(x)近似看作return new Promise((resolve) => resolve(x)),两者最终都会生成以x为结果的fulfilled Promise,行为无明显差异。
  • 严格规范角度:两者不能完全等同。Promise.resolve(x) 是ECMAScript规范定义的静态方法,包含“复用同构造函数Promise实例”“适配子类构造函数”等优化逻辑,而new Promise(...)是基础的Promise创建方式,仅负责新建实例并执行executor回调,无特殊优化。

简言之new Promise((resolve) => resolve(x))Promise.resolve(x)的“基础实现逻辑”,但Promise.resolve(x)在规范层面补充了更智能的 实例复用子类适配 逻辑,功能更完善。

苹果正加速推进其首款可折叠iPhone的研发,预计将于2026年秋季正式发布

2025年12月1日 18:02
据摩根大通最新行业分析报告,苹果公司正加速推进其首款可折叠iPhone的研发,预计将于2026年秋季正式发布,有望在三项关键技术上实现突破——无折痕内屏、2400万像素屏下摄像头以及高容量电池。市场普遍预期,这款设备将延续苹果高端产品线的定价策略,初步估算售价约为2400美元,将成为有史以来最昂贵的iPhone。(新浪财经)

上海电信与阿里云达成战略合作

2025年12月1日 17:59
36氪获悉,12月1日,上海电信与阿里云签署战略合作协议,双方将围绕云计算与人工智能开展深度合作,共同推动上海及长三角地区政企客户的数字化与智能化转型。

CesiumLite-在三维地图中绘制3D图形变得游刃有余

2025年12月1日 17:53

🎯 告别重复造轮子!CesiumLite 实体管理模块让3D图形开发效率翻倍

本文深入介绍 CesiumLite 的实体管理模块,从开发痛点到封装原理,再到实战应用,带你全面了解如何优雅地管理 Cesium 三维实体。

📌 前言

在使用 Cesium.js 开发三维地图应用时,实体(Entity)的创建和管理是最常见的需求之一。无论是标注点位、绘制建筑轮廓,还是展示三维模型,都离不开实体的操作。

然而,Cesium 原生 API 虽然功能强大,但在实际开发中却存在不少痛点。本文将通过 CesiumLite 项目的实体管理模块,展示如何优雅地解决这些问题。

🎨 在线演示

项目提供了完整的功能演示页面,你可以访问以下链接查看实际效果:

在线演示

image.png项目地址

演示页面包含以下功能:

  • 🔹 多边形面
  • 🔹 盒子模型
  • 🔹 矩形
  • 🔹 球体
  • 🔹 椭圆形
  • 🔹 圆柱
  • 🔹 线段
  • 🔹 管道(PolylineVolume)
  • 🔹 走廊
  • 🔹 墙体

🚫 开发痛点分析

痛点 1:实体创建过于繁琐

使用 Cesium 原生 API 创建一个简单的多边形,需要这样写:

// 创建一个多边形实体
const entity = viewer.entities.add({
  polygon: {
    hierarchy: Cesium.Cartesian3.fromDegreesArray([
      -109.080842, 45.002073,
      -104.058488, 45.002073,
      -104.053011, 41.003906,
      -105.728954, 41.003906,
    ]),
    height: 5000,
    material: Cesium.Color.BLUE.withAlpha(0.5),
    outline: true,
    outlineColor: Cesium.Color.BLACK,
  }
});

// 如果需要定位到该实体
viewer.zoomTo(entity);

问题在于:

  • 每次创建都要重复写 viewer.entities.add()
  • 没有统一的实体 ID 管理机制
  • 定位功能需要单独调用
  • 实体更新和删除操作分散

痛点 2:实体生命周期管理混乱

当项目中实体数量增多时,管理变得复杂:

// 需要手动维护实体引用
const entities = [];
entities.push(viewer.entities.add({ /* ... */ }));
entities.push(viewer.entities.add({ /* ... */ }));

// 更新某个实体?需要先找到它
const targetEntity = entities.find(e => e.id === 'someId');
if (targetEntity) {
  targetEntity.polygon.material = Cesium.Color.RED;
}

// 删除某个实体?
viewer.entities.remove(targetEntity);

// 清空所有?
viewer.entities.removeAll(); // 这会删除所有实体,包括其他模块创建的!

问题在于:

  • 实体引用分散,难以统一管理
  • 查找、更新、删除操作繁琐
  • 清空操作会影响其他模块
  • 缺乏命名空间隔离

痛点 3:代码复用性差

每个项目都要重新实现相似的功能:

// 项目 A
class ProjectAEntityManager {
  addPolygon(options) { /* ... */ }
  removePolygon(id) { /* ... */ }
}

// 项目 B
class ProjectBEntityController {
  createEntity(config) { /* ... */ }
  deleteEntity(entityId) { /* ... */ }
}

// 项目 C - 又要重新写一遍...

问题在于:

  • 每个项目都在造轮子
  • 没有统一的最佳实践
  • 维护成本高,bug 重复出现

💡 CesiumLite 的解决方案

核心设计思路

CesiumLite 的实体管理模块采用了以下设计思路:

  1. 双层封装架构EntityManager + EntityWrapper
  2. 独立数据源隔离:使用 CustomDataSource 避免污染全局实体集合
  3. 统一 ID 管理:自动生成唯一 ID,支持自定义
  4. 链式操作支持:提供流畅的 API 调用体验

架构设计图

┌─────────────────────────────────────────┐
│          CesiumLite 核心类              │
│  ┌───────────────────────────────────┐  │
│  │      EntityManager 管理器         │  │
│  │  - 统一管理所有实体               │  │
│  │  - 独立 CustomDataSource          │  │
│  │  - 提供增删改查接口               │  │
│  │                                   │  │
│  │  ┌─────────────────────────────┐ │  │
│  │  │   EntityWrapper 实体包装器  │ │  │
│  │  │  - 封装单个实体             │ │  │
│  │  │  - 自动生成唯一 ID          │ │  │
│  │  │  - 提供更新方法             │ │  │
│  │  └─────────────────────────────┘ │  │
│  └───────────────────────────────────┘  │
│                  ↓                       │
│  ┌───────────────────────────────────┐  │
│  │      Cesium Viewer 实例           │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

🔧 核心代码实现

1. EntityWrapper:实体包装器

EntityWrapper 负责封装单个实体,提供统一的操作接口:

import { Entity, createGuid } from 'cesium';

class EntityWrapper {
    constructor(options = {}) {
        // 自动生成唯一 ID,也支持自定义
        this.id = options.id || createGuid();
        this.options = Object.assign({}, options);
        this.entity = new Entity(this.options);
    }

    // 更新实体属性
    update(options) {
        Object.assign(this.options, options);
        this.entity.update(this.options);
    }

    // 获取原生 Cesium 实体
    getEntity() {
        return this.entity;
    }
}

export default EntityWrapper;

设计亮点:

  • ✅ 自动生成唯一 ID,避免冲突
  • ✅ 保存实体配置,方便后续更新
  • ✅ 提供 getEntity() 方法,保持原生 API 的兼容性

2. EntityManager:实体管理器

EntityManager 是实体管理的核心,提供完整的生命周期管理:

import { CustomDataSource } from 'cesium';
import EntityWrapper from './entityWrapper';

class EntityManager {
    constructor(viewer) {
        if (!viewer) throw new Error('Viewer instance is required');
        this.viewer = viewer;

        // 创建独立的数据源,实现命名空间隔离
        this.dataSource = new CustomDataSource('entityManager');
        this.viewer.dataSources.add(this.dataSource);

        // 使用 Map 管理所有实体,O(1) 查找性能
        this.entities = new Map();
    }

    // 添加实体
    addEntity(options, isLocate = false) {
        const entityWrapper = new EntityWrapper(options);
        this.entities.set(entityWrapper.id, entityWrapper);
        this.dataSource.entities.add(entityWrapper.getEntity());

        // 支持创建后自动定位
        if (isLocate) {
            this.locateEntity(entityWrapper.id);
        }

        return entityWrapper.id;
    }

    // 移除实体
    removeEntity(entityId) {
        if (this.entities.has(entityId)) {
            const entityWrapper = this.entities.get(entityId);
            this.dataSource.entities.remove(entityWrapper.getEntity());
            this.entities.delete(entityId);
        }
    }

    // 更新实体
    updateEntity(entityId, options) {
        if (this.entities.has(entityId)) {
            const entityWrapper = this.entities.get(entityId);
            entityWrapper.update(options);
        }
    }

    // 视角定位到实体
    locateEntity(entityId) {
        if (this.entities.has(entityId)) {
            const entityWrapper = this.entities.get(entityId);
            this.viewer.zoomTo(entityWrapper.getEntity());
        }
    }

    // 获取所有实体
    getAllEntities() {
        return Array.from(this.entities.values()).map(wrapper => wrapper.getEntity());
    }

    // 清除所有实体(只清除当前管理器的实体)
    clearEntities() {
        this.dataSource.entities.removeAll();
        this.entities.clear();
    }
}

export default EntityManager;

设计亮点:

  • 独立数据源:使用 CustomDataSource 实现命名空间隔离,不会影响其他模块
  • 高效查找:使用 Map 数据结构,提供 O(1) 的查找性能
  • 自动定位:支持创建实体后自动飞行到目标位置
  • 统一接口:增删改查操作命名规范,易于理解

🎯 使用教程

基础用法

1. 初始化 CesiumLite
const cesiumLite = new CesiumLite('cesiumContainer', {
  map: {
    baseMap: {
      id: 'imagery'
    },
    camera: {
      longitude: 116.397428,
      latitude: 39.90923,
      height: 1000000
    }
  }
});
2. 添加各种几何实体
添加多边形
const polygonId = cesiumLite.entityManager.addEntity({
  polygon: {
    hierarchy: Cesium.Cartesian3.fromDegreesArray([
      -109.080842, 45.002073,
      -104.058488, 45.002073,
      -104.053011, 41.003906,
      -105.728954, 41.003906,
    ]),
    height: 5000,
    material: Cesium.Color.BLUE.withAlpha(0.5),
    outline: true,
    outlineColor: Cesium.Color.BLACK,
  }
}, true); // 第二个参数 true 表示创建后自动定位
添加盒子模型
cesiumLite.entityManager.addEntity({
  position: Cesium.Cartesian3.fromDegrees(-109.080842, 45.002073),
  box: {
    dimensions: new Cesium.Cartesian3(5000, 5000, 5000),
    material: Cesium.Color.RED.withAlpha(0.5),
  }
}, true);
添加球体
cesiumLite.entityManager.addEntity({
  name: "Three-dimensional sphere",
  position: Cesium.Cartesian3.fromDegrees(-114.0, 40.0, 300000.0),
  ellipsoid: {
    radii: new Cesium.Cartesian3(200000.0, 200000.0, 300000.0),
    innerRadii: new Cesium.Cartesian3(150000.0, 150000.0, 200000.0),
    material: Cesium.Color.RED.withAlpha(0.5),
    outline: true
  }
}, true);
添加圆柱
cesiumLite.entityManager.addEntity({
  position: Cesium.Cartesian3.fromDegrees(-104.058488, 44.996596),
  cylinder: {
    length: 5000,
    topRadius: 500,
    bottomRadius: 500,
    material: Cesium.Color.RED.withAlpha(0.5),
    outline: true,
    numberOfVerticalLines: 20
  }
}, true);
添加走廊(Corridor)
cesiumLite.entityManager.addEntity({
  corridor: {
    positions: Cesium.Cartesian3.fromDegreesArray([
      -109.080842, 45.002073,
      -105.91517, 45.002073,
      -104.058488, 44.996596,
    ]),
    width: 5000,
    height: 1000,
    extrudedHeight: 10000,
    material: Cesium.Color.RED.withAlpha(0.5),
  }
}, true);
添加墙(Wall)
cesiumLite.entityManager.addEntity({
  name: "Vertical wall",
  wall: {
    positions: Cesium.Cartesian3.fromDegreesArrayHeights([
      -107.0, 43.0, 100000.0,
      -97.0, 43.0, 100000.0,
      -97.0, 40.0, 100000.0,
      -107.0, 40.0, 100000.0,
    ]),
    material: Cesium.Color.RED.withAlpha(0.5),
    outline: true
  }
}, true);

高级操作

更新实体
// 保存实体 ID
const entityId = cesiumLite.entityManager.addEntity({ /* ... */ });

// 更新实体属性
cesiumLite.entityManager.updateEntity(entityId, {
  polygon: {
    material: Cesium.Color.GREEN.withAlpha(0.7)
  }
});
定位到指定实体
cesiumLite.entityManager.locateEntity(entityId);
删除指定实体
cesiumLite.entityManager.removeEntity(entityId);
清空所有实体
cesiumLite.entityManager.clearEntities();
获取所有实体
const allEntities = cesiumLite.entityManager.getAllEntities();
console.log('当前实体数量:', allEntities.length);

📊 对比传统开发方式

代码量对比

操作 传统方式 CesiumLite 减少代码量
创建实体 10+ 行 3 行 70%
创建并定位 15+ 行 3 行 80%
更新实体 8+ 行 1 行 87%
删除实体 5+ 行 1 行 80%
批量清空 10+ 行 1 行 90%

功能对比

功能 传统方式 CesiumLite
实体创建
唯一 ID 管理 ❌ 需手动实现 ✅ 自动生成
命名空间隔离 ❌ 需手动实现 ✅ 内置支持
自动定位 ❌ 需单独调用 ✅ 参数控制
统一更新接口 ❌ 分散操作 ✅ 统一接口
批量操作 ❌ 需手动循环 ✅ 内置支持

🚀 快速开始

1. 安装

# NPM 安装(推荐)
npm install cesium-lite

# 或者通过 GitHub 克隆
git clone https://github.com/lukeSuperCoder/cesium-lite.git
cd cesium-lite
npm install

2. 引入使用

方式一:NPM 方式
import CesiumLite from 'cesium-lite';
import 'cesium/Build/Cesium/Widgets/widgets.css';

const cesiumLite = new CesiumLite('cesiumContainer', {
  // 配置项
});

方式二:本地运行项目

# 克隆项目
git clone https://github.com/lukeSuperCoder/cesium-lite.git
cd cesium-lite

# 安装依赖
npm install

3. 运行示例

npm run dev

访问 http://localhost:8020/entity.html 查看实体管理示例。

💡 最佳实践建议

1. 合理使用自动定位

// 对于重要的首个实体,启用自动定位
const mainEntityId = cesiumLite.entityManager.addEntity(options, true);

// 批量创建时,关闭自动定位以提升性能
entities.forEach(entity => {
  cesiumLite.entityManager.addEntity(entity, false);
});

// 批量创建完成后,手动定位到某个实体
cesiumLite.entityManager.locateEntity(mainEntityId);

2. 实体 ID 管理

// 为重要实体指定自定义 ID
const buildingId = cesiumLite.entityManager.addEntity({
  id: 'building_main_001',  // 自定义 ID
  polygon: { /* ... */ }
});

// 后续可以直接使用自定义 ID 操作
cesiumLite.entityManager.updateEntity('building_main_001', { /* ... */ });

3. 批量操作优化

// 批量创建实体
const entityIds = [];
const batchData = [ /* 大量数据 */ ];

batchData.forEach(data => {
  const id = cesiumLite.entityManager.addEntity(data, false);
  entityIds.push(id);
});

// 需要时再批量定位
entityIds.forEach(id => {
  cesiumLite.entityManager.locateEntity(id);
});

🔮 未来规划

实体管理模块后续将会支持:

  • 实体分组管理
  • 实体样式预设
  • 实体动画支持
  • 实体点击事件封装
  • 实体序列化与反序列化
  • 批量操作性能优化

📚 相关资源

🙏 总结

CesiumLite 的实体管理模块通过双层封装架构,有效解决了 Cesium 原生开发中的诸多痛点:

  • 简化 API:减少 70%-90% 的代码量
  • 统一管理:自动 ID 生成 + 命名空间隔离
  • 开箱即用:无需重复造轮子
  • 性能优化:使用 Map 数据结构,高效查找

如果你正在使用 Cesium 开发三维地图应用,不妨试试 CesiumLite,让你的开发效率翻倍!


⭐ 如果这个项目对你有帮助,欢迎给个 Star 支持一下!

💬 有任何问题或建议,欢迎在评论区交流!

相关标签: #Cesium #三维地图 #WebGIS #前端开发 #JavaScript #开源项目 #地图可视化

同事:架构太复杂了,源码文件找半天。 我:源码溯源了解一下?

2025年12月1日 17:52

背景


相信刚入行,或是刚入行的小伙伴们,对于企业级代码与架构,以及扑面而来业务需求。想要在短时间内从对应的页面定位到组件时,是很难办到的事情,尤其是突然交给一个陌生的项目的需求,问题也会比较突出。

尤其是对于鼠鼠我本人来说,也是深有体会:司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 🤯🤯🤯

在这样的情况下,一款能够快速定位源码的插件呼之欲出🎉🎉🎉


通过本篇文章,大家能学习到:
  1. 如何编写一个简易的vite插件
  2. vite插件的生命周期是怎么样的
  3. 源码溯源,快速定位:实现思路,原理

首先准备好实验环境:vue+vite+pnpm 让cursor快速生成一个项目即可

image.png

在正式将源码定位之前,我想讲讲一个简易的vite插件该如何实现,这对我们后面的学习会有比较有效的帮助

如何写Vite插件

再讲如何编写vite插件之前,需要先了解一下如何将自己编写的vite插件在Vite的构建流程中生效:

Vite插件本质是一个对象,通过到处一个对象函数,放入Vite配置项数组中即可实现效果:

在配置文件中:

那么作为Vite的自定义插件,和webpack一样,需要使用各种生命周期钩子,才能实现对应的效果:

这里介绍一下主流的生命周期钩子:

主流钩子

配置阶段:

config(config, env ):

  • 触发时机:当vite读取配置时触发

  • 常用场景:修改或扩展配置对象

configResolved(resolvedConfig):

  • 触发时机:当配置解析完成时触发

  • 常用场景:获取最终配置,初始化插件状态

该阶段主要用于插件初始化或读取用户配置,不是必须

构建阶段

buildStart:

  • 触发时机: 构建开始

  • 常用场景: 初始化状态,打印日志,准备数据

buildEnd:

  • 触发时机: 构建结束

  • 常用场景:收尾,打印统计

closeBundle:

  • 触发时机:构建完成并生成文件后

  • 常用场景:做最终清理或发布的操作

主要用于插件需要做全局初始化或构建后操作的场景

模块解析和加载阶段

resolveId(id,importer)

  • 触发时机:解析模块路径时

  • 常用场景:重写模块路径,生成虚拟模块

load(id)

  • 触发时机:模块加载内容

  • 常用场景:返回模块代码,生成虚拟模块

moduleParsed

  • 触发时机:模块 AST 解析完成

  • 常用场景:分析模块 AST ,做统计或收集信息

核心点:虚拟模块一般用 resolveId + load,处理源码前可以分析 AST。

模块transform阶段(最常用)

thransform(code,id)

  • 触发时机:模块加载后,打包前

  • 常用场景:核心 hook,用于修改 源码 、注入代码、操作 Vue/ JSX ****AST

transformIndexHtml

  • 触发时机: HTML 文件处理阶段

  • 常用场景:修改 HTML 模版,例如注入script,link

transform 是最主流的钩子,几乎所有插件都至少用它做一次源码修改。

整个构建生命周期流程图来看是这样的:

image.png

针对LLM返回给我们的主流钩子使用频率来看,我们优先掌握的肯定就是:模块 transform 阶段,因为这个阶段是能够直接接触的源代码,更容易在源代码上动手脚的阶段。

模块 transform 阶段

好记性不如烂笔头,让我们实战来看看,这个阶段能够做什么呢?

什么是transform阶段

在Vite的构建过程中,一个文件会从源码 -> 浏览器可执行文件,会经历很多处理环节。比如:

  • TS-> js
  • JSX -> JS
  • VUE单文件组件拆成JS,CSS
  • 去掉console.log
  • 注入HMR代码
  • 压缩

而 transform 就是 Vite 插件体系里专门负责“把代码转成新代码”的阶段

transform的函数签名

transform(code, id) {
  return {
    code: '新的代码',
    map: null, // 或 sourcemap
  }
}
  1. Code: 当前拿到的文件 源码
  2. id:当前文件的绝对路径

返回值:

  1. 返回一个字符串:
return transformedCode

说明只修改了代码,不管 source map,由 Vite 自动处理部分情况。

⚠️ 但 source map 会丢失或错误。

  1. 返回一个包含code+map的对象
return {
  code: transformedCode,
  map: null  // 或 SourceMap 对象
}
  • Vite 会继续把 map 传给下一环
  • 最终映射会合并到 browser source map
  • 对 HMR Debug 友好

若map为null时,让vite自己处理

  1. 返回为null或undefined
  • 表示我不处理这个模块,让下个插件处理。即:跳过这个阶段的

何时会触发transform

  1. 开发( dev server) :Vite 在浏览器请求模块时,先 resolveIdload(读文件)→ transform → 返回给浏览器(并缓存结果)。
  2. 构建(build) :Rollup 打包流程,Vite 基于 Rollup 插件接口执行,顺序类似:resolveIdloadtransform → 打包。
  3. 对于 SFC(例如 Vue 单文件组件),一个 .vue 会被拆成多个请求(script/template/style),每个子模块都会走 transform,因此你会看到同一个文件被多次 transform(通过 id 的 query 区分)。

image.png

源码溯源

为什么需要源码溯源插件

谈到为什么需要源码 溯源。就得提到司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 行, 所以我们拟设计一款Vite插件配合油猴脚本,能够识别一个页面的所有组件,通过click,能够快速定位到对应的component。

设计思路是什么?

目标:

我们想要实现一个所见即所得模式,即能够清楚的看到一个页面由哪些组件组成,并且可以看到对应的组件渲染了页面的哪些地方,并且点击对应模块后,能够立马弹出组件对应的绝对路径,方便直接去寻找到对应的组件。

具体体现成什么样呢?这里起一个简单的小项目给大家看看

image.png

是一个很简单的小架构,当我们想要知道头部组件在对应源代码的哪个位置时,我们点击他:

image.png

第一个就是头部组件对应的组件路径,下面的就是其父组件,方便我们了解嵌套关系。

具体思路:

首先我们需要知道一件事情,浏览器最后渲染的内容,拿到的源文件是经过构建工具的转译,压缩,打包后的源代码,与自己实际开发是天壤之别,所以针对打包后的源代码溯源是不切实际的。所以我们的思路是:

  1. 需要在构建阶段,针对对应文件进行处理
  2. 具体处理就是将对应文件的绝对路径,通过某些方式,在构建后,保存到 源代码
  3. 再通过油猴插件,在浏览器中执行脚本,该脚本核心代码就是提取到点击模块对应的保存的绝对路径进行转译渲染出来,成为图片中的样式。
具体实现:
  1. 编写自定义vite插件,插件用处:在每个组件的根元素中添加自定义属性,内容为该文件绝对路径的编码形式存储在此。

  2. 将根元素的自定义属性值广播到子组件的类型中,任何你想点击/调试的元素都带有足够的信息

  3. 编写js脚本,核心在于提取到点击对应元素,能够快速识别转译出路径,并渲染到弹窗。

vite插件如何编写?

在编写插件前,我们需要明确我们插件需要做什么:

  • 每个Vue文件中的根元素,添加对应的自定义属性,属性值填的是对应路径的编码。

那么针对这个需求,我们首先需要分析,要使用哪个生命周期钩子才能实现对应的效果?

搜索过后,发现thransform(code,id) 这个钩子能够帮助我们实现我们想要的效果。

transform 是 Vite 插件体系里的编译钩子。每当 Vite 正在加载某个模块(无论是 .ts、.vue 还是别的可处理资源),都会把“源代码字符串 + 模块 id(含绝对路径/查询参数)”传进每个插件的 transform(code, id) ,让插件有机会在官方编译器运行前对源码 做一次改写、替换或分析

最后效果如下:

image.png

具体源代码实现:

export function cscMark(): Plugin {
  return {
    name: 'csc-mark',
    enforce: 'pre',
    transform(code, id) {
      if (!id.endsWith('.vue')) {
        return null;
      }

      const { template } = parse(code, { filename: id }).descriptor;

      if(template) {
        const elm = template.ast.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
        if(elm) {
          const tagString = `<${elm.tag}`;

          const insertIndex = elm.loc.source.indexOf(tagString) + tagString.length;
          const newSource
              = `${elm.loc.source.slice(0, insertIndex)} csc-mark="${LZString.compressToBase64(id)}"${elm.loc.source.slice(insertIndex)}`;
  
          code = code.replace(elm.loc.source, newSource);
        }
      }

      return code;
    }
  };
}
  1. 遍历每个vue组件

  2. 获得code里面template的内容

  3. 通过ast拿到根元素:elm

  4. 通过LZString.compressToBase64( id )绝对路径赋值进去。注:该钩子参数id就是遍历该文件的绝对路径

  5. 返回新代码给后续编译构建使用

如何将路径广播到子组件?

我们需要有个钩子,能够在上述标签打完之后,再逐一遍历该文件内的其他组件。将编码后的id注入class中。那么哪个钩子能够实习这种功能呢?

经过调研后发现:

Vue插件中,有个钩子能够帮助我们

export default defineConfig(({ mode }) => ({
  plugins: [vue({
    template: {
      compilerOptions: {
          nodeTransforms: [
              自己编写的函数
          ],
      },
  },
  }),cscMark() ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  server: {
    host: '0.0.0.0',
    port: 4173,
    open: true
  },
  define: {
    __APP_ENV__: JSON.stringify(mode)
  }
}));

用法:

在编译模板时,对每个 AST 节点执行自己编写的特定函数

🌰

<template>
  <div csc-mark="路径1">
    <h1>标题</h1>
    <ks-dialog>弹窗</ks-dialog>
  </div>
</template>

Vue插件编译器会解析:

  1. 读取.vue文件
  2. 解析 template 部分
  3. 生成 AST(抽象语法树)

最后生成:

ROOT (type: 0)
  └── <div> (ELEMENT, type: 1)
       ├── csc-mark="路径1" (ATTRIBUTE)
       ├── <h1> (ELEMENT, type: 1)
       │    └── "标题" (TEXT)
       └── <ks-dialog> (ELEMENT, type: 1)
            └── "弹窗" (TEXT)

Vue 编译器会深度优先遍历 AST,对每个节点调用自定义的函数。

那这个自定义函数该如何去进行编写呢?

export const cscMarkNodeTransform = (node, context) => {
  if (node.type === NodeTypes.ELEMENT && context.parent) {
      if ([NodeTypes.ROOT, NodeTypes.IF_BRANCH].includes(context.parent.type)) {
          const firstElm = context.parent.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
          const addText = firstElm && firstElm.props.find(item => item.name === 'csc-mark')?.value?.content || '';

          if (addText) {
                  addClass(node, addText, 'class');
          }
      } else if (context.parent.props?.find(item => item.name === 'csc-mark')?.value?.content) {
          const addText = context.parent.props.find(item => item.name === 'csc-mark')?.value?.content || '';
          if (addText) {
                  addClass(node, addText, 'class');
          }
      }

  }
};
  1. cscMarkNodeTransform 中,只有当当前 node 是 NodeTypes.ELEMENT 且存在 context.parent 时才会继续处理,避免对非元素节点或无父节点的情况做多余操作
  2. 当父节点是 ROOT 或 IF_BRANCH 时,会查找父节点的首个子元素,读取其 csc-mark 属性的内容,并将该内容通过 addClass 加在当前节点的 class 上,从而把顶层 csc-mark 标记扩散到具体元素。
  3. 如果父节点本身带有 csc-mark 属性,就直接读取父节点的该属性内容并同样调用 addClass,以确保嵌套元素 继承 父级 csc-mark 定义的类名

页面效果呈现:

image.png

油猴脚本编写:

脚本作用:

  1. 添加检查button,只有点击button时,才会开启溯源功能
  2. 点击后高亮所有带有css-vite-mark-类名的元素
  3. 点击元素时,收集并显示嵌套组件及组件绝对路径

核心代码解释

1.组件层次结构的收集:

  • 这个函数从点击的元素开始向上遍历DOM树,收集所有带有标记的父元素,构建组件层次结构。
 // 函数:收集从顶层到当前元素的 csc-mark 属性列表
    function collectCscMarkHierarchy(element) {
        let cscMarkList = [];
        while (element) {
            if (element.hasAttribute('csc-mark')) {
                cscMarkList.push({ element, mark: element.getAttribute('csc-mark') });
            }
            element = element.parentElement;
        }
        return cscMarkList;
    }

2.路径解码:

这部分代码从类名中提取压缩的路径部分,然后使用LZString.decompressFromBase64解码还原为实际绝对路径。

// 处理源码路径部分代码
cssMarkList.forEach(item => {
    const tag = item.element.tagName.toLowerCase();
    try {
        const encodedPath = item.originMark.substring(prefix.length);
        const filePath = LZString.decompressFromBase64(encodedPath);
        decodedPaths.push({ tag, filePath });
    } catch (e) {
        console.error('解码路径失败:', e);
    }
});

3.交互机制

用户点击该元素时,收集组件嵌套,并渲染对话框

 // 函数:处理点击事件并显示 csc-mark 层级
    function handleClick(event) {
        let element = event.target;
  
        // 遍历 DOM 树查找最近的具有 csc-mark 属性的祖先元素
        while (element && !element.hasAttribute('csc-mark')) {
            element = element.parentElement;
        }
  
        if (element && element.hasAttribute('csc-mark')) {
            event.stopPropagation();
            event.preventDefault();
            const cscMarkList = collectCscMarkHierarchy(element);
            showCustomDialog(cscMarkList);
        }
    }
  

具体使用流程:

  1. 启动开发服务器
  2. 通过油猴插件添加脚本

image.png 3. 点击inspect按钮

image.png

  1. 之后想要修改哪个模块就可以进行点击

image.png

⚠️使用该油猴脚本时需要注意匹配到你对应的项目路径

image.png

总结:

通过上述方法可以实现一个简易的源码定位系统了,能够帮助我们在很多复杂项目中快速定位到自己需要修改的模块所对应的,通过这么一个比较小的需求,能够快速帮助大家对vite的生命周期,以及自定义插件油猴插件的基本使用,有个较为清晰的了解。综合性比较强,需求完成后对大家的开发效率也会有很大的提升,大家感兴趣的可以进我的github上看对应的插件源码和脚本代码:溯源代码

扩展点:

  1. 如何在webpack上,通过编写对应插件,实现相应的功能
  2. 目前只能够在页面上知道对应模块使用的组件,不知道这个组件能够对应哪个页面
  3. 可以修改一些样式,让整体更加美观
  4. 一步到位,点击对应模块能够自动跳转的编辑器中

猿辅导旗下AI Agent“飞象老师”正式上线

2025年12月1日 17:48
36氪获悉,12月1日,猿辅导旗下AI Agent“飞象老师”正式上线,系国内首个教师专属AI工具。依托十余年教育大模型与百亿级知识数据,教师输入一句话即可生成专业级的交互动画与游戏化课件,深度适配数学、语文、英语、科学等多学科。产品目前限时向全国教师免费开放注册。

新版中俄双边投资协定于12月1日正式生效

2025年12月1日 17:47
12月1日,新版《中华人民共和国政府和俄罗斯联邦政府关于促进和相互保护投资的协定》正式生效实施。中俄两国政府于2006年签署双边投资协定,为给中俄两国投资者及其投资营造更加稳定、公平、透明、可预期的营商环境,双方于2022年启动双边投资协定升级谈判,并于2025年5月8日签署新版双边投资协定。(央视新闻)
❌
❌