普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月17日掘金 前端

下一代组件的奥义在此!headless 组件构建思想探索!

作者 孟祥_成都
2025年11月17日 17:14

前言

这里并不是想引起所谓 ant-designelement plus 这种样式,dom结构,javascript在一起的传统组件库更好,还是国外 github start 都要接近 100k 的组件库 shadcn/ui (谷歌的 mui 已经在做 headless 版本了,你可以简单理解 headless 就是无样式组件的意思)更好!

而是提供一个更宽广的视角,都说 headless 组件库拓展性很好,可维护性很好,到底为什么这么说?(当然缺点也很明显,技术一般的人 hold 不住在拓展 headless 组件中复杂功能的实现)。

还有,国内没有特别有含金量的实战文章,比如 从 0 到 1 实现一个比较典型能说明 headless 组件优点的文章,又因为我自己在做 headless 组件库( github地址官网github宣传地址), 也看了一些这方面的文章,受益颇深, 希望能跟大家交流 headless 的思想。

对你的帮助

这篇文章读完,能确保你了解到:

  • 具备自己的 headless 组件的思路,无论是写组件库,还是实现业务组件的抽象,提供思路
  • 想了解 shadcn/ui, reach ui, radix-ui 等等这些可组合的 headless 组件内部是如何构建的

Let's get started!

什么是好的组件

在你工作中,假设你的负责人让你开发一个 手风琴 组件,如下,点击 Accordion 1 或者 2, 3的标题,其折叠在内的文字会展开。你可能会这样做。’

accordin.jpg

首先用法如下,传入 accordionData

const accordionData = [
    { id: 1, headingText: 'Heading 1', panel: 'Panel1 Content' },
    { id: 2, headingText: 'Heading 2', panel: 'Panel2 Content' },
    { id: 3, headingText: 'Heading 3', panel: 'Panel3 Content' },
]

const SomeComponent = () => {
    const [activeIndex, setActiveIndex] = useState(0)

    return (
        <div>
            <Accordion
                data={accordionData}
                activeIndex={activeIndex}
                onChange={setActiveIndex}
            />
        </div>
    )
}

然后,组件内部通过你传入的 data,使用 map 函数渲染,接着给组件的 button(用来装标题的),绑定一个 onClick 事件,当点击的时候,就看看就会 onChange 当前点击的 activeIndex 是否就是自己的 index,从而在展开内容的 <div hidden={activeIndex !== idx}>{item.panel}</div> 部分判断,是否展开自己的文字部分。


function Accordion({ data, activeIndex, onChange }) {
    return (
        <div>
            {data.map((item, idx) => (
                <div key={item.id}>
                    <button onClick={() => onChange(idx)}>
                        {item.headingText}
                    </button>
                    <div hidden={activeIndex !== idx}>{item.panel}</div>
                </div>
            ))}
        </div>
    )
}

这种封装在我们日常业务中司空见惯,但假设需求发生了变化,现在您需要在手风琴按钮和标题中添加对图标的支持,并能添加一些样式。你一般就会这样做:

- function Accordion({ data, activeIndex, onChange }) {
+ function Accordion({ data, activeIndex, onChange, displaySomething }) {
  return (
    <div>
      {data.map((item, idx) => (
        <div key={item.id}>
          <button onClick={() => onChange(idx)}>
            {item.headingText}
+            {item.icon? (
+              <span className='someClassName'>{item.icon}</span>
+            ) : null}
          </button>
-          <div hidden={activeIndex !== idx}>{item.panel}</div>  
+          <div hidden={activeIndex !== idx}>
+            {item.panel}
+            {displaySomething}
+          </div>
        </div>
      ))}
    </div>
  )
}

对于每一个不断变化的需求,你都需要重构你的组件,以满足业务的需求。这似乎很正常,需求变了难到组件不变吗?你仔细想想,似乎也不太对,就跟组件库一样,难道需求变了,组件库内部也需要不断变吗?这些组件库都是第三方的,很难要求他们根据你的需求去变化?

所以怎么可能有万能的组件库,dom 结构跟得上千变万化的业务呢?这也说明了传统 ant-design, element-plus 组件库的局限。

所以我们想想有什么办法能解决?

现在目光转移到一个简单的问题上来,想想 HTML 中的 <select><option> 元素,分开来说,这两个元素能做的很有限,可当他们组合起来的时候,通过共享一些状态,可以组合成下拉框组件,这就复合组件的概念,复杂组件组装而来,而不是一味的隐藏黑盒和过度封装。

复合组件

根据上面复合组件的思想,我们改装之前的 手风琴 组件:

<Accordion>
    <AccordionItem>
        <AccordionButton>Heading 1</AccordionButton>
        <AccordionPanel>
            Panel 1
        </AccordionPanel>
    </AccordionItem>
    <AccordionItem>
        <AccordionButton>Heading 2</AccordionButton>
        <AccordionPanel>
           Panel 2
        </AccordionPanel>
    </AccordionItem>
    <AccordionItem>
        <AccordionButton>Heading 3</AccordionButton>
        <AccordionPanel>
           Panel 3
        </AccordionPanel>
    </AccordionItem>
</Accordion>

这里,我们把整个手风琴组件拆分为 <Accordion><AccordionItem><AccordionButton>, <AccordionPanel> 四部分。

这里有同学会说了,你这例如 <AccordionButton> 都把 html 标签设置死了,拓展性也不强呀。(其实这个感觉还好,因为 css 自定义的情况下,完全可以自己改展示元素的样式,就已经脱离了标签本身样式的限制了),但为了做的更好,我们设置用户可以自定义标签类型。

例如 <AccordionButton> 元素我们这样封装:

const AccordionButton = forwardRef(function (
  { children, as: Comp = "button", ...props }: AccordionButtonProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-button="">
      {children}
    </Comp>
  );
});

可以看到,上面有 as 属性,可以自定义标签类型。

其它组件也是类似:

const Accordion = forwardRef(function (
  { children, as: Comp = "div", ...props }: AccordionProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion="">
      {children}
    </Comp>
  );
});

const AccordionItem = forwardRef(function (
  { children, as: Comp = "div", ...props }: AccordionItemProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-item="">
      {children}
    </Comp>
  );
});

const AccordionPanel = forwardRef(function (
  { children, as: Comp = "div", ...props }: AccordionPanelProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-panel="">
      {children}
    </Comp>
  );
});

需要说明如下:

  • Accordion 组件是由四部分组成
  • 每个部分都包装在 forwardRef 中,所有外界都可以获取到对应的 dom 元素的实例。
  • as 属性可以自定义渲染元素的是什么
  • data-* 属性用于在测试这个组件的时候,咱们设置一个独一无二的属性选择器,好让测试框架选中

组合起来,我们可以这样使用手风琴组件:

  <Accordion>
    <Accordion.Item>
      <Accordion.Button>Button 1</Accordion.Button>
      <Accordion.Panel>Panel 1</Accordion.Panel>
    </Accordion.Item>
    <Accordion.Item>
      <Accordion.Button>Button 2</Accordion.Button>
      <Accordion.Panel>Panel 2</Accordion.Panel>
    </Accordion.Item>
    <Accordion.Item>
      <Accordion.Button>Button 3</Accordion.Button>
      <Accordion.Panel>Panel 3</Accordion.Panel>
    </Accordion.Item>
  </Accordion>

到此,大家仔细看看,之前我们有个增加 Icon 的功能,其实我们只需要在<Accordion.Button> 中增加 Icon 组件就可以了。

  <Accordion.Button><自定义 Icon 组件 /> Button 1</Accordion.Button>

是不是很简单就拓展了 dom 原来的 Accordion 组件也完全不用改,这属于用户自定义行为。

当然这里有个坑我们后面解决,就是,每个 <Accordion.Item> 可能都需要一个 indexdisabled 参数,index 是告诉我这个面板的索引是多少,disabled是告诉这个面板是否是禁用状态。

而这两个参数,可能我们自定义的 Icon 组件需要,比如在 disabeld 状态下,样式会变化。所以这个悬念后面我们解决。

然后因为手风琴组件,大家要共享选中和关闭状态,例如有哪些面板被选中,这里我们使用 Context API 来实现。

const AccordionContext = createContext({});
const AccordionItemContext = createContext({});

const useAccordionContext = () => {
  const context = useContext(AccordionContext);
  if (!context) {
    throw Error("useAccordionContext must be used within Accordion.");
  }
  return context;
};
.......
const Accordion = forwardRef(function (
  .....  
  return (
    <AccordionContext.Provider value={{}}>
     .....
    </AccordionContext.Provider>
  );
});
  • 我们创建了一个上下文共享状态的 context: AccordionContext 以及对应的的 useContext 钩子:`useAccordionContext``

  • AccordionContext 用来共享全局的状态,例如关闭和打开哪个 <Accordion.Item>(面板) 组件的索引。

小总结

这个 AccordionContext 好处是,如果组件库导出了这个 context,那么你可以在这个里面自己添加任何组件,通过调用useAccordionContext, 就能共享手风琴里的所有共享信息,所以此时这个组件库已经超越了传统组件库不能定制样式和不能定制 dom 结构的问题了。

其实还需要一个 conext,例如 <Accordion.Item>(面板)可以单独传入 disabled 参数,表示是否禁用当前面板,所以在这个<Accordion.Item>下,我们如果自定义的组件,也需要共享到这个 disabled 状态,所以单独导出一个 <Accordion.Item> 共享的 context

在下面的案例里也会有这个 useAccordionItemContext,大家大概明白是什么意思就行。这篇文章主要是讲解 headless 组件构建思路。

到这里其实就解决之前的疑问,如何让面板共享状态给我们自定义的 Icon 组件,思想使用 Context API.

其实还有一种方式,就是使用 React.cloneElement 语法,这种方式也有其用武之地,但在这里明显不如 Context API 灵活,为啥呢,因为我们自定义 Icon,我们可以用 Context API 获取到共享状态,而 React.cloneElement 只能给组件中已知的,例如 <Accordion.Button> 传递状态。比如这样

const AccordionItem = forwardRef(function (
  { children, as: Comp = "div", disabled, ...props }: AccordionItemProps,
  forwardedRef
) {
  return (
    <Comp {...props} ref={forwardedRef} data-hb-accordion-item="">
     ```
      {React.Children.map(children, (child) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, { 
            disabled
          });
        }
        return child;
      })}
    </Comp>
  );
});

缺点之前也说过了,这里不赘述,大家可以看做另一种方案。

我们接下来给组件增加一些功能。

继续深入:无状态组件和有状态组件

无状态组件典型的就是 <input> 元素,你输入任何值,它自己单独记录,你不用管。有状态组件就是你要单独自己设置 state 去管理,例如:

<input value={someValue} />

说白了,无状态组件,你不需要单独传参数去控制它最终显示的值,有状态组件就需要。

同时需要记住,一个组件要么是有状态组件,要么是无状态的,只能二选一。如果都有的话,那么就是有状态组件,无状态会被无视。

最后一个无状态组件的关键点是可以传入 defaultValue

<input defaultValue='John Doe' /> 

我们为了丰富之前手风琴组件的效果,支持传入如下参数:

  • index: 可选,类型是 number 或者 number 数组,代表当手风琴面板的索引, 应该跟 onChange 配合使用。

  • onChange: 可选,类型是函数,(index: number) => void,用法是当手风琴里的子元素打开或者关闭时触发此事件。

  • collapsible:可选,类型是 boolean,默认 false. 它决定了是否允许用户关闭所有面板,有些产品要求至少有一个面板是展开的,所以增加了这个参数。此参数仅对非受控组件(即没有 index 和 onChange 属性的组件)有效。在受控组件中,面板的打开与关闭状态完全由父组件通过 index 属性控制。

  • defaultIndex,可选,类型是 number 或者 number 数组,代表打开面板的索引默认值,如果 collapsible 设置为 true,没有设置 defaultIndex,那么所有面板初始化都是关闭的。否则,默认第一个面板打开。

  • multiple,可选,类型是 boolean,默认 false,在非受控组件的情况下,是否允许同时打开多个面板

  • readOnly,可选,类型是 boolean,默认 false,手风琴组件是否是可读状态,也就意味着用户是否可以切换面板状态。

改造组件如下:

const Accordion = forwardRef(function (
  {
    children,
    as: Comp = "div",
    defaultIndex,
    index: controlledIndex,
    onChange,
    multiple = false,
    readOnly = false,
    collapsible = false,
    ...props
  }: AccordionProps,
  forwardedRef
) {
....

const AccordionItem = forwardRef(function (
  {
    children,
    as: Comp = "div",
    disabled = false,
    ...props
  }: AccordionItemProps,
  forwardedRef
) {
....

增加无状态组件功能

涉及无状态组件功能参数包括:defaultIndexmultiplecollapsible, 但是不包括 indexonChange

然后我们也会给 AccordionItem 一个 index 参数,表示每个面板的索引

  <Accordion defaultIndex={[0, 1]} multiple collapsible>
    <Accordion.Item index={0}>   // <= index
      ....
    </Accordion.Item>
    <Accordion.Item index={1}>  // <= index
      ....
    </Accordion.Item>
      .... 
  </Accordion>

然后我们继续丰富组件内容,以下不是完整代码,主要是帮助大家快速理解 headless 组件构建思路。

const Accordion = (...) => {
  const [openPanels, setOpenPanels] = useState(() => {
    // 根据 multiple, collapsible 参数设置初始化展开哪些面板
  });

  const onAccordionItemClick = (index) => {
    setOpenPanels(prevOpenPanels => { // 更新面板展开或者关闭逻辑 })
  }

  const context = {
    openPanels,
    onAccordionItemClick
  };

  return (
    <AccordionContext.Provider value={context}>
     ....
    </AccordionContext.Provider>
  );
};

const AccordionItem = ({ index, ...props }) => {
  const { openPanels } = useAccordionContext();

  const state = openPanels.includes(index) ? 'open' : 'closed'

  const context = {
    index,
    state,
  };

  return (
    <AccordionItemContext.Provider value={context}>
        ....
    </AccordionItemContext.Provider>
  );
};

const AccordionButton = () => {
  const { onAccordionItemClick } = useAccordionContext();
  const { index } = useAccordionItemContext();

  const handleTriggerClick = () => {
    onAccordionItemClick(index);
  };

  return (
    <Comp
      ....
      onClick={handleTriggerClick}
    >
      {children}
    </Comp>
  );
};

const AccordionPanel = (...) => {
  const { state } = useAccordionItemContext();

  return (
    <Comp
      ....
      hidden={state === 'closed' }
    >
      {children}
    </Comp>
  );
});

增加有状态功能

有状态组件是很简单的,因为是用户自己传入参数来控制。我们增加 indexonChange 参数,从而让用户能更新内部状态。

const Accordion = forwardRef(function ({
+  index: controlledIndex,
+  onChange,
....
  const onAccordionItemClick = useCallback(
    (index: number) => {
+     onChange && onChange(index);

      setOpenPanels((prevOpenPanels) => {
       ...
  );

  const context = {
+    openPanels: controlledIndex ? controlledIndex : openPanels,
    .....
  };

如上,,受控状态 controlledIndex 按预期覆盖了 openPanels 中的非受控状态。(openPanels 是前面我们在Accordion组件内定义记录当前打开的是哪些面板的 state)。

关于 onChange,它并不决定我们的组件是受控还是非受控。无论是否传递了受控的 index 属性,都可以传递 onChangeonChange 属性的目的是向父组件通知状态变更。

其实到这里差不多就结束了,其实很多组件库,把无状态和有状态都合并为了一个 hooks 这样可以解决用条件去判断(如我们上面)的繁琐。

这个函数我的 t-ui组件库也有借鉴(求一个 start, 致力于打造最好的组件库教程网站 ,github地址)源码如下:

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { isUndefined } from '../utils';
import { usePrevious } from './use-previous';

export function useMergeValue<T>(
  defaultStateValue: T,
  props?: {
    defaultValue?: T;
    value?: T;
  },
): [T, React.Dispatch<React.SetStateAction<T>>, T] {
  const { defaultValue, value } = props || {};
  const firstRenderRef = useRef(true);
  const prevPropsValue = usePrevious(props?.value);

  const [stateValue, setStateValue] = useState<T>(
    !isUndefined(value) ? value : !isUndefined(defaultValue) ? defaultValue : defaultStateValue,
  );

  // 受控转为非受控的时候,需要做转换处理
  useEffect(() => {
    if (firstRenderRef.current) {
      firstRenderRef.current = false;
      return;
    }
    if (value === undefined && prevPropsValue !== value) {
      setStateValue(value);
    }
  }, [value]);

  const mergedValue = isUndefined(value) ? stateValue : value;

  return [mergedValue, setStateValue, stateValue];
}

这里简单解释以下,其实就是你传了 value 我就认为你是受控组件,然后 value 就透传出去,我这个 hooks 不管,如果有 defaultValue 或者组件库想默认给个默认值, 我会用其初始化 stateValue 然后传出去。并且 setStateValue 方法能改变其值。

setStateValue 其实在传入 value 的情况下,也没什么用,因为改变不了 value 的值。

搞不懂作用域链?这篇文章让你一眼秒懂!

作者 soda_yo
2025年11月17日 16:39

前言

新手哈基米搞不懂什么是作用域链?深入了解JS代码的执行过程让你秒懂作用域链!

经典案例

function fn() {
    console.log(myName);
}
function fn2() {
    var myName = '南北绿豆'
    fn()
}
var myName = '哈基米'
fn2()
  • 究竟是会出现"哈基米",还是"南北绿豆"呢?诶,先不要着急哈气。想要真正弄清输出结果是"哈基米"还是"南北绿豆"。我们先要深入理解一下这段js代码究竟是怎样的一个执行过程。

作用域与它的动态载体

作用域

  • 在程序中定义变量的区域,该位置决定了变量的生命周期。通俗来讲,作用域就是变量和函数的可访问范围

函数执行上下文 : 函数作用域的动态载体

  1. 什么是函数执行上下文?
    • 当一个函数被调用时,js引擎会先创建一个对应的“函数执行上下文”对象,并将函数中声明的变量与函数都存在其中。供函数执行时使用,由此才实现了函数作用域的概念。
  2. 其主要组成部分:
    • 变量环境:保存使用var关键字声明的对象和函数内部声明的子函数,由此实现常见的“函数作用域”。
    • 词法环境:保存使用let关键字声明的对象,主要用于实现“块级作用域”的功能,非本文重点,不做详细描述。
  3. 例子:
function fn2() {
    var myName = '南北绿豆'
    fn()
}

图片的抽象表达:js引擎会为本函数创建一个函数上下文

75EE8152-FE72-438e-A284-A224F6A6E135.png
  1. 新手可能都会有个疑问?
    • 为什么fn函数没有存在这个函数执行上下文当中呢?
      • 欸,这就告诉你fn函数的声明被存在哪里。

全局执行上下文:全局作用域的动态载体

function fn() {
    console.log(myName);
}
function fn2() {
    var myName = '南北绿豆'
    fn()
}
var myName = '哈基米'
fn2()
  1. 全局执行上下文与函数执行上下文类似
  • 由这份代码可知,在该js文件的全局下,声明了两个函数:fn与fn2,声明了一个变量: myName。

  • 所以js引擎同样会为其创建一个“全局执行上下文供全局参考,由此形成全局作用域。

461C8CE8-56E8-431a-80AB-0A5FD7C8D366.png

待解决的疑问

  • 光靠这些我们还无法得知fn2是如何调用到fn函数的,所以我们还得了解这两个执行上下文的出现顺序,以及js引擎是如何对他们进行处理,才能够正常的执行代码。所以我们的新朋友“调用栈”便可以登场了。

js引擎的好兄弟:调用栈

  • 帮助js引擎控制代码执行顺序的“无形态大手”

什么是调用栈?

  • 后进先出:它与我们所知的“栈”这一数据结构一样,有着“后进先出”(LIFO)的特点。
  • “函数执行上下文生命周期”的管理者:js引擎通过调用栈间接控制“函数执行上下文的生命周期”来管理“函数的执行顺序”,由此直接决定代码的执行流程。
    • 具体过程是: 每当一个函数被调用时,js引擎会为其创建一个“函数执行上下文”,然后将该“函数执行上下文”压入栈顶,js引擎就会开始执行处于栈顶的函数,当函数执行完毕,便会让栈顶元素出栈。

函数执行上下文被压入栈的过程

  • 全局被编译时,js引擎创建全局执行上下文并将其压入调用栈,然后开始执行全局沿着全局一行一行往下执行。
845D93CB-BD81-438d-A3B5-9D490ADB5C7A.png
  • 当准备执行fn2()时,js引擎会在全局执行上下文中寻找fn2()的定义,发现其存在。js引擎对其进行编译并创建对应函数执行上下文并将其压入调用栈,然后开始执行函数fn2,一行一行往下执行。
C06E8B4E-D793-49e6-9A11-21AEFA2F357C.png
  • 当准备执行fn()时,js引擎会在fn2函数执行上下文中寻找fn()的定义,未发现定义,于是继续向全局执行上下文中寻找fn()的定义。发现fn()定义,js引擎对其进行编译并创建对应函数执行上下文压入调用栈,然后开始执行fn,一行一行往下执行。
9C03BEC2-0DD7-4b97-A119-037D2BC2A8C7.png

消失的“南北绿豆”:交流的失败?

js引擎对变量与函数的寻找过程难道真的只是“由内向外寻找”?如果是这样输出的结果应该是“南北绿豆”才对。

951C6781-E75B-4942-BB5D-BBEE88EA7C0E.png

怎么是哈基米?

作用域链:作用域之间以outer指针为链接,形成的单向链表

outer指针:函数执行上文间的链接者,向外寻找的罪魁祸首

  • outer指针:当一个函数执行上文被创建时,其内部存在一个指针outer指向该函数被定义时所在的词法作用域
  • 词法作用域:一个函数被编译时一定会用一个outer指针记录该执行上下文(作用域)的外层(作用域)是谁。
  • 例子:
var num=0;
function f1()
{
    console.log(num);
}
fn1()
9E981168-8FAF-4a64-AB56-40E2074FAC46.png

由此形成了一个最简单的作用域链

  • 当执行函数内部时,在函数执行上文内部无法找到num的声明,js引擎便会沿着outer指针指向的全局执行上下文,寻找num的声明
  • 全局执行上文中存在num的声明,且num为1
  • 于是程序执行输出为1

作用域链:作用域之间以outer指针为链接,形成的单向链表

  • 由上述逻辑不断推导,最开始的“南北绿豆”消失之迷便迎刃而解了。
function fn() {
    console.log(myName);
}
function fn2() {
    var myName = '南北绿豆'
    fn()
}
var myName = '哈基米'
fn2()
882B21C2-CB2A-4e9b-8527-4C939FA5927E.png

执行fn()函数时,寻找myName的定义的过程:

  • 第一步:在fn函数执行上文中寻找,未查找到。
  • 第二部:沿着outer指针指向查找上一级,在全局执行上下文中寻找到myName=“哈基米”
  • 输出哈基米
  • 由此我们发现:作用域链的形成与函数调用的过程无关。
  • 作用域链的形成:依赖outer指针的指向。

总结

  1. 作用域: 在程序中定义变量的区域,该位置决定了变量的生命周期。通俗来讲,作用域就是变量和函数的可访问范围
  2. 调用栈:LIFO(后进先出)的执行上下文栈,用于管理函数调用顺序
  3. 执行上下文:作用域的动态载体,存储变量的具体环境
  4. outer指针:每一个函数内部都会存在一个指针outer,指向该函数的外层作用域(其所在的词法作用域)
  5. 词法作用域:函数或者变量被定义时的代码嵌套位置
  6. 作用域链:作用域之间以outer指针为链接,形成的单向链表

【hvigor专栏】OpenHarmony应用开发-hvigor插件之动态修改应用hap文件名

作者 Winslei
2025年11月17日 16:30

前言

在OpenHarmony应用开发中,动态修改应用hap文件名是较为常见的功能,比如文件名包含版本号、构建时间、编译模式等等。下文会以一个完整的示例为大家讲述如何实现此功能。

开发环境

DevEco Studio: DevEco Studio 6.0.0 Release(Build Version: 6.0.0.858)

开发流程

前置步骤

  1. 创建新工程。

  2. 编译工程,可以看到build/default/outputs/default/目录下生成了名为entry-default-unsigned.hap的默认hap。

新增artifactName

entry模块的build-profile.json5下新增

{
  ···
  "targets": [
    {
      "name": "default",
      ···
      "output": {
        "artifactName": "samples"
      }
    }
  ]
}

新增hvigor任务

在根目录hvigorfile.ts里修改为以下代码

import { appTasks, OhosAppContext, OhosHapContext, OhosPluginId } from '@ohos/hvigor-ohos-plugin';
import { hvigor } from '@ohos/hvigor';

// 动态修改应用hap文件名
export function dynamicChangeNamePlugin(): HvigorPlugin {
  return {
    pluginId: 'dynamicChangeNamePlugin',
    context() {
      return {
        data: 'modify output name'
      };
    },
    async apply(currentNode: HvigorNode): Promise<void> {
      // 获取app插件的上下文对象
      const appContext = currentNode.getContext(OhosPluginId.OHOS_APP_PLUGIN) as OhosAppContext;
      // 通过上下文对象获取从根目录build-profile.json5文件中读出来的obj对象
      const buildProfileOpt = appContext.getBuildProfileOpt();
      const appJsonOpt = appContext.getAppJsonOpt();
      // 修改obj对象为想要的,此处举例修改app中的signingConfigs
      const products = buildProfileOpt.app.products;
      let date = new Date();
      let formatDate = date.getFullYear().toString() + (date.getMonth() + 1).toString().padStart(2, '0') +
      date.getDate().toString().padStart(2, '0') + '_' + date.getHours().toString().padStart(2, '0') +
      date.getMinutes().toString().padStart(2, '0') + date.getSeconds().toString().padStart(2, '0');
      for (const product of products) {
        if (product.name == 'default') {
          product.output.artifactName = formatDate + '_' + appJsonOpt.app.versionName + '_' +
          product.output.artifactName + '_' + appContext.getBuildMode();
          console.info(`output app name: ${product.output.artifactName}`);
        }
      }
      // 将obj对象设置回上下文对象以使能到构建的过程与结果中
      appContext.setBuildProfileOpt(buildProfileOpt);
      hvigor.nodesEvaluated(async () => {
        currentNode.subNodes((node: HvigorNode) => {
          // 获取hpp插件的上下文对象
          const hapContext = node.getContext(OhosPluginId.OHOS_HAP_PLUGIN) as OhosHapContext;
          // 通过上下文对象获取从根目录build-profile.json5文件中读出来的obj对象
          const hapBuildProfileOpt = hapContext?.getBuildProfileOpt();
          if (hapBuildProfileOpt != undefined) {
            const targets = hapBuildProfileOpt['targets'];
            for (const target of targets) {
              if (target.name == 'default' && target.output?.artifactName != undefined) {
                target.output.artifactName = formatDate + '_' + appJsonOpt.app.versionName + '_' +
                target.output.artifactName + '_' + appContext.getBuildMode();
                console.info(`output hap name: ${target.output?.artifactName}`);
              }
            }
            hapContext.setBuildProfileOpt(hapBuildProfileOpt);
          }
        })
      })
    }
  }
}

export default {
  system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
  plugins: [
    dynamicChangeNamePlugin()
  ]       /* Custom plugin to extend the functionality of Hvigor. */
}

编译验证

  1. 编译工程,可以看到build/default/outputs/default/目录下生成了名为20251117_113634_1.0.0_samples_debug-unsigned.hap的自定义文件名hap,其中包含了构建时间、版本号、自定义产物名、编译模式、是否签名等信息。

注意事项

  1. 如果自定义文件名里包含了时间规则,那么因为时间的变化,每次打出的hap都不会覆盖前一个,而是会创建一个新的,从而导致hap越来越多,本地工程体积越来越大。

前端代码一键打包上传服务器?10分钟配好永久告别手动部署!

作者 扑棱蛾子
2025年11月17日 16:26

搞了一个服务器准备折腾一下,但是每次打包用FTP一个个传文件太麻烦了。 受不了于是准备搞个脚本自动部署下。

于是乎,我今天教你配一套自动化部署脚本,以后改完代码直接一个命令npm run deploy,喝杯咖啡十分钟的功夫,就自动打包部署完了。

第一步:配置免密登录

让本地电脑和服务器之间建立信任关系,不然每次上传都要输密码,烦都烦死。

本地生成密钥(如果你之前没搞过的话):

ssh-keygen -t rsa -b 4096 -C “your-email@example.com”

一路回车就行。这就像给你的电脑办了张身份证。

查看刚生成的密钥

cat ~/.ssh/id_rsa.pub

会出来一长串字符,全选复制它!这是你电脑的“身份证号”。

登录服务器添加信任

ssh username@your-server-ip

输完密码进去后,执行下面这几条命令:

# 创建存放密钥的文件夹
mkdir -p ~/.ssh

# 设置文件夹权限
chmod 700 ~/.ssh

# 把刚才复制的公钥粘贴进去
echo “粘贴你刚才复制的那一长串” >> ~/.ssh/authorized_keys

# 设置密钥文件权限
chmod 600 ~/.ssh/authorized_keys

测试一下有没有配好:退出服务器后重新执行ssh username@your-server-ip,如果不用输密码直接进去了,那就成了!

第二步:部署脚本

在项目根目录新建一个deploy。js文件,直接把下面代码复制进去:

// deploy.js - 完整的部署脚本(直接上传文件版本)
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'

// 配置信息 - 根据你的实际情况修改
const config = {
  server: 'username@your-server-ip', // 替换为你的服务器信息
  deployPath: 'yourpath'
}

// 彩色日志
const colors = {
  reset: '\x1b[0m',
  red: '\x1b[31m',
  green: '\x1b[32m',
  yellow: '\x1b[33m',
  blue: '\x1b[34m'
}

function log(message, color = colors.reset) {
  console.log(color + message + colors.reset)
}

function runCommand(command, description) {
  log(`📝 ${description}`, colors.blue)
  log(`  执行: ${command}`, colors.yellow)

  try {
    const output = execSync(command, { stdio: 'inherit' })
    log(`✅ ${description} 成功`, colors.green)
    return true
  } catch (error) {
    log(`❌ ${description} 失败: ${error.message}`, colors.red)
    return false
  }
}

// 递归获取目录中的所有文件
function getAllFiles(dirPath, arrayOfFiles = []) {
  const files = fs.readdirSync(dirPath)

  files.forEach((file) => {
    const fullPath = path.join(dirPath, file)
    if (fs.statSync(fullPath).isDirectory()) {
      arrayOfFiles = getAllFiles(fullPath, arrayOfFiles)
    } else {
      arrayOfFiles.push(fullPath)
    }
  })

  return arrayOfFiles
}

// 主部署函数
async function deploy() {
  log('🚀 开始直接文件上传部署流程', colors.blue)
  log('='.repeat(50))

  // 1. 检查 dist 目录是否存在,如果不存在则构建
  if (!fs.existsSync('dist')) {
    log('📦 dist 目录不存在,开始构建项目...', colors.yellow)
    if (!runCommand('npm run build', '项目构建')) {
      return
    }
  } else {
    log('📦 发现已存在的 dist 目录,跳过构建', colors.green)
  }

  // 2. 获取 dist 目录中的所有文件
  log('📋 扫描 dist 目录中的文件...', colors.blue)
  const distFiles = getAllFiles('dist')
  log(`📄 找到 ${distFiles.length} 个文件需要上传`, colors.green)

  // 3. 测试服务器连接
  log('🔗 测试服务器连接...', colors.blue);
  if (!runCommand(`ssh -o ConnectTimeout=10 ${config.server} "echo '连接成功'; exit"`, '测试SSH连接')) {
    return;
  }

  // 4. 在服务器上创建备份
  log('💾 在服务器上创建备份...', colors.blue);
  const backupCommands = `set -e; cd ${config.deployPath}; echo "备份当前文件..."; if [ -f "index.html" ] || [ -d "assets" ]; then tar -czf backup_old.tar.gz index.html assets 2>/dev/null || true; echo "✅ 当前文件已备份为 backup_old.tar.gz"; else echo "⚠️ 没有找到当前文件,跳过备份"; fi; echo "清理旧文件..."; rm -rf index.html assets; echo "✅ 服务器准备完成"; exit 0`;
  
  if (!runCommand(`ssh -o ConnectTimeout=30 ${config.server} "${backupCommands}"`, '服务器准备')) {
    return;
  }

  // 5. 逐个上传文件到服务器
  log('📤 开始上传文件到服务器...', colors.blue)
  let uploadedCount = 0

  for (const filePath of distFiles) {
    const relativePath = path.relative('dist', filePath)
    const remotePath = path
      .join(config.deployPath, relativePath)
      .replace(/\\/g, '/')
    const remoteDir = path.dirname(remotePath).replace(/\\/g, '/')

    // 确保远程目录存在
    const mkdirCommand = `ssh ${config.server} "mkdir -p ${remoteDir}"`
    if (!runCommand(mkdirCommand, `创建目录: ${remoteDir}`)) {
      continue
    }

    // 上传文件
    const scpCommand = `scp ${filePath} ${config.server}:${remotePath}`
    if (runCommand(scpCommand, `上传文件: ${relativePath}`)) {
      uploadedCount++
    }
  }

  log(
    `✅ 文件上传完成: ${uploadedCount}/${distFiles.length} 个文件`,
    colors.green
  )

  // 6. 在服务器上设置文件权限
  log('🔒 设置服务器文件权限...', colors.blue);
  const permissionCommands = `set -e; cd ${config.deployPath}; echo "设置文件权限..."; chmod -R 755 .; echo "✅ 权限设置完成"; exit 0`;
  
  runCommand(`ssh -o ConnectTimeout=30 ${config.server} "${permissionCommands}"`, '设置文件权限');

  // 7. 验证部署
  log('🔍 验证部署结果...', colors.blue);
  runCommand(
    `ssh -o ConnectTimeout=10 ${config.server} "cd ${config.deployPath} && ls -la && echo '--- 文件统计 ---' && find . -type f | wc -l; exit"`,
    '检查部署目录'
  );

  log('\n🎉 部署完成!', colors.green)
  log(`🌐 请访问: http://${config.server.split('@')[1]}`, colors.green)
  log('='.repeat(50))
}

// 执行部署
deploy().catch((error) => {
  log(`💥 部署过程出现错误: ${error.message}`, colors.red)
  process.exit(1)
})

这脚本干了啥?

  1. 自动检查有没有打包文件,没有就先打包
  2. 测试服务器连接(别传到一半断了)
  3. 备份服务器上的旧文件(万一新版本有问题能回滚)
  4. 上传所有新文件
  5. 设置好权限让Nginx能访问

第三步:配置打包命令

打开package.json,在scripts里加一行:

"scripts": {
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview",
  "deploy": "vite build && node deploy.js"
},

就这么简单!

开始享受一键部署

以后每次改完代码需要部署的时候,终端里输入:

npm run deploy

然后你就会看到彩色的日志在跑,打包→上传→部署一气呵成。等出现🎉 部署完成!的时候,刷新浏览器就能看到新版本了。没看到记得重启一下nginx。

是不是超简单?

以后再也不用打开一堆工具手动传文件了,改完代码一个命令搞定,省下的时间够你多摸几次鱼了。

Set 和 Map常用场景代码片段

作者 MoMoDad
2025年11月17日 16:23

一、Set 实用代码片段

1. 数组去重(基础版)

场景:接口返回数组、用户输入列表等需要快速去重。

javascript

运行

/**
 * 数组去重(支持基本类型,引用类型需额外处理)
 * @param {Array} arr - 待去重数组
 * @returns {Array} 去重后数组
 */
const uniqueArray = (arr) => [...new Set(arr)];

// 示例
const arr = [1, 2, 2, 3, 'a', 'a'];
console.log(uniqueArray(arr)); // [1, 2, 3, 'a']

2. 检查数组是否有重复元素

场景:表单验证(如 “标签不可重复”)、数据校验。

javascript

运行

/**
 * 检查数组是否存在重复元素
 * @param {Array} arr - 待检查数组
 * @returns {boolean} 是否有重复
 */
const hasDuplicates = (arr) => new Set(arr).size !== arr.length;

// 示例
console.log(hasDuplicates([1, 2, 3])); // false
console.log(hasDuplicates([1, 2, 2])); // true

3. 集合操作(交集 / 并集 / 差集)

场景:权限对比(如 “用户权限与角色权限的交集”)、数据筛选。

javascript

运行

// 交集:两个数组的共同元素
const intersection = (arr1, arr2) => {
  const set2 = new Set(arr2);
  return arr1.filter(item => set2.has(item));
};

// 并集:两个数组的所有元素(去重)
const union = (arr1, arr2) => [...new Set([...arr1, ...arr2])];

// 差集:arr1 有但 arr2 没有的元素
const difference = (arr1, arr2) => {
  const set2 = new Set(arr2);
  return arr1.filter(item => !set2.has(item));
};

// 示例
const a = [1, 2, 3];
const b = [2, 3, 4];
console.log(intersection(a, b)); // [2, 3]
console.log(union(a, b)); // [1, 2, 3, 4]
console.log(difference(a, b)); // [1]

4. 临时存储 “已处理项”(避免重复操作)

场景:批量处理数据时记录已处理 ID,防止重复请求 / 计算。

javascript

运行

// 记录已处理的任务ID
const processedTaskIds = new Set();

/**
 * 处理任务(仅处理未处理过的)
 * @param {number} taskId - 任务ID
 */
const processTask = (taskId) => {
  if (processedTaskIds.has(taskId)) {
    console.log(`任务 ${taskId} 已处理,跳过`);
    return;
  }
  // 模拟处理逻辑
  console.log(`处理任务 ${taskId}`);
  processedTaskIds.add(taskId);
};

// 示例
processTask(1); // 处理任务 1
processTask(1); // 任务 1 已处理,跳过
processTask(2); // 处理任务 2

二、Map 实用代码片段

1. 复杂键名映射(替代对象的局限性)

场景:用对象(如 DOM 元素、实例)作为键存储数据(对象的键会被转为字符串,无法直接用对象当键)。

javascript

运行

// 场景:给DOM元素绑定额外数据(如点击次数、状态)
const elementData = new Map();

// 获取DOM元素
const btn = document.getElementById('submit-btn');
const input = document.getElementById('username-input');

// 存储数据(键为DOM元素,值为任意类型)
elementData.set(btn, { clickCount: 0, disabled: false });
elementData.set(input, { value: '', touched: false });

// 更新数据
btn.addEventListener('click', () => {
  const data = elementData.get(btn);
  elementData.set(btn, { ...data, clickCount: data.clickCount + 1 });
  console.log(`按钮点击次数:${elementData.get(btn).clickCount}`);
});

2. 接口数据缓存(避免重复请求)

场景:同一参数的接口请求,优先返回缓存数据,减少接口调用。

javascript

运行

/**
 * 带缓存的接口请求工具
 * @param {Function} fetchFn - 实际请求函数(返回Promise)
 * @returns {Function} 包装后的请求函数
 */
const withCache = (fetchFn) => {
  const cache = new Map(); // 缓存:键为参数字符串,值为请求结果

  return async (...args) => {
    // 生成唯一缓存键(将参数转为字符串,支持多参数)
    const cacheKey = JSON.stringify(args);

    // 命中缓存:直接返回
    if (cache.has(cacheKey)) {
      console.log('使用缓存数据');
      return cache.get(cacheKey);
    }

    // 未命中:请求并缓存
    console.log('发起新请求');
    const result = await fetchFn(...args);
    cache.set(cacheKey, result);
    return result;
  };
};

// 示例:包装一个获取用户信息的接口
const fetchUser = async (userId) => {
  const res = await fetch(`/api/user/${userId}`);
  return res.json();
};

// 使用缓存版请求
const fetchUserWithCache = withCache(fetchUser);

// 第一次请求(无缓存)
fetchUserWithCache(1); 
// 第二次请求同一用户(用缓存)
fetchUserWithCache(1); 

3. 多维度数据映射(快速查询)

场景:同一份数据需要通过多个 “键” 查询(如用户信息可通过 ID、手机号、用户名查询)。

javascript

运行

// 原始用户数据
const users = [
  { id: 1, name: '张三', phone: '13800138000' },
  { id: 2, name: '李四', phone: '13900139000' }
];

// 构建多维度映射
const userMaps = {
  byId: new Map(),    // 键:id
  byName: new Map(),  // 键:name
  byPhone: new Map()  // 键:phone
};

users.forEach(user => {
  userMaps.byId.set(user.id, user);
  userMaps.byName.set(user.name, user);
  userMaps.byPhone.set(user.phone, user);
});

// 快速查询示例
console.log(userMaps.byId.get(1)); // {id:1, name:'张三', ...}
console.log(userMaps.byPhone.get('13900139000')); // {id:2, ...}

4. 有序键值对遍历(保留插入顺序)

场景:需要按 “插入顺序” 遍历键值对(对象的键遍历顺序不稳定,尤其是数字键)。

javascript

运行

// 场景:按用户操作顺序记录日志(需保留顺序)
const actionLog = new Map();

// 按顺序插入操作
actionLog.set('login', { time: '09:00', user: '张三' });
actionLog.set('view', { time: '09:05', page: '首页' });
actionLog.set('logout', { time: '10:00', user: '张三' });

// 按插入顺序遍历(Map 会保留插入顺序)
for (const [action, detail] of actionLog) {
  console.log(`${action}${JSON.stringify(detail)}`);
}
// 输出顺序:login → view → logout(与插入顺序一致)

三、使用小贴士

  1. 选择 Set 还是 Map

    • 只需要 “唯一元素集合” → 用 Set;
    • 需要 “键值对映射”(尤其是复杂键) → 用 Map。
  2. 性能考量

    • Set/Map 的 has/get/set 操作时间复杂度为 O (1),比数组的 indexOf、includes等(O (n))更高效,数据量大时优先使用。
  3. 转换技巧

    • Set 转数组:[...mySet] 或 Array.from(mySet)
    • Map 转对象(键为字符串时):Object.fromEntries(myMap)
  4. 转换技巧

    • Set/Map 的 has/get/set 操作都是 O (1),比数组 indexOf(O (n))、对象循环查询快,数据量大(>100)时优先用
    • 临时缓存(比如接口缓存)如果不需要持久化,用 Map 即可;需要持久化到 localStorage,要先转成数组 / 对象(因为 localStorage 只能存字符串)。
  5. 避坑提醒

    • Set 存引用类型(对象、数组)时,不会自动去重(因为引用地址不同),比如 new Set([{a:1}, {a:1}]) 会存两个对象。
    • Map 的键是 “引用相等”,比如 map.set({}, 1) 和 map.set({}, 2) 是两个不同的键(对象引用不同)。

我为什么说全栈正在杀死前端?

作者 ErpanOmer
2025年11月17日 16:15

大家好,我又来了🤣。

打开2025年的招聘软件,十个资深前端岗位,有八个在JD(职位描述)里写着:“有Node.js/Serverless/全栈经验者优先”。

50fb0729f6733fc5092ecfc91f063c6.jpg

全栈 👉 成了我们前端工程师内卷的一种方式。仿佛你一个干前端的,要是不懂点BFF、不会配Nginx、不聊聊K8s,你都不好意思跟人说你是资深。

我们都在拼命地,去学Nest.js、学数据库、学运维。我们看起来,变得越来越全能了。

但今天,我想泼一盆冷水🤔:

全栈正在杀死前端。


全栈到底是什么

我们先要搞清楚,现在公司老板们想要的全栈,到底是什么?

image.png

他们想要的,不是一个T型人才(在一个领域是专家,同时懂其他领域)。

他们想要的是:一个能干两个人(前端+后端)的活,但只需要付1.5个人的工资。

但一个人的精力,毕竟是有限的。

  • 当我花了3个月,去死磕K8s的部署和Nest.js的依赖注入时,我必然没有时间,去研究新出炉的INP性能指标该如何优化。
  • 当我花了半周时间,去设计数据库表结构和BFF接口时,我必然没有精力,去打磨那个React组件的可访问性,无障碍(a11y)和动画细节。

我们引以为傲的前端精神,正在被全栈的广度要求,稀释得一干二净。

全栈的趋势,正在逼迫我们,从一个能拿90分的前端专家,变成一个前后端都是及格的功能实现者。


关于前端体验

做全栈的后果,最终由谁来买单?

是用户。

我们来看看全栈前端主导下,最容易出现的受灾现场:

1.能用就行的交互

全栈思维,是功能驱动的。

数据能从数据库里查出来,通过API发到前端,再用v-for渲染出来,好了,这个功能完成了😁。

至于:

  • 列表的虚拟滚动做了吗?
  • 图片的懒加载做了吗?
  • 按钮的loadingdisabled状态,在API请求时加了吗?
  • 页面切换的骨架屏做了吗?
  • 弱网环境下的超时和重试逻辑写了吗?
  • UI测试呢?

抱歉,没时间。我还要去写BFF层的单元测试。

2.无障碍,可访问性(a11y)

你猜一个全栈,在用 <div> 还是 <button> 来实现一个按钮时,会思考 aria-* 属性吗?他会关心Tab键的焦点顺序吗?

根本不会。

因为可访问性这个东西,是纯粹的纯前端范围,它不属于全栈能力范围。

3. 性能优化

当一个全栈工程师的注意力,被数据库索引、Nginx缓存、Docker镜像大小给占满时,他还有多少脑容量,去关心LCP、CLS、Tree Shaking、Code Splitting?

useMemoPureComponent?能跑就行了,别搞那么复杂。

前端,正在从用户体验的第一负责人,被降维成了全栈流程的最后一个环节——那个把数据显示出来UI就行。


一个前端的专业性

最让我发慌的,是一种风气的转变。

五年前,我们团队,会为一个如何把白屏时间再减少100ms的议题,在白板前吵一个下午。我们会为该用padding还是margin来实现间距 这种像素级的细节,在CR(Code Review)里吵架。

现在呢?

CR时,大家都在聊:你这个BFF的Controller层,不该写业务逻辑、你这个数据库类型定义不规范。

没人再关心那个前端按钮逻辑了。

全栈,正在杀死前端的专业性。它让前端这个职业,变得不再纯粹,不再专注一个领域。


我不想做全栈开发😠

聊了这么多,我不是在贩卖焦虑,也不是在抵制学习后端知识。

作为8年老前端,我现在给自己的定位是:一个T型前端工程师。

我必须是团队里,对浏览器渲染原理、JS性能优化、CSS布局、组件化架构、可访问性理解最深的那个人。这是我的前端身份,是我的技能。

我懂Node.js,是为了能和后端吵架时,提出更合理的BFF接口设计。

我懂Docker,是为了能理解我的代码,是如何在CI/CD上闪退的。

我懂SQL,是为了能理解为什么我的一个查询,会导致查询慢。


请大家别再神话全栈了😒。

Suggestion.gif

全栈的尽头,很可能是全废了,这个也不精,那个也不精。

我宁愿要做一个95分的前端专家,和一个95分的后端专家,让他们强强联手;

也不想要两个及格的全栈工程师,最终交付一个50分的、能跑就行的垃圾代码💩。

欢呼大家,尊重前端这个职业的专业性。

谢谢🙌

React常见hooks及运用场景梳理(一)——useState、useEffect

作者 Jseeza
2025年11月17日 15:52

本文用于梳理React开发中使用较多的hooks。

仅作为入门快速了解hooks从而开发所用,不涉及很多原理性的东西。

这次先讲useStateuseEffect

一、useState

useState:用于管理组件内部状态,是React开发中最常用的hook,可以让函数组件拥有内部可更新的状态(对比纯js开发,就是用class的this.state)。

1.1 什么是状态

虽然看起来很像无关的话题,但是在理解useState的时候,很有必要去理解一下state(状态),从而帮助我们更好地理解useState的实现机制和React的渲染逻辑。

在React中,state是一个非常核心的概念,可以理解为,state驱动页面变化:只要state改变,React就会自动重新渲染界面。

看起来似乎一般场景也能做到,但让我们设想一下:假设有一个上下翻页的组件,你点击了下一页,state发生了变化,在React中,每当state发生了变化,React就会重新调用组件函数,组件函数中的代码会从头执行,因此普通变量会重新初始化。

很显然,这是不符合实际运用的,因为一般的变量在组件重新渲染了之后,记录翻页的一般变量也被恢复到初始状态了。

以下是上述示例的代码,在我们狂点下一页的时候,页面始终只能看到初始化的index=0:

export default function Demo() {
  let index = 0;

  const handleClick = (index) => {
    index = index + 1;
  };

  return (
    <>
      <button onClick={handleClick}>下一页</abutton>
      <p>index={index}</p>
    </>
  );
}

所以,我们需要引入useState,记住需要被记住的state,防止被初始化。useState返回的值并不是局部变量,而是React在内部保存的一个状态单元,被记住的state在渲染之间不会丢失。 也就是这样:

import { useState } from "react";

export default function Demo() {
  const [index, setIndex] = useState(0);

  const handleClick = () => {
    setIndex(index + 1);
  };

  return (
    <>
      <button onClick={handleClick}>下一页</button>
      <p>index={index}</p>
    </>
  );
}

现在在我们狂戳的时候,就会发现index也随之变化了,显然,useState帮助组件记住了我们想要的状态。

强调:state不是普通的局部变量,普通变量存储在函数的执行上下文里,而state存储在React的内部数据结构中(Fiber),它独立于组件函数的执行,下面会再次提到这个,可以这么理解:

  • 普通变量=每次渲染都会重新生成。
  • state=React管理,在渲染之间保持不变。

1.2 useState的实现机制是什么呢

上一节我们在讲述state时,已经初步引入了useState。让我们参考一下官方的说法:useState是一个React Hook,它允许你向组件添加一个状态变量。

useState本质上做了三件事情:

  1. 分配一个“状态单元”来存储数据
  2. 按调用顺序记录这个state单元的位置
  3. 在调用setState时触发组件重新渲染

1.2.1 React如何保存state——“状态单元”

函数组件本身只是一个普通函数,函数执行完了,一般来说,内部的局部变量就会销毁,这是由函数执行的生命周期决定的。 但是下述函数的count不会丢:

const demo = () => {
  const [count, setCount] = useState(0);
  return <div>{count}</div>;
}

为什么呢?

因为:React把state存在组件对应的Fiber节点中,而不是函数内部。 我们可以抽象理解一下,一个组件可能有多个state,React对这些state私下维护一个作用域高于组件的数组(Fiber),里面存放这些state。

const [a, setA] = useState(1);  // state[0]
const [b, setB] = useState(2);  // state[1]
const [c, setC] = useState(3);  // state[2]

React内部对上述会存在一个这样的数组:

fiber.state = [
  { memoizedState: 1 }, // a
  { memoizedState: 2 }, // b
  { memoizedState: 3 }, // c
]

这个数组的作用域范围高于函数组件,所以state在渲染时不会消失。

1.2.2 React如何知道每个state对应哪个useState——“按顺序记录与调用”

React规定:

  • 首次渲染时,按照遇到的useState顺序,依次将对应的state存放在state数组中
  • 接下来渲染时,根据遇到的useState顺序,依次将对应的state从state数组中取出。

这里有严格的调用顺序,React根据“调用顺序”匹配state,state的顺序决定一切

因此,这里也会引出一个“老生常谈”的问题:为什么useState不能写在if里面——因为顺序会乱。

if (someCondition) {
  const [a, setA] = useState(1);  // 有时候执行,有时候不执行
}
const [b, setB] = useState(2);

这种写法就是错误的:因为someCondition影响a的执行,从而导致b的顺序发生改变。

someCondition为true: state[0] = a, state[1] = b someCondition为false: state[0] = b

someCondition的执行与否导致初始化与后续渲染时a可能存在可能不存在,只有初始化时是存state,后续是取state,b的位置会发生错位,React无法精准匹配到b对应的state,从而报错。

1.2.3 setState如何触发更新

setState触发更新本质在做两件事情:

  1. 向state对应的队列里push一次更新(Update)
  2. 标记当前的Fiber需要重新渲染,并触发更新

翻译成人话就是:把state的新值记录下来,并通知React重新渲染组件。这样当下一次渲染发生时:

  1. React按顺序再次执行useState
  2. 发现对应的state有更新
  3. 更新并返回新的state

1.2.4 补充:一些遇到的疑难杂症

1. 为什么React的state更新是“异步”的

不是因为setState真的是异步操作,而是因为React会把多个state更新合并批处理:在一次事件循环中,React会收集所有的setState,最后统一重新渲染组件,从而提高性能。

在React18后,React在更多场景下(Promise、定时器等)也会进行批处理,表现得更加异步。

2. 为什么我更新了状态,但是屏幕没有更新

这是由React的内部机制(Object.is比较)决定的,React会比较新旧状态是否相同,如果下一个状态等于先前的状态,则React会忽略这次更新,这是一种默认的“浅比较”,从而避免频繁的更新,实现防抖的效果,如果想要解决这个问题,需要始终保证在状态中替换对象和数组,而不是对它们进行更改

const [index, setIndex] = useState(0);

// 错误写法
const handleClick = () => {
  setIndex(index+1); // 1
  setIndex(index+1); // 1
  setIndex(index+1); // 1
}

// 正确写法
const handleClick = () => {
  setIndex(index => index + 1); // 1
  setIndex(index => index + 1); // 2
  setIndex(index => index + 1); // 3
}
3. 为什么setState后,工作日志打印出来的还是旧值

调用set函数不能改变运行中的代码的状态。因为状态表现就像一个快照,更新状态会使用新的状态值去请求另一个渲染,但是并不影响在已经运行的事件处理函数中的变量。

const handleClick = () => {
  console.log(count);  // 0
  
  setCount(count + 1);
  console.log(count);  // 0
  
  setTimeout(() => {
    console.log(count);  // 0
  }, 5000);
}

如果需要下一个状态,可以在将其传递给set函数之前保存在一个变量中:

const handleClick = () => {
  const nextCount = count + 1;
  setCount(nextCount);
  
  console.log(count); // 0
  console.log(nextCount); // 1
}

二、useEffect

useEffect:用于实现组件与外部系统同步。

2.1 为什么要用useEffect

有些组件需要与外部系统同步。例如,你可能希望根据React state控制非React组件、建立服务器连接或当组件在页面显示时发送分析日志。Effect允许你在渲染结束后执行一些代码,以便将组件与React外部的某个系统相同步。

以上话语摘自官方文档,我觉得有些拗口,但是其实翻译成大白话就是:组件已经渲染好了,但是在渲染好了后我还希望执行一些逻辑,这些逻辑不能写在渲染流程中,这个时候就可以使用useEffect来实现这些逻辑。

这部分逻辑我们称作副作用(Side Effect,和渲染UI无关但必须做的事情),常见的副作用有:

  • 发请求
  • 订阅、监听事件
  • 添加定时器
  • 操作DOM
  • 打日志
  • 手动更新某些外部变量

这些不能直接写在函数组件里面,因为组件会反复执行,但这些副作用不需要反复执行,比如不需要反复请求接口发送请求,反复执行可能会带来一些问题。

2.2 该如何使用useEffect

useEffect本质上只有一种格式:

useEffect(() => {
  // 副作用
  return () => {
    // 清理副作用
  };
}, [deps]);

在上述代码中,deps数组存放执行useEffect的依赖,useEffect根据依赖数组判断是否需要执行副作用函数。

2.2.1 什么是依赖

新手常见问题:什么是依赖?

一句话总结:用于告诉React哪些值必须变化时,这个effect需要执行,这就是依赖(dependency)。

没有依赖,会导致useEffect认为每次渲染都需要执行副作用函数,轻则带来糟糕的性能,重则影响页面逻辑。

怎么界定依赖,很简单,看effect内部“访问”到的state或者props,谁被访问,谁就是依赖。

useEffect(() => {
  console.log(user.name);
  console.log(age);
}, [user.name, age]);

effect访问了user.nameage,所以依赖数组就是这两个。

开发中要时刻注意依赖有没有写对,因为依赖的存在会影响useEffect的执行逻辑。

useEffect(() => {
  console.log(user.name);
  console.log(age);
}, [user.name]);

比如这样,依赖中没有写age,那么age的变化不会调用useEffect执行副作用函数,useEffect中的age也永远只是初始化的值。

如何保证依赖一定写对:记住依赖就是useEffect里面用的来自外界的东西,只要在effect中用到了需要从useEffect这个钩子之外的变量、state、props、函数等,就要把这些东西放在依赖数组中。

2.2.2 useEffect怎么用

根据依赖数组,可以把useEffect分成三类使用。

1. 没有依赖数组
useEffect(() => {
  console.log('每次渲染都执行');
});

没有依赖数组,那么useEffect在首次渲染和每次更新后都会执行。不过这种比较少,因为大部分逻辑不需要这么频繁的运行。

2. 依赖数组为空
useEffect(() => {
  console.log('只在初始化的时候调用一下');
}, []);

依赖数组为空,那么只有在初始化的时候会执行一次useEffect,后续变化都不再执行,因为对比依赖数组发现变量不需要被依赖,当然因为依赖为空,所以useEffect中永远保持初始值的样子。

3. 有依赖数组
useEffect(() => {
  console.log('count变了就调用一下');
}, [count]);

这种才是最常用的,根据count来调用useEffect,依赖变了则需要执行副作用函数。这种时候,就是依赖数组有啥,useEffect就根据依赖数组的内容是否变化判断要不要执行effect。

4. 给一个粗糙的demo
import { useState, useEffect } from "react";

function Demo({ count, age }) {
  // 无依赖数组
  useEffect(() => {
    console.log("每次都执行");
  });

  // 依赖数组为空
  useEffect(() => {
    console.log("初始化时候执行一下");
  }, []);

  // 依赖数组不为空
  useEffect(() => {
    console.log("根据count执行一下");
  }, [count]);

  return (
    <div>
      被调用了{count}次,今年{age}岁
    </div>
  );
}

export default function App() {
  const [count, setCount] = useState(0);
  const [age, setAge] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  const handleAge = () => {
    setAge(age + 1);
  };

  return (
    <>
      <button onClick={handleClick}>count变一下</button>
      <button onClick={handleAge}>age变一下</button>
      <Demo count={count} age={age}></Demo>
    </>
  );
}

2.3 useEffect不是每次都需要的

这里参考了官方文档。

如果你没有打算与某个外部系统同步,那么你可能不需要Effect。

人言翻译:useEffect不是用来做所有逻辑的,只是用来做“渲染之外”的事情。这里的外界系统,指无法通过只依赖React内部(state、props)和渲染自动完成。

useEffect是用来处理副作用的,但是很多人把本来是“渲染逻辑”的东西也写进了副作用中,导致了不必要的useEffect

我们需要明白,useEffect≠业务逻辑&计算逻辑

useEffect(() => {
  setTotal(a + b);
}, [a, b]);

看起来这个的意思是,根据a、b是否变化判断要不要重新算total,但是这种写法是没必要的,state不需要你重新计算,当a、b变化时,UI自己就会更新。

const total = a + b;

写成这样就可以了,没有副作用就不要引入useEffect

React渲染组件=执行函数,但是副作用不能写在渲染时执行的代码中,所以使用useEffect来延迟处理他们。

我遇到过的一个比较典型的例子是:一个页面需要从ctx中拿到某个机构的一些参数,这个ctx中的参数也是通过接口拿到的,并用这些参数来发送请求,由于我没有使用useEffect来保证在拿到参数后再发送请求,以及没有给页面留下差错处理,于是页面崩溃了。这里的正确做法是,在useEffect中发送请求,监听参数是否拿到了,拿到了再发请求。

2.4 useEffect闭包陷阱

useEffect闭包陷阱是React中常见的一个问题,特别在处理异步问题、事件监听或者计时器时。这个问题源于JavaScript闭包特性,当在useEffect内部使用外部的变了时,可能会捕获旧值,从而导致代码中的副作用没有按照预期的行为执行。

典型的闭包陷阱一般发生在依赖组件的state,且state是异步更新的。

假设我们有这样一个计时器,每秒更新一次:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1);  // 闭包陷阱
    }, 1000);

    return () => clearInterval(interval);
  }, []);  // 依赖为空,effect只会在挂载时执行一次

  return <div>Count: {count}</div>;
}

在这段代码中,setCount(count + 1)要用到count,但countuseState管理,并且是异步更新的。useEffect会在首次渲染后执行,并且每次渲染都会“捕获”count当前的值,但它并不会随着count的变化自动更新。所以setCount(count + 1)总是使用组件渲染时捕获的“旧值”。因此,count值没有递增,而是一直停留在初值。

为什么会这样呢?因为在useEffect中使用的count被闭包捕获,而useEffect在组件首次渲染时就被调用了,并且闭包里捕获的count永远不会更新,因此useState中的回调总是访问初次渲染的count,而不是组件更新后的最新值。无论count如何变化,都只会更新旧值。

解决方法也是有的:React提供了函数式更新作为setState的一种方式,这样,React可以确保在setState时,始终拿到最新的state。

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((prevCount) => prevCount + 1);  // 使用函数式更新
    }, 1000);

    return () => clearInterval(interval);
  }, []);  // 依赖为空,effect只会在挂载时执行一次

  return <div>Count: {count}</div>;
}

当然,我们也可以加上显式依赖。

闭包陷阱的解决的关键在于:确保useEffect中的state始终是最新的,尤其是异步操作也要是新的。使用函数式更新和正确的依赖项,可以有效地解决闭包问题。

暂时先讲到这里,希望各位大佬有问题务必狠狠指正!

scala中trait基本使用

作者 零碎岛11
2025年11月17日 15:30

(一)trait定义和作用

[讲] Scala没有Java中接口的概念,所以Scala的trait就类比Java中的接口。Scala的特质定义如下:

trait identified {  
    属性; 方法

}

屏幕截图 2025-11-17 152748.png

屏幕截图 2025-11-17 152753.png

trait是关键字,identified 表示一个合法的标记。

(二)实现单个特质

[码] 用一个类去实现单个特质

package level02

/*
 * 特质
 * trait: 实现多继承
 **/
object class16 {

  trait BeautifulEye {
    val eye: String = "眼睛漂亮"
  }

  trait Tall {
    val height: String = "大高个"
  }

  // 继承 with
  class Child extends BeautifulEye with Tall {
  }

  def main(args: Array[String]): Unit = {
    val child = new Child()
    println(child.eye)
    println(child.height)
  }
}

(三)实现多个特质

格式:类名 extends 特质1 with 特质2 with 特质3  其中多个特质的顺序可以交换。

【代码示范】

定义两个特质,使用一个类来实现他们。

package level02

/*
 * 特质
 * trait: 实现多继承
 **/
object class16 {

  trait BeautifulEye {
    val eye: String = "眼睛漂亮"  // 具体属性
    val name:String  // 抽象属性
  }

  trait Tall {
    val height: String = "大高个"
    def run():Unit={
      println("run......")
    }
    def jump():Unit
  }

  // 继承 with
  class Child extends BeautifulEye with Tall {
    val name :String="小花"
    def jump():Unit={
      println(s"${name},jump.......")
    }
  }

  def main(args: Array[String]): Unit = {
    val child = new Child()
    println(child.eye)
    println(child.height)
    child.run()
    child.jump()
  }
}

结果如下:

屏幕截图 2025-11-17 152703.png

(四)特质成员的处理方式

一个类继承了一个特质之后,如何处理它的属性和方法呢?

分成四种情况:

1.特质中的抽象属性:可以通过val或var修饰来重写

2.特质中的抽象方法:一定要实现方法体。

3.特质中的具体属性:重写var不需override和var,只需要属性名即可;重写val需要加上override关键字。

4.特质中的具体方法:使用override重写,且保持名称,参数,返回值一致。

Webpack 5.x 开发模式启动流程详解

2025年11月17日 14:49

Webpack 5.x 开发模式启动流程详解

本文介绍 Webpack 5.x 版本在 development(开发模式)下的完整启动流程,重点区分首次启动代码更新(热更新) 两个核心场景。

前置知识:开发模式下的核心特征

在配置文件中通过 mode: 'development' 启用开发模式后,Webpack 会默认开启以下核心特性,这些特性直接影响启动流程:

  • 源码映射(Source Map) :默认生成 eval-cheap-module-source-map,便于开发时调试源码(而非编译后的代码)。
  • 不压缩代码:跳过代码混淆、压缩等优化步骤,提升构建速度。
  • 热模块替换(HMR)支持:配合 webpack-dev-server 实现代码更新后局部刷新,无需全页重载。
  • 缓存机制:默认缓存模块解析结果和编译结果,加速二次构建。

本文基于以下基础配置展开,后续步骤均围绕此配置示例:

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');

module.exports = {
  mode: 'development', // 明确指定开发模式
  entry: './src/index.js', // 入口文件
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.resolve(__dirname, 'dist'), // 输出目录
    clean: true // 每次构建前清空dist目录
  },
  devtool: 'eval-cheap-module-source-map', // 开发模式推荐Source Map
  devServer: {
    static: './dist', // 开发服务器静态资源目录
    hot: true, // 开启热模块替换
    open: true, // 启动后自动打开浏览器
    port: 8080 // 开发服务器端口
  },
  module: {
    rules: [
      {
        test: /.js$/, // 匹配JS文件
        exclude: /node_modules/, // 排除第三方依赖
        use: 'babel-loader' // 使用Babel转译
      },
      {
        test: /.css$/, // 匹配CSS文件
        use: ['style-loader', 'css-loader'] // 处理CSS(开发模式不提取CSS)
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({ // 自动生成HTML文件
      template: './src/index.html',
      filename: 'index.html'
    }),
    new webpack.HotModuleReplacementPlugin() // 热更新核心插件
  ]
};

场景一:首次启动流程

首次启动是指 Webpack 从读取配置到启动开发服务器并生成初始构建结果的完整过程,可细分为 8 个核心步骤,流程链路为:启动命令解析 → 配置解析与合并 → 环境准备 → 入口解析 → 模块递归构建 → 资源优化 → 输出到内存 → 启动开发服务器

步骤1:执行启动命令并初始化

开发模式下通常通过 webpack serve(Webpack 5 内置,替代旧版 webpack-dev-server 命令)启动,命令执行后触发以下操作:

  1. 命令解析:Node.js 执行 webpack 可执行文件,解析 serve 命令参数(如端口、是否自动打开浏览器等,优先级:命令行参数 > 配置文件 > 默认值)。
  2. 初始化 Compiler 实例:Webpack 核心类 Compiler 被创建,该实例负责统筹整个构建流程,保存构建过程中的所有状态。

步骤2:配置解析与合并

Compiler 实例初始化后,Webpack 会读取并处理配置信息,核心操作包括:

  1. 读取配置文件:默认读取项目根目录的 webpack.config.js,若指定 --config 参数则读取对应文件(如 npx webpack serve --config webpack.dev.js)。
  2. 配置合并:将用户配置与开发模式默认配置(webpack/lib/config/defaults.js 中定义)合并,用户配置优先级更高。例如开发模式默认 optimization.minimize: false,若用户未配置则沿用默认值。
  3. 插件初始化:实例化配置中 plugins 数组内的所有插件(如 HtmlWebpackPluginHotModuleReplacementPlugin),并调用插件的 apply 方法将其挂载到 Compiler 实例上,监听后续构建生命周期事件。

示例:开发模式默认配置与用户配置合并后,optimization 部分最终配置为:

{
  optimization: {
    minimize: false, // 开发模式默认关闭压缩
    splitChunks: {
      chunks: 'async' // 默认只拆分异步chunk
    },
    runtimeChunk: 'single' // 默认提取运行时chunk(Webpack 5默认)
  }
}

步骤3:环境准备与缓存初始化

配置合并完成后,Webpack 会准备构建环境并初始化缓存机制,为后续构建加速:

  1. 环境变量注入:通过 DefinePlugin(Webpack 内置,开发模式自动启用)注入 process.env.NODE_ENV = 'development' 到代码中,供业务代码判断环境。
  2. 缓存初始化:开发模式默认启用 cache: true,缓存目录为 node_modules/.cache/webpack,用于缓存模块解析结果(如 resolve 解析的路径)和编译结果(如 Babel 转译后的代码)。首次启动时缓存为空,后续构建可复用缓存。

环境变量示例:业务代码中可通过环境变量判断环境:

// src/index.js
if (process.env.NODE_ENV === 'development') {
  console.log('当前为开发环境,启用调试模式');
}
// 构建后会被替换为:if ('development' === 'development') { ... }

缓存初始化

1. 简单项目结构示例:以常见的前端开发项目为例,核心目录及文件如下,后续缓存结构将对应此项目的构建产物:

# 项目根目录
my-webpack-project/
├─ src/                  # 源码目录(Webpack构建入口)
│  ├─ index.js           # 入口文件(对应配置entry: ./src/index.js)
│  ├─ utils.js           # 工具模块(被index.js依赖)
│  ├─ style.css          # 样式文件(被index.js依赖)
│  └─ index.html         # HTML模板(供HtmlWebpackPlugin使用)
├─ node_modules/         # 第三方依赖(如react、lodash等)
├─ webpack.config.js     # Webpack配置文件(开发模式配置)
└─ package.json          # 项目依赖配置(含webpack、webpack-cli等)

2. 对应 .cache/webpack 目录结构:上述项目首次启动后,node_modules/.cache/webpack 会生成与项目模块对应的缓存文件,核心结构及对应关系如下:

# 缓存目录(对应my-webpack-project项目)
node_modules/.cache/webpack/
├─ default-development/  # 开发模式缓存(mode: development对应)
│  ├─ 0.pack             # 缓存1:项目源码模块解析结果(src/index.js等路径解析)
│  ├─ 1.pack             # 缓存2:项目源码编译结果(babel-loader转译后代码、css-loader处理结果)
│  ├─ 2.pack             # 缓存3:第三方依赖缓存(node_modules中模块的解析+编译结果)
│  ├─ cache.lock         # 缓存锁:防止多进程同时操作缓存导致冲突
│  ├─ metadata.json      # 元数据:记录缓存版本、项目依赖哈希、缓存有效期等
│  └─ modules/           # 拆分模块缓存(当项目模块较多时自动生成,对应单个模块)
│     ├─ 10.js.cache     # 对应src/index.js的编译缓存(独立模块缓存,便于增量更新)
│     ├─ 11.js.cache     # 对应src/utils.js的编译缓存
│     ├─ 12.css.cache    # 对应src/style.css的编译缓存(经css-loader处理后)
│     └─ 20.js.cache     # 对应node_modules/lodash的编译缓存(第三方依赖独立缓存)
└─ webpack-dev-server/   # devServer专属缓存
   └─ watch-state.json   # 监听状态缓存(记录src目录文件监听状态,快速检测文件变化)

缓存与项目的核心对应关系: - 项目src/目录下的每个模块(index.js、utils.js等)会对应modules/下的独立缓存文件(如10.js.cache); - 项目node_modules/中的第三方依赖会集中缓存或拆分为独立模块缓存(如20.js.cache),首次构建后后续复用; - 0.pack1.pack等打包缓存为聚合型缓存,提升批量模块的读取效率,modules/下为单模块缓存,便于热更新时精准替换。

缓存文件名与项目原文件名差异大的核心原因.cache/webpack 中的文件名(如 0.pack10.js.cache)并非直接沿用项目原文件名,而是 Webpack 基于“缓存唯一性”“构建效率”和“模块管理”设计的标识,具体原因如下:

  1. 基于内容哈希的唯一性标识:缓存的核心需求是“模块内容未变则复用缓存”,Webpack 会计算模块的内容哈希值(如 MD5、SHA-1)作为缓存键。例如 src/index.js 会根据其文件内容、依赖关系、loader 配置等生成唯一哈希,对应缓存文件名可能为 10.js.cache。这种方式能精准判断模块是否变化,避免因原文件名相同但内容不同导致的缓存失效问题。
  2. 模块 ID 映射机制:Webpack 会为每个参与构建的模块分配唯一的模块 ID(数字或哈希形式),替代原文件名作为模块的内部标识。例如 src/utils.js 可能被分配 ID 为 11,其缓存文件对应 11.js.cache。模块 ID 能简化依赖图的管理,减少构建产物体积(数字 ID 比长文件名更简洁),同时缓存文件名直接关联模块 ID,便于快速定位模块缓存。
  3. 聚合缓存的优化设计0.pack1.pack 等打包缓存文件是 Webpack 对多个模块缓存的聚合优化。当项目模块较多时,若为每个模块生成独立缓存文件会导致文件数量爆炸,影响读取效率。Webpack 会将关联紧密的模块(如同一目录下的源码模块、同一第三方库的子模块)的缓存聚合到一个 *.pack 文件中,文件名采用递增数字标识,既简化管理又提升批量读取速度。
  4. 环境与配置关联的动态标识:缓存文件名会隐含构建环境(如 default-development 目录对应开发模式)和配置信息。例如不同 mode(开发/生产)、devtoolloader 配置会导致同一模块的编译结果不同,Webpack 会通过缓存目录结构和文件名后缀区分这些差异,确保不同配置下的缓存互不干扰。
  5. 避免文件系统兼容性问题:不同操作系统对文件名的长度、特殊字符(如 @#)有不同限制,项目原文件名可能包含特殊字符(如 component@2x.js)。缓存文件名采用标准化的数字或哈希形式,可避免跨系统缓存读写异常,确保缓存机制的跨平台兼容性。

总的来说, 缓存文件名的设计核心是“脱离原文件名束缚,以更高效、唯一的方式关联模块缓存”。其与原文件的对应关系并非通过文件名直接体现,而是通过缓存元数据(如 metadata.json)中的“模块 ID-原文件路径-哈希值”映射表实现,Webpack 内部可通过该映射表快速匹配原文件与缓存文件。

步骤4:入口解析与模块依赖图构建

这是构建的核心步骤,Webpack 从入口文件开始,递归解析所有模块依赖,构建出完整的模块依赖图:

  1. 入口文件定位:根据配置的 entry(本文示例为 ./src/index.js),通过 resolve 配置解析出入口文件的绝对路径。
  2. 模块解析:调用 loader-runner 执行入口文件对应的 loader(本文示例中 .js 文件对应 babel-loader),对文件进行转译(如将 ES6+ 转译为 ES5)。
  3. 依赖递归解析:转译后的代码通过 acorn 解析为 AST(抽象语法树),遍历 AST 找到 requireimport 等依赖声明,递归解析每个依赖模块,重复步骤 2-3,直到所有依赖模块都被解析完成,最终构建出模块依赖图。

示例:若入口文件依赖 src/utils.js 和 src/style.css,依赖图结构为:

./src/index.js
├─ ./src/utils.js
└─ ./src/style.css

终端输出的编译日志会显示解析的模块数量:

[webpack-cli] Compilation finished

asset bundle.js 1.25 MiB [emitted] (name: main)
asset index.html 289 bytes [emitted]
runtime modules 27.5 KiB 13 modules
cacheable modules 530 KiB
  modules by path ./src/ 1.87 KiB
    ./src/index.js 786 bytes [built] [code generated]
    ./src/utils.js 523 bytes [built] [code generated]
    ./src/style.css 577 bytes [built] [code generated]
  modules by path ./node_modules/ 528 KiB
    ...(第三方依赖模块列表)

这里逐行解释一下信息:

// Webpack命令行编译完成的提示信息
[webpack-cli] Compilation finished
// 输出的JS资源:bundle.js文件名,体积1.25MiB,已发射(生成),对应主chunk
asset bundle.js 1.25 MiB [emitted] (name: main)
// 输出的HTML资源:index.html文件名,体积289字节,已发射
asset index.html 289 bytes [emitted]
// 运行时模块:负责模块加载、HMR等逻辑,共27.5KiB,13个模块
runtime modules 27.5 KiB 13 modules
// 可缓存模块:共530KiB,后续构建可复用缓存
cacheable modules 530 KiB
// 项目src目录下的模块统计:共1.87KiB
  modules by path ./src/ 1.87 KiB
    // 入口模块index.js:体积786字节,已构建,代码已生成
    ./src/index.js 786 bytes [built] [code generated]
    // 工具模块utils.js:体积523字节,已构建,代码已生成
    ./src/utils.js 523 bytes [built] [code generated]
    // 样式模块style.css:体积577字节,已构建,代码已生成(经loader处理后)
    ./src/style.css 577 bytes [built] [code generated]
// node_modules目录下的第三方依赖统计:共528KiB
  modules by path ./node_modules/ 528 KiB
    ...(第三方依赖模块列表)

步骤5:模块编译与代码生成

所有模块解析完成后,Webpack 会将模块依赖图转换为可在浏览器中运行的代码,核心操作包括:

  1. 模块封装:将每个模块封装为独立的函数(基于 IIFE),避免全局变量污染,同时通过模块 ID 建立依赖关联。

  2. 运行时注入:注入 Webpack 运行时代码(负责模块加载、依赖解析、HMR 逻辑等),使浏览器能够识别并执行封装后的模块。

  3. 资源处理:对于非 JS 资源(如 CSS、图片),根据 loader 配置处理:

    • CSS 模块css-loader 解析 @import 和 url()style-loader 将 CSS 转换为 JS 字符串,通过 document.createElement('style') 注入到页面。
    • 图片资源:若配置 file-loader 或 url-loader,会将图片转换为 Base64 或输出到指定目录(开发模式通常嵌入内存以提升速度)。

示例:编译后的 bundle.js 开头会包含运行时代码,模块部分类似:

// 运行时代码(简化)
(function(modules) {
  var installedModules = {};
  function __webpack_require__(moduleId) { ... } // 模块加载逻辑
  __webpack_require__.hmrM = {}; // HMR相关逻辑
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  // 模块封装(简化)
  "./src/index.js": (function(module, exports, __webpack_require__) {
    var utils = __webpack_require__("./src/utils.js");
    __webpack_require__("./src/style.css");
    console.log('当前为开发环境,启用调试模式');
  }),
  "./src/utils.js": (function(module, exports) {
    exports.formatDate = function(date) { ... };
  }),
  "./src/style.css": (function(module, exports, __webpack_require__) {
    var style = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js")(...);
  })
});

步骤6:插件执行(优化与输出)

模块编译完成后,Webpack 会触发 emit(输出前)和 afterEmit(输出后)等生命周期事件,插件通过监听这些事件介入构建流程。需明确:Webpack 5 开发模式下无绝对“必须手动配置”的插件(核心能力已内置),但存在实现核心开发体验的“关键必选插件”(部分可通过配置自动注入),具体分类及执行逻辑如下:

一、开发模式核心必选插件(实现基础功能)

这类插件是开发模式正常运行的基础,缺失会导致核心功能失效,部分可通过配置自动启用,推荐手动配置以确保兼容性:

  1. HotModuleReplacementPlugin(热更新核心插件)触发时机:监听 compile(编译开始)、make(模块构建)、emit(输出前)等事件。

    • 核心作用:注入热更新相关运行时代码(如模块变化检测、补丁生成逻辑),是 HMR 机制的核心。若不配置,即使 devServer.hot: true,也只能触发全页刷新,无法实现局部热更新。
    • 启用方式:Webpack 5 中配置 devServer.hot: true 会自动注入,但手动在 plugins 中配置可避免版本兼容问题,配置代码:new webpack.HotModuleReplacementPlugin()
  2. DefinePlugin(环境变量注入插件)触发时机:监听 compile 事件,在模块编译前注入环境变量。

    • 核心作用:将 process.env.NODE_ENV = 'development' 等环境变量注入业务代码,供开发者判断环境(如调试逻辑开关)。这是开发模式“不压缩代码”“启用 Source Map”等默认行为的触发基础。
    • 启用方式:Webpack 5 配置 mode: 'development' 后自动启用,无需手动配置;若需自定义环境变量,可手动配置插件并传入参数。

二、提升开发体验的关键推荐插件(非强制但必备)

这类插件不影响基础构建流程,但缺失会大幅降低开发效率,是实际开发中“必选”的体验增强插件:

  1. HtmlWebpackPlugin(HTML 自动生成插件)触发时机:监听 emit 事件(输出前)。

    • 核心作用:根据模板自动生成 HTML 文件,并将构建后的 JS/CSS 资源路径自动注入(如 <script src="bundle.js"></script>)。若不配置,需手动创建 HTML 并维护资源路径,模块拆分或文件名变化后需手动修改,极易出错。
    • 启用方式:需手动安装(npm i html-webpack-plugin -D)并配置,核心配置:new HtmlWebpackPlugin({ template: './src/index.html' })
  2. (替代 CleanWebpackPlugin)output.clean 配置触发时机:构建前清空 output.path 目录(对应原 CleanWebpackPlugin 的核心功能)。

    • 核心作用:避免旧构建产物(如重命名后的旧 chunk 文件)残留,防止开发中手动访问磁盘资源时加载旧文件。
    • 启用方式:Webpack 5 已将 CleanWebpackPlugin 功能内置到 output.clean: true,无需安装插件;Webpack 4 及以下需手动安装 CleanWebpackPlugin 并配置。

示例:生成的 index.html 内容(自动注入 bundle.js):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Webpack Dev Mode</title>
</head>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script> 
</body>
</html>

步骤7:输出到内存(非磁盘)

开发模式与生产模式的核心区别之一是输出目标:生产模式将构建结果输出到磁盘(output.path 目录),而开发模式为提升速度,将构建结果(JS、HTML、CSS 等)存储在内存中,由 webpack-dev-server 直接从内存读取资源并提供服务。

开发模式下通过 memory-fs 存储的文件结构,与配置的 output 目录逻辑一致,但仅存在于内存中,可通过开发服务器的资源访问路径映射。以本文基础配置为例,内存中的核心文件结构如下:

// 内存中的虚拟文件系统结构(对应 output.path: dist 逻辑目录)
dist/  // 虚拟根目录,对应配置的 output.path
├─ bundle.js  // 主构建产物(JS文件,含运行时+模块代码+Source Map信息)
├─ index.html  // HtmlWebpackPlugin生成的HTML文件(自动注入bundle.js路径)
├─ main.abc123.hot-update.js  // 热更新补丁文件(代码修改后动态生成)
├─ main.abc123.hot-update.json  // 热更新元数据(记录更新模块ID、哈希值)
└─ assets/  // 若配置处理图片等资源(如url-loader),生成的虚拟资源目录
   └─ logo.efg456.png  // 处理后的图片资源(可能为Base64编码或虚拟路径)

  内存文件结构的核心特点

  1. 逻辑映射性:虚拟目录 dist/ 与配置的 output.path 完全对应,资源路径(如 bundle.js)与磁盘输出时一致,确保开发服务器可通过 /bundle.js 直接访问;
  2. 动态性:热更新时会动态新增/修改补丁文件(如 .hot-update.js),无需重建整个目录结构,提升更新效率;
  3. 无实际文件:无法通过文件管理器查看,需通过浏览器开发者工具的 Network 面板(如访问 http://localhost:8080/bundle.js)或 Webpack 插件(如 webpack-dev-middleware 的调试接口)查看;
  4. 资源完整性:包含构建所需的所有资源(JS、HTML、补丁文件、处理后的静态资源),与磁盘输出的完整产物逻辑一致,确保浏览器可正常加载运行。

关键原理:Webpack 通过 memory-fs(内存文件系统)替代本地文件系统,构建结果写入内存后,webpack-dev-server 监听内存中的文件变化,无需等待磁盘 I/O,大幅提升响应速度。

示例:首次构建完成后,项目根目录的 dist 目录可能为空(或仅存在非 Webpack 管理的静态资源),但浏览器访问 http://localhost:8080 可正常加载页面,因为资源来自内存。

步骤8:启动开发服务器并监听文件变化

构建结果写入内存后,webpack-dev-server 会启动一个 HTTP 服务器(默认端口 8080),并完成以下操作:

  1. 启动服务器:绑定配置的端口(本文示例为 8080),并将内存中的资源作为静态资源提供服务。
  2. 自动打开浏览器:若配置 devServer.open: true,会自动打开浏览器并访问服务器地址(如 http://localhost:8080)。
  3. 监听文件变化:通过 chokidar 库监听 src 等源码目录的文件变化(可通过 devServer.watchFiles 配置监听范围),当文件修改时触发后续热更新流程。

示例:服务器启动完成后,终端输出日志:

[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.
[webpack-dev-server] Live Reloading enabled.

此时浏览器访问 http://localhost:8080 可看到页面,控制台输出 “当前为开发环境,启用调试模式”。

场景二:代码更新(热更新)流程

当开发者修改源码后(如修改 src/index.js),Webpack 会通过热模块替换(HMR)机制实现局部更新,无需全页刷新,流程链路为:文件变化检测 → 增量构建 → 生成更新补丁 → 客户端接收补丁 → 局部模块替换

以下步骤基于首次启动完成后的状态展开。

步骤1:检测文件变化

webpack-dev-server 通过 chokidar 监听配置的文件目录(默认监听 context 目录下的文件),当文件被修改并保存后,触发以下操作:

  1. 变化检测chokidar 检测到文件变化(如 src/index.js 被修改),并将变化的文件路径通知给 Webpack。
  2. 过滤无关变化:Webpack 会过滤掉非模块文件(如日志文件、临时文件)和配置中排除的文件(如 node_modules),仅处理源码模块的变化。

示例:修改 src/index.js 中的控制台输出内容,保存后终端输出:

[webpack-dev-server] [HMR] File updated: ./src/index.js
[webpack-cli] Compilation starting...

步骤2:增量构建(仅重新编译变化的模块)

与首次启动的全量构建不同,热更新时 Webpack 会执行增量构建,仅重新处理变化的模块及其依赖,大幅提升构建速度,核心操作包括:

  1. 模块依赖分析:根据文件变化路径,找到对应的模块 ID,然后分析该模块在依赖图中的所有依赖和被依赖模块(即“依赖链”)。例如,若修改 src/utils.js,则所有依赖 utils.js 的模块(如 index.js)都需要重新编译。

  2. 缓存复用:未变化的模块直接复用首次构建时的缓存结果,仅重新编译变化的模块及其依赖链上的模块。缓存结果来自 Webpack 开发模式默认的文件系统缓存目录 node_modules/.cache/webpack(首次启动时已初始化该目录及缓存文件)。具体来说,未变化模块的缓存分为两类,获取逻辑不同:

    1. 项目源码模块(如 utils.js、style.css) :其缓存对应缓存目录中 node_modules/ 下的独立文件(如 11.js.cache 对应 src/utils.js),缓存内容为模块的解析路径和经 loader 处理后的编译结果。Webpack 通过模块 ID 与原文件路径的映射关系(存储在 metadata.json 中),快速匹配并读取对应缓存。
    2. 第三方依赖模块(如 node_modules 下的库) :其缓存通常聚合在 0.pack 或 2.pack 等打包缓存文件中,缓存内容为依赖的解析结果和编译结果(若未排除转译)。因第三方依赖极少修改,Webpack 直接复用首次构建时生成的缓存,无需重新解析和转译。
  3. 代码重新生成:对变化的模块重新执行 loader 转译和模块封装,生成新的模块代码。

示例:修改 src/index.js 后,终端输出的增量构建日志(仅重新编译变化的模块):

[webpack-cli] Compilation finished
asset bundle.js 1.25 MiB [emitted] (name: main)
asset index.html 289 bytes [emitted]
runtime modules 27.5 KiB 13 modules
cacheable modules 530 KiB
  modules by path ./src/ 1.87 KiB
    ./src/index.js 792 bytes [built] [code generated] [1 change] // 仅该模块变化
    ./src/utils.js 523 bytes [built] [code generated] [cache hit] // 缓存命中,未重新编译
    ./src/style.css 577 bytes [built] [code generated] [cache hit]
  modules by path ./node_modules/ 528 KiB
    ...(第三方依赖缓存命中)

步骤3:生成模块更新补丁(HMR Update)

增量构建完成后,HotModuleReplacementPlugin 会生成模块更新补丁,核心内容包括:

  1. 变化模块信息:包含变化的模块 ID、新的模块代码、模块依赖关系等。
  2. 更新策略:指定如何替换旧模块(如直接替换、删除后新增)。

补丁文件通常以 .hot-update.js 结尾(如 main.abc123.hot-update.js),同时生成一个 .hot-update.json 文件记录更新信息(如更新的 chunk 名称、哈希值)。这些文件同样存储在内存中。

示例:热更新构建完成后,终端输出补丁相关日志:

[HMR] Updated modules:
[HMR]  - ./src/index.js
[HMR] Webpack output is served from /
[HMR] Content not from webpack is served from './dist' directory
[HMR] App updated. Recompiling...
[HMR] Waiting for update signal from WDS...

步骤4:客户端(浏览器)接收更新通知

开发服务器与客户端之间通过 WebSocket 建立长连接,实时同步更新信息,核心流程:

  1. 服务器发送更新通知:当补丁生成完成后,服务器通过 WebSocket 向客户端发送更新通知,包含更新的 hash 值(用于匹配补丁文件)。
  2. 客户端请求补丁文件:客户端(浏览器)接收到通知后,根据 hash 值请求对应的 .hot-update.json 和 .hot-update.js 补丁文件(从内存中获取)。

示例:打开浏览器开发者工具的 Network 面板,可看到热更新时的请求:

GET http://localhost:8080/main.abc123.hot-update.json 200 OK
GET http://localhost:8080/main.abc123.hot-update.js 200 OK

步骤5:局部模块替换与页面更新

客户端获取补丁文件后,由 Webpack 运行时的 HMR 逻辑执行局部模块替换,避免全页刷新,核心操作:

  1. 模块替换:运行时根据补丁文件中的模块信息,替换内存中对应的旧模块代码,并更新模块依赖关系。
  2. 执行模块热替换回调:若业务代码中定义了 module.hot.accept 回调(用于处理模块更新后的逻辑),则执行该回调。例如,React 项目中 react-refresh-webpack-plugin 会通过该回调实现组件的热刷新。
  3. 局部页面更新:若模块替换成功且无需全页刷新,则仅更新页面中受影响的部分(如修改 CSS 后实时更新样式,修改 JS 后执行新逻辑);若模块无法热替换(如修改了运行时代码),则 webpack-dev-server 会自动触发全页刷新。

示例1:基础 JS 模块热更新

// src/index.js
console.log('当前为开发环境,启用调试模式');

// 定义HMR回调(可选,用于自定义更新逻辑)
if (module.hot) {
  module.hot.accept('./utils.js', () => {
    console.log('utils模块已更新,执行自定义逻辑');
    // 重新调用utils模块的方法
    const { formatDate } = require('./utils.js');
    console.log('更新后的日期格式:', formatDate(new Date()));
  });
}

修改 src/utils.js 后,浏览器控制台输出:

[HMR] Updated modules:
[HMR]  - ./src/utils.js
[HMR] App is up to date.
utils模块已更新,执行自定义逻辑
更新后的日期格式:2025-11-15

示例2:CSS 热更新:由于 style-loader 会将 CSS 注入到 style 标签中,修改 src/style.css 后,HMR 会直接替换 style 标签中的内容,页面样式实时更新,无需刷新。

关键优化点与常见问题

1. 开发模式构建速度优化

  • 合理配置缓存:默认启用的缓存已足够优化,若需自定义可配置 cache 选项(如指定缓存目录、缓存类型)。例如配置 cache: { type: 'filesystem', cacheDirectory: path.resolve(__dirname, '.webpack-cache') } 可将缓存存储到自定义目录,便于管理。
  • 排除第三方依赖:通过 module.rules.exclude: /node_modules/ 排除第三方依赖,避免重复转译(第三方依赖通常已编译为 ES5)。进一步可结合 cache-loader 或 Webpack 5 内置缓存,缓存第三方依赖的处理结果。
  • thread-loader 使用:对耗时的 loader(如 babel-loader)启用多线程处理,提升转译速度。配置示例:use: ['thread-loader', 'babel-loader'],需注意线程启动有开销,适合处理大量文件时使用。
  • 减少监听文件范围:通过 devServer.watchFiles 仅监听源码目录,避免监听 node_modules 等目录。示例配置 devServer: { watchFiles: ['src/**/*'] },精准监听 src 下所有文件变化。
  • 选择合适的 devtool:不同 devtool 类型对构建速度影响显著,开发模式推荐 eval-cheap-module-source-map(平衡速度与调试体验),若追求极致速度可临时使用 eval(调试信息较简略)。
  • 拆分公共代码:通过 optimization.splitChunks 拆分第三方依赖为独立 chunk(如 vendors.js),该 chunk 仅在依赖变化时重新构建,减少主 chunk 构建频率。配置示例:splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /node_modules/, name: 'vendors', priority: -10 } } }
  • webpack-dev-middleware 使用 自定义服务:若需整合 Express 等 Node 服务,可使用 webpack-dev-middleware 替代 webpack-dev-server,灵活控制服务逻辑的同时保留内存构建特性。

2. 热更新失效的常见原因

  • 未启用 HMR 插件:需在 plugins 中配置 new webpack.HotModuleReplacementPlugin()。Webpack 5 中 devServer.hot: true 会自动注入该插件,但手动配置插件可避免版本兼容问题。
  • devServer.hot 未开启:需在 devServer 配置中设置 hot: true,确保开发服务器启用热更新功能,该配置与 HotModuleReplacementPlugin 需配合使用。若同时设置 hotOnly: true,则热更新失败时不触发全页刷新,便于排查问题。
  • 模块无法热替换:部分模块(如入口文件、运行时代码)或未适配 HMR 的第三方库,修改后无法实现局部替换,会触发全页刷新;可通过 module.hot.accept 自定义适配逻辑。例如入口文件无法直接热替换,可将核心逻辑抽离为子模块后对其配置热更新。
  • 文件监听异常:Windows 系统下可能因文件权限问题导致 chokidar 监听失效,或因 node_modules 等目录未排除导致监听负载过高,可通过 devServer.watchFiles 精确配置监听范围。此外,编辑器自动保存可能触发多次更新,可调整编辑器保存策略或配置 devServer.watchOptions.ignored 排除临时文件。
  • 缓存冲突:若之前的构建缓存未清理,可能导致旧模块缓存与新模块冲突,可通过删除 node_modules/.cache/webpack 目录或配置 cache: false 临时禁用缓存排查问题。若使用 filesystem 缓存,可配置 cache.buildDependencies.config: [__filename],确保配置文件变化时清空缓存。
  • 第三方库兼容性问题:部分老旧第三方库使用全局变量或未采用模块化规范,修改其引用后可能导致热更新失效。解决方案:使用 imports-loader 或 exports-loader 适配模块化,或通过 splitChunks 将其拆分为独立 chunk 减少更新影响。
  • WebSocket 连接失败:防火墙拦截、端口占用或代理配置错误可能导致 WebSocket 连接失败,热更新通知无法传递。排查方法:检查终端是否有 WebSocket 错误日志,确保 devServer.client.webSocketURL 配置正确,端口未被其他进程占用。
  • loader 配置冲突:部分 loader 可能修改模块输出格式,导致 HMR 无法识别模块变化。例如使用 babel-loader 时未排除 node_modules,可能导致第三方模块转译后热更新异常,需确保 exclude 配置正确。

Vue2 和 Vue3 中 watch 用法和原理详解

作者 木易士心
2025年11月17日 14:42

@TOC

1. Vue2 中的 watch

1. 基本用法

在 Vue2 中,watch 是一个对象,其键是要观察的表达式,值是对应的回调函数或包含选项的对象。

// 对象写法
export default {
  data() {
    return {
      count: 0,
      user: {
        name: 'John',
        age: 25
      }
    }
  },
  watch: {
    // 监听基本数据类型
    count(newVal, oldVal) {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    },
    
    // 深度监听对象
    user: {
      handler(newVal, oldVal) {
        console.log('user changed:', newVal)
      },
      deep: true, // 深度监听
      immediate: true // 立即执行
    },
    
    // 监听对象特定属性
    'user.name': function(newVal, oldVal) {
      console.log(`name changed from ${oldVal} to ${newVal}`)
    }
  }
}

2. 程序式监听

Vue2 也提供了 $watch API,可以在实例的任何地方监听数据变化。

export default {
  mounted() {
    // 使用 $watch API
    const unwatch = this.$watch(
      'count',
      (newVal, oldVal) => {
        console.log(`count changed: ${oldVal} -> ${newVal}`)
      },
      {
        immediate: true,
        deep: false
      }
    )
    
    // 取消监听
    // unwatch()
  }
}

2. Vue3 中的 watch

1. 组合式 API 用法

Vue3 的 watch 更加灵活,支持监听 ref、reactive 对象、getter 函数等多种数据源。

import { ref, reactive, watch, watchEffect } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const user = reactive({
      name: 'John',
      age: 25
    })
    
    // 监听 ref
    watch(count, (newVal, oldVal) => {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    })
    
    // 监听 reactive 对象
    watch(
      () => user.name, // getter 函数
      (newVal, oldVal) => {
        console.log(`name changed from ${oldVal} to ${newVal}`)
      }
    )
    
    // 深度监听对象
    watch(
      () => user,
      (newVal, oldVal) => {
        console.log('user changed:', newVal)
      },
      { deep: true }
    )
    
    // 监听多个源
    watch(
      [() => count.value, () => user.name],
      ([newCount, newName], [oldCount, oldName]) => {
        console.log(`count: ${oldCount}->${newCount}, name: ${oldName}->${newName}`)
      }
    )
    
    // watchEffect - 自动追踪依赖
    watchEffect(() => {
      console.log(`count is ${count.value}, name is ${user.name}`)
    })
    
    return {
      count,
      user
    }
  }
}

2. 选项式 API 用法

Vue3 也支持在选项式 API 中使用 watch,与 Vue2 的用法类似。

import { watch } from 'vue'

export default {
  data() {
    return {
      count: 0,
      user: {
        name: 'John',
        age: 25
      }
    }
  },
  watch: {
    count(newVal, oldVal) {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    }
  },
  created() {
    // 使用 watch 函数
    watch(
      () => this.user.name,
      (newVal, oldVal) => {
        console.log(`name changed from ${oldVal} to ${newVal}`)
      }
    )
  }
}

3.核心原理分析

1. Vue2 的 Watch 原理

Vue2 的 watch 基于响应式系统的依赖收集和派发更新机制。

  • 在组件实例初始化阶段,遍历 watch 对象的每一个属性,为每一个监听表达式创建一个 watcher 实例。
  • watcher 的创建过程:解析表达式,生成 getter 函数;执行 getter 函数,触发依赖收集;保存旧值,等待数据变化。
  • 当被监听的数据发生变化时,触发 setter,通知对应的 watcher 更新;watcher 执行 getter 获取新值,比较新值和旧值,如果不同则执行回调函数。

2. Vue3 的 Watch 原理

Vue3 的 watch 基于 effect 机制实现。

  • 将回调函数包装成一个 effect,当被监听的数据发生变化时,effect 会重新执行。
  • 通过 track 函数进行依赖收集,trigger 函数触发更新。
  • 使用调度器 scheduler 控制 effect 的执行时机,实现异步更新和 flush 选项。

4. 主要差异对比

1. 差异总结

  • Vue2 的 watch 语法较为简单直观,适合选项式 API;Vue3 的 watch 更加灵活,适合组合式 API。
  • Vue3 的 watch 基于 effect 机制实现,提供了更好的性能和更丰富的配置选项。
  • 两者都支持深度监听、立即执行、异步回调等特性,但在语法和使用方式上有所不同。

2. 特性对比

特性 Vue2 Vue3
API 形式 选项式 组合式 + 选项式
监听 reactive 不支持 原生支持
深度监听 需要显式配置 reactive 对象默认深度监听
多源监听 不支持 支持监听多个数据源
清理副作用 不支持 支持 cleanup 函数
性能 相对较低 基于 Proxy,性能更好

5. 使用建议

1. 性能优化

避免不必要的深度监听,只监听需要的属性。

// Vue3 - 避免不必要的深度监听
const largeObject = reactive({ /* 大量数据 */ })

// 不好的做法
watch(largeObject, () => {
  // 任何属性变化都会触发
})

// 好的做法 - 只监听需要的属性
watch(
  () => largeObject.importantProp,
  () => {
    // 只有 importantProp 变化时触发
  }
)

2. 清理副作用

Vue3 支持在 watch 中清理副作用,避免内存泄漏。

// Vue3 - 清理副作用
watch(
  data,
  async (newVal, oldVal, onCleanup) => {
    let cancelled = false
    onCleanup(() => {
      cancelled = true
    })
    
    const result = await fetchData(newVal)
    if (!cancelled) {
      // 处理结果
    }
  }
)

3. 防抖处理

使用防抖函数避免频繁触发 watch 回调。

import { debounce } from 'lodash-es'

// Vue3 防抖监听
watch(
  searchQuery,
  debounce((newVal) => {
    searchAPI(newVal)
  }, 300)
)

6.常见问题解答

1. Vue2 和 Vue3 的 watch 混用?

在 Vue3 的选项式 API 中,可以继续使用 Vue2 风格的 watch 选项,但不建议混用。

2. 什么时候用 watch,什么时候用 computed?

watch 用于执行副作用(如 API 调用、DOM 操作),computed 用于派生数据。

3. watchEffect 和 watch 的区别?

watchEffect 自动追踪依赖,立即执行;watch 需要明确指定监听源,默认懒执行。

通过深入理解 Vue2 和 Vue3 中 watch 的用法和原理,可以更好地根据项目需求选择合适的监听方式,并编写出更高效、可维护的代码。

Cesium.js基本使用

作者 Harlen
2025年11月17日 14:35

整体效果如下:

image.png

image.png

对于cesium的一些初始工作,可在前几篇博客中自行参考,本文不做过多阐述;

点、线、模型(及运动轨道)的添加

在cesium官网文档cesium.com/learn/cesiu…中可看到一些相关属性

image.png

image.png

image.png

这里我们只取最常用的几个:

点的添加:

viewer.entities.add({
    name: '添加点',
    position: Cesium.Cartesian3.fromDegrees(lng, lat, height), //经、纬、高度
    point: {
        color: new Cesium.Color(0, 0, 0, 1.0), // 颜色
        pixelSize: 10, // 大小
        outlineColor: Cesium.Color.RED, // 轮廓颜色
        outlineWidth: 5 //轮廓宽度
    }
})

线的添加:

//起始点
const startPosition = Cesium.Cartesian3.fromDegrees(lng, lat, height);
//结束点
const endPosition = Cesium.Cartesian3.fromDegrees(lng, lat, height);


viewer.entities.add({
    name: '添加线',
    polyline: {
        positions: Cesium.Cartesian3.fromDegreesArray([startPosition, endPosition]),
        width: 2,
        material: Cesium.Color.RED,
    }
})

模型的添加:

 shipEntity.value = viewer.entities.add({
    id: "shipEntity",
    name: "shipEntity",
    position: Cesium.Cartesian3.fromDegrees(122.35782164573101, 34.12, 100),
    model: {
      uri: '/data/model/hwj.gltf', //模型相对路径  放在public目录下
      minimumPixelSize: 100,
      maximumScale: 20000,
    },
    properties: {         //属性存放
      name: "test-name",
      description: "test-desc"
    }
  })

  shipEntity2.value = viewer.entities.add({
    id: "shipEntity2",
    name: "shipEntity2",
    position: Cesium.Cartesian3.fromDegrees(122.35782164573101, 34.9, 100),
    model: {
      uri: '/data/model/hm.gltf', //模型相对路径  放在public目录下
      minimumPixelSize: 100,
      maximumScale: 20000,
    },
    properties: {        //属性存放
      name: "test-name",
      description: "test-desc"
    }
  })

飞行路线及飞行模型添加

const createAirplane = () => {
  if (!window.viewer || airplaneEntity.value) return;

  // 初始化时间
  startTime.value = Cesium.JulianDate.fromDate(new Date());
  stopTime.value = Cesium.JulianDate.addSeconds(startTime.value, 360, new Cesium.JulianDate());

  // 设置时钟
  window.viewer.clock.startTime = startTime.value.clone();
  window.viewer.clock.currentTime = startTime.value.clone();
  window.viewer.clock.stopTime = stopTime.value.clone();
  window.viewer.clock.multiplier = 5;
  window.viewer.timeline.zoomTo(startTime.value, stopTime.value);
  window.viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;

  // 计算飞行路线
  const property = computeFlyRoute();
  const orientation = new Cesium.VelocityOrientationProperty(property);

  // 设置视角偏移
  const viewFrom = new Cesium.Cartesian3();
  viewFrom.z = 1466.61814287398;
  viewFrom.x = -3306.272590514738;
  viewFrom.y = 135.03403439279646;

  // 创建飞机实体
  airplaneEntity.value = window.viewer.entities.add({
    name: 'airplane',
    availability: new Cesium.TimeIntervalCollection([
      new Cesium.TimeInterval({
        start: startTime.value,
        stop: stopTime.value
      })
    ]),
    position: property,
    orientation: orientation,
    viewFrom: viewFrom,
    model: {
      uri: '/data/model/feiji.glb',
      minimumPixelSize: 100,
      maximumScale: 2000,
    },
    path: {
      resolution: 1,
      material: new Cesium.PolylineGlowMaterialProperty({
        glowPower: 0.1,
        color: Cesium.Color.GREEN.withAlpha(1),
      }),
      width: 9,
    },
  });
}

测试文件数据:

这里创建一个json文件(包含点和线)

测试数据如下:

image.png

点线整合代码:

/**
 * 创建边的数据源(提取的独立函数)
 * @param {Object} viewer - Cesium 实例
 * @param {Array} nodes - 节点数据
 * @param {Array} edges - 边数据
 * @param {String} dataSourceName - 数据源名称
 * @param {Boolean} clampToGround - 是否贴合地面
 * @param {String} color - 线条颜色
 */
const createEdgesDataSource = function (viewer, nodes = [], edges = [], dataSourceName = 'edgesDataSource', clampToGround = true, color = null) {
    // 查找是否已存在同名的数据源
    let dataSource = viewer.dataSources.getByName(dataSourceName);

    if (dataSource.length === 0) {
        // 创建新的数据源
        dataSource = new Cesium.CustomDataSource(dataSourceName);
        viewer.dataSources.add(dataSource);
    } else {
        dataSource = dataSource[0];
    }

    // 清空现有实体
    dataSource.entities.removeAll();

    // 创建边(连接线)- 使用优化后的逻辑
    if (edges && edges.length > 0) {
        edges.forEach((item, index) => {
            const lineArr = [];

            // 查找源节点和目标节点的坐标
            for (let i = 0; i < nodes.length; i++) {
                const node = nodes[i];
                const hasCoordinates = (node.hasOwnProperty('longitude') && node.hasOwnProperty('latitude')) ||
                    (node.hasOwnProperty('经度') && node.hasOwnProperty('纬度'));

                if (hasCoordinates) {
                    if (item.source == node.id) {
                        const lng = node['经度'] !== undefined ? node['经度'] : node.longitude;
                        const lat = node['纬度'] !== undefined ? node['纬度'] : node.latitude;
                        const height = node['高度'] !== undefined ? node['高度'] : (node.height || 0);

                        lineArr.push(lng || 0, lat || 0, height || 0);
                    }

                    if (item.target == node.id) {
                        const lng = node['经度'] !== undefined ? node['经度'] : node.longitude;
                        const lat = node['纬度'] !== undefined ? node['纬度'] : node.latitude;
                        const height = node['高度'] !== undefined ? node['高度'] : (node.height || 0);

                        lineArr.push(lng || 0, lat || 0, height || 0);
                    }
                }
            }

            // 如果成功获取了两个点的坐标,创建连线
            if (lineArr.length === 6) { // 两个点,每个点有3个坐标值
                const startPosition = Cesium.Cartesian3.fromDegrees(lineArr[0], lineArr[1], lineArr[2]);
                const endPosition = Cesium.Cartesian3.fromDegrees(lineArr[3], lineArr[4], lineArr[5]);

                let polylineColor = Cesium.Color.YELLOW;
                if (color) {
                    polylineColor = Cesium.Color.fromCssColorString(color);
                } else if (item.color) {
                    polylineColor = Cesium.Color.fromCssColorString(item.color);
                }

                dataSource.entities.add({
                    id: `edge_${item.source}_${item.target}_${index}`,
                    name: item.name || '连接线',
                    polyline: {
                        positions: [startPosition, endPosition],
                        width: item.width || 7,
                        material: new Cesium.PolylineArrowMaterialProperty(polylineColor),
                        clampToGround: item.clampToGround !== undefined ? item.clampToGround : clampToGround,
                        arcType: Cesium.ArcType.GEODESIC
                    }
                });
            } else {
                console.warn(`无法创建边: 节点 ${item.source} 或 ${item.target} 的坐标不完整`);
            }
        });
    }

    return dataSource;
};

/**
 * Cesium 加载点的方法(改进版,贴合地球表面)
 * viewer: cesium实例
 * array: 节点数据需要id,经纬度
 * dataSourceName: 数据源名称,用于统一管理
 * edges: 边数据,包含连接的节点id
 * clampToGround: 是否贴合地面,默认为true
 */
const createNodesDataSource = function (viewer, nodes = [], height = 0, dataSourceName = 'nodesDataSource', clampToGround = true) {
    // 查找是否已存在同名的数据源
    let dataSource = viewer.dataSources.getByName(dataSourceName);

    if (dataSource.length === 0) {
        // 创建新的数据源
        dataSource = new Cesium.CustomDataSource(dataSourceName);
        viewer.dataSources.add(dataSource);
    } else {
        dataSource = dataSource[0];
    }

    // 清空现有实体
    dataSource.entities.removeAll();

    // 存储节点位置,用于边的连接
    const nodePositions = {};

    // 创建节点
    nodes.forEach((item) => {
        // 使用地形高度采样获取准确的地面高度
        const longitude = item.经度 !== undefined ? item.经度 : item.longitude;
        const latitude = item.纬度 !== undefined ? item.纬度 : item.latitude;

        const cartographicPosition = Cesium.Cartographic.fromDegrees(longitude, latitude);
        const sampledHeight = viewer.scene.globe.getHeight(cartographicPosition) || 0;
        const finalHeight = clampToGround ? sampledHeight + (height || 0) : height;

        const position = Cesium.Cartesian3.fromDegrees(longitude, latitude, finalHeight);
        nodePositions[item.id] = position;

        dataSource.entities.add({
            id: item.id,
            name: item.name,
            position: position,
            point: {
                pixelSize: 20,
                color: new Cesium.Color(1, 1, 0, 1),
                heightReference: clampToGround ? Cesium.HeightReference.CLAMP_TO_GROUND : Cesium.HeightReference.NONE
            },
            label: {
                text: item.name || '暂无名称',
                font: '10px',
                fillColor: Cesium.Color.WHITE,
                backgroundColor: Cesium.Color.SKYBLUE,
                showBackground: false,
                outlineWidth: 2,
                pixelOffset: new Cesium.Cartesian2(1, -34),
                verticalOrigin: Cesium.VerticalOrigin.TOP,
                horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
                heightReference: clampToGround ? Cesium.HeightReference.CLAMP_TO_GROUND : Cesium.HeightReference.NONE
            },
        });
    });
    

    return dataSource;
};

效果如下:

image.png

image.png

指北针添加

可参考www.npmjs.com/package/ces…

image.png

image.png

npm install cesium-navigation-es6 --save

import {  Viewer,Rectangle} from "cesium";
import 'cesium/Build/Cesium/Widgets/widgets.css';
import CesiumNavigation from "cesium-navigation-es6";

const viewer = new Viewer("cesiumContainer",{
    animation:false,
    timeline:false
});

const options = {};
// 用于在使用重置导航重置地图视图时设置默认视图控制。接受的值是Cesium.Cartographic 和 Cesium.Rectangle.
// options.defaultResetView = Rectangle.fromDegrees(80, 22, 130, 50)
options.defaultResetView = new Cartographic(CesiumMath.toRadians(111.50623801848565), CesiumMath.toRadians(2.8997206760441205), 8213979.400955964)
//相机方向
options.orientation = {
    heading: CesiumMath.toRadians(350.94452087411315),
    pitch: CesiumMath.toRadians(-66.6402342251215),
    roll: CesiumMath.toRadians(360)
}
//相机延时
options.duration = 4//默认为3s

// 用于启用或禁用罗盘。true是启用罗盘,false是禁用罗盘。默认值为true。如果将选项设置为false,则罗盘将不会添加到地图中。
options.enableCompass= true;
// 用于启用或禁用缩放控件。true是启用,false是禁用。默认值为true。如果将选项设置为false,则缩放控件将不会添加到地图中。
options.enableZoomControls= true;
// 用于启用或禁用距离图例。true是启用,false是禁用。默认值为true。如果将选项设置为false,距离图例将不会添加到地图中。
options.enableDistanceLegend= true;
// 用于启用或禁用指南针外环。true是启用,false是禁用。默认值为true。如果将选项设置为false,则该环将可见但无效。
options.enableCompassOuterRing= true;

//修改重置视图的tooltip
options.resetTooltip = "重置视图";
//修改放大按钮的tooltip
options.zoomInTooltip = "放大";
//修改缩小按钮的tooltip
options.zoomOutTooltip = "缩小";

//如需自定义罗盘控件,请看下面的自定义罗盘控件
new CesiumNavigation(viewer, options);

CZML文件加载

image.png

const entitiesCzmlInit = () => {
  //测试文件路径
  let czmlFiles = [`1`, `2`, `3`, `4`,`5`,`6`,'7',`8`, `9`, `10`, `11`,`12`,`13`,'14',`15`, `16`, `17`, `18`,`19`,`20`,'21',`22`, `23`, `24`, `25`,`26`];
  // 测试
  czmlFiles.forEach((file) => {
    viewer.dataSources.add(Cesium.CzmlDataSource.load(`/czmlFile/${file}.czml`));
  });
};

onMounted(async () => {
  Cesium.Ion.defaultAccessToken =
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIyYTNkYjhhNC04NDQ4LTRiMDItYTg4OS03YWU3ZWVjNjBiNTgiLCJpZCI6ODYyMDMsImlhdCI6MTY4MTM3ODA5M30.M1tBPd6f5Of1l2ElUqecFjv9GcZ-Ntcwm2iain-fvkk";
  const viewer = new Viewer("cesiumContainer", {
    infoBox: false,
    animation: true, // 是否显示动画控件
    homeButton: false, // 是否显示home键
    geocoder: false, // 是否显示地名查找控件
    baseLayerPicker: false, // 是否显示图层选择控件
    timeline: true, // 是否显示时间线控件
    fullscreenButton: false, // 是否全屏显示
    infoBox: false, // 是否显示点击要素之后显示的信息
    sceneModePicker: false, // 是否显示投影方式控件  三维/二维
    navigationInstructionsInitiallyVisible: false,
    navigationHelpButton: false, // 是否显示帮助信息控件
    orderIndependentTranslucency: false,
    shouldAnimate: true,
    scene3DOnly: false, // 每个几何实例将只能以3D渲染以节省GPU内存
    selectionIndicator: false, // 取消点击有绿框
    // imageryProvider: false, // 不提供地图
    baseLayerPicker: true, //是否显示图层选择控件
    sceneMode: Cesium.SceneMode.SCENE3D, // 设置场景为3D模式
  });
 
  // 设置开始时间
  viewer.clock.startTime = Cesium.JulianDate.fromDate(
    new Date(2024, 10, 10, 8, 30)
  ); // 设置为2024年10月10日 08:30
  // 控制播放状态
  // viewer.clock.shouldAnimate = true;  // 启用动画(时间流动)
  //设置版权等信息不显示
  viewer._cesiumWidget._creditContainer.style.display = "none";


  cesiumViewer.value = viewer;
  window.viewer = viewer;
  
  
  entitiesCzmlInit();
});

声明:

本数据的发布仅为技术演示和学习目的,不对使用本数据引发的任何误用或误解负责。

vue3.x 使用vue3-tree-org实现组织架构图 + 自定义模版内容 - 附完整示例

作者 bug爱好者
2025年11月17日 14:08

组织树形结构架构图,如果是vue2项目,请移步www.cnblogs.com/10ve/p/1257…

本文主要讲解在vue3项目中使用,废话不多说,直接上代码。

实际完成效果图

a106a8797916fda0c2faf2501698f655.png

官方文档:sangtian152.github.io/vue3-tree-o…

image.png

安装

npm i vue3-tree-org -S
# or
yarn add vue3-tree-org

安装版本号

"vue3-tree-org": "^4.2.2",

全局使用共有两种方法:

  1. main.js直接使用:

import { createApp } from 'vue'
import vue3TreeOrg from 'vue3-tree-org';
import "vue3-tree-org/lib/vue3-tree-org.css";
 
const app = createApp(App)
 
app.use(vue3TreeOrg)
app.mount('#app')
  1. main.js封装使用(推荐):
import { createApp } from 'vue';
import App from './App.vue';
import router, { setupRouter } from '@/router';
import { setupStore } from '@/store';
import { setupDirectives } from '@/directives';
import setupPlugins from '@/plugins';

// 引入动画
import 'animate.css/animate.min.css';
import 'animate.css/animate.compat.css';
import '@/styles/common/base.scss';
import '@/styles/common/element_edit_after.scss';
import '@/styles/common/el-button.scss';

async function appInit() {
  const app = createApp(App);

  // 挂载状态管理
  setupStore(app);

  // 挂载路由
  setupRouter(app);

  // 挂载插件
  setupPlugins(app);

  // 自定义指令
  setupDirectives(app);

  // 路由准备就绪后挂载APP实例
  await router.isReady();

  // 挂载到页面
  app.mount('#app', true);
}

void appInit();

3. plugins文件下的treeOrg.ts

import { App } from 'vue'

import vue3TreeOrg from 'vue3-tree-org';
import "vue3-tree-org/lib/vue3-tree-org.css";

export function setupTreeOrg(app: App) {
  app.use(vue3TreeOrg)
}

整体文件对应图

image.png

如果不需要自定义内容,可以这样使用


<template>
  <div class="tree-wrap" style="height: 400px">
    <div class="search-box">
      <span>搜索:</span>
      <input type="text" v-model="keyword" placeholder="请输入搜索内容" @keydown.enter="filter" />
    </div>
    <vue3-tree-org
      ref="treeRef"
      :data="data"
      :horizontal="horizontal"
      :collapsable="collapsable"
      :label-style="style"
      :node-draggable="true"
      :scalable="false"
      :only-one-node="onlyOneNode"
      :default-expand-level="1"
      :filter-node-method="filterNodeMethod"
      :clone-node-drag="cloneNodeDrag"
      @on-restore="restore"
      @on-contextmenu="onMenus"
      @on-node-click="onNodeClick"
    />
  </div>
</template>
 
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
 
const treeRef = ref()
const data = ref({
  id: 1,
  label: 'xxx科技有限公司',
  children: [
    {
      id: 2,
      pid: 1,
      label: '产品研发部',
      style: { color: '#fff', background: '#108ffe' },
      children: [
        { id: 6, pid: 2, label: '禁止编辑节点', disabled: true },
        { id: 8, pid: 2, label: '禁止拖拽节点', noDragging: true },
        { id: 10, pid: 2, label: '测试' }
      ]
    },
    {
      id: 3,
      pid: 1,
      label: '客服部',
      children: [
        { id: 11, pid: 3, label: '客服一部' },
        { id: 12, pid: 3, label: '客服二部' }
      ]
    },
    { id: 4, pid: 1, label: '业务部' }
  ]
})
const keyword = ref('')
const horizontal = ref(false)
const collapsable = ref(true)
const onlyOneNode = ref(true)
const cloneNodeDrag = ref(true)
const expandAll = ref(true)
const style = ref({
  background: '#fff',
  color: '#5e6d82'
})
 
const onMenus = ({ node, command }) => {
  console.log(node, command)
}
const restore = () => {
  console.log('restore')
}
const filter = () => {
  treeRef.value.filter(keyword.value)
}
const filterNodeMethod = (value, data) => {
  console.log(value, data)
  if (!value) return true
  return data.label.indexOf(value) !== -1
}
const onNodeClick = (e, data) => {
  ElMessage.info(data.label)
}
const expandChange = () => {
  toggleExpand(data.value, expandAll.value)
}
</script>
<style lang="scss" scoped>
.tree-wrap {
  position: relative;
  padding-top: 52px;
}
.search-box {
  padding: 8px 15px;
  position: absolute;
  top: 0;
  left: 0;
  input {
    width: 200px;
    height: 32px;
    border: 1px solid #ddd;
    outline: none;
    border-radius: 5px;
    padding-left: 10px;
  }
}
.tree-org-node__text {
  text-align: left;
  font-size: 14px;
  .custom-content {
    padding-bottom: 8px;
    margin-bottom: 8px;
    border-bottom: 1px solid currentColor;
  }
}

效果图为:

image.png

如果需要自定义,可以这样使用

<template v-slot="{node}">
    <div class="tree-org-node__text node-label">
        <div class="custom-content">自定义内容</div>
        <div>节点ID:{{node.id}}</div>
        <div>节点名称:{{node.label}}</div>
    </div>
</template>

注意:

  1. 这样只能只能取id和label
  2. 如果你有其他的,如createTime,gross这些额外的字段,在使用node.createTime,或node.gross,将不会生效,你需要使用$$data字段进行解析
  3. 由来$$data::render-content函数进行渲染打印,你会得到:
<template>
    <vue3-tree-org 
        :render-content="renderContent"
    >
    </vue3-tree-org>
</template>

const renderContent = (h: any, node: any) => {
  console.log(node, '11111111111111111')
}

image.png

  1. 此时你就可以这样使用:
<template v-slot="{node}"> 
    <div class="tree-org-node__text node-label">
        <div class="custom-content">自定义内容</div> 
        <div>节点ID:{{node.$$data.id}}</div> 
        <div>节点名称:{{node.$$data.label}}</div>
        <div>节点时间:{{node.$$data.createTime}}</div>
        <div>节点增长:{{node.$$data.gross}}</div>
    </div> 
</template>

注意:如果你在使用renderContent函数进行数据渲染打印时,控制台无输出,请暂时删掉template模版内所有内容后重试,原因如官网所示:

image.png

项目中使用(完整代码)

<template>
  <div class="tree-wrap">
    <vue3-tree-org
      center
      ref="treeRef"
      :data="treeData"
      :horizontal="horizontal"
      :collapsable="collapsable"
      :label-style="style"
      :node-draggable="true"
      :scalable="scalable"
      :only-one-node="onlyOneNode"
      :default-expand-level="1"
      :clone-node-drag="cloneNodeDrag"
      :before-drag-end="beforeDragEnd"
    >
    <!-- 自定义节点内容,实现可配置 -->
    <template v-slot="{node}">
        <div class="tree-org-node__text">
          <div class="mb12">提煤计划号:{{ node.$$data.no || '--' }}</div>
          <div class="mb12 myb-cursor-pointer">
            <span class="no-cursor-pointer">转发张数:{{node.$$data.num || 0}}</span>
            <span class="ml15" @click="getClick('4', node.$$data.id)">接单张数:<span class="c409eff">{{node.$$data.receive || 0}}</span></span>
            <span class="ml15" @click="getClick('7', node.$$data.id)">过空张数:<span class="c409eff">{{node.$$data.tare || 0}}</span></span>
            <span class="ml15" @click="getClick('8', node.$$data.id)">过重张数:<span class="c409eff">{{node.$$data.gross || 0}}</span></span>
            <span class="ml15" @click="getClick('9', node.$$data.id)">作废张数:<span class="c409eff">{{node.$$data.cancel || 0}}</span></span>
          </div>
          <div class="mb5 box">
            <span class="myb-ellipsis-1">转出方:{{ node.$$data.partyBname || '--' }}</span>
            <span class="ml15">转出时间:{{ formatTime(node.$$data.createTime, node.$$data.partyBname) }}</span>
          </div>
          <div class="mb5 box">
            <span class="myb-ellipsis-1">转入方:{{ node.$$data.preName || '--' }}</span>
            <span class="ml15">转入时间:{{ formatTime(node.$$data.createTime, node.$$data.preName) }}</span>
          </div>
        </div>
      </template>
      <!-- 节点展开数量 -->
      <!-- <template v-slot:expand="{node}">
        <div>{{node.children.length}}</div>
      </template> -->
    </vue3-tree-org>
  </div>
</template>

<script setup lang="ts">
import moment from 'moment'
import { ref, onBeforeMount, h } from 'vue'
import { coalPlanTreeDetail, statByCoalPlan } from "@/service-api/coalDeliveryNote";

const props = defineProps({
 coalPlanId: {
    // 提煤计划id
    type: [String, Number],
    default: "",
  },
});

const treeData = ref({})
const scalable = ref(false) // 是否可缩放
const horizontal = ref(false) // 是否水平布局
const collapsable = ref(true) // 是否可折叠
const onlyOneNode = ref(true) // 是否仅拖动当前节点,如果true,仅拖动当前节点,子节点自动添加到当前节点父节点,如果false,则当前节点及子节点一起拖动
const cloneNodeDrag = ref(true)  // 是否拷贝节点拖拽
// tree整体样式配置
const style = ref({
  background: '#fff',
  color: '#606266'
})

// 递归获取所有节点ID
const getAllNodeIds = (node: any): number[] => {
  let ids: number[] = [];
  if (node.id) {
    ids.push(node.id);
  }
  if (node.children && node.children.length > 0) {
    node.children.forEach((child: any) => {
      ids = ids.concat(getAllNodeIds(child));
    });
  }
  return ids;
};

// 递归更新节点数据
const updateNodeData = (node: any, statDataMap: Map<number, any>) => {
  if (node.id !== undefined && statDataMap.has(node.id)) {
    const statData = statDataMap.get(node.id);
    node.receive = statData.receive;
    node.tare = statData.tare;
    node.gross = statData.gross;
    node.cancel = statData.cancel;
  }
  if (node.children && node.children.length > 0) {
    node.children.forEach((child: any) => {
      updateNodeData(child, statDataMap);
    });
  }
};

const getTreeData = async () => {
  const { data } = await coalPlanTreeDetail({
    // id: props.coalPlanId
    id: 35
  });

  // 注意:因为本项目中的接单张数,过空张数,过重张数,作废张数,是在接口请求之后,在同步请求statByCoalPlan接口,将数据同步到节点数据中,如果你的项目部需要这步骤,则直接用下面代码:
  if (data) {
    // 获取所有节点ID
    const allIds = getAllNodeIds(data);
    const statDataMap = new Map<number, any>();
    // 并行请求所有统计数据
    const promises = allIds.map(id => statByCoalPlan({ coalPlanId: id }));
    const results = await Promise.all(promises);
    // 将结果存入映射
    results.forEach((result, index) => {
      if (result.data) {
        statDataMap.set(allIds[index], result.data);
      }
    });
    // 更新节点数据
    updateNodeData(data, statDataMap);
    treeData.value = data;
  }

  // 如果不需要这个步骤,则直接使用下面代码:
  treeData.value = data;
};

const beforeDragEnd = (node: any, targetNode: any) => {
  return new Promise<void>((resolve, reject) => {
    if (!targetNode) reject()
    if (node.id === targetNode.id) {
      reject()
    } else {
      resolve()
    }
  })
};

const emit = defineEmits(['handleSwitchTab'])
const getClick = (type: string, id: string) => {
  emit('handleSwitchTab', type, id)
};

const formatTime = (time: string, name: string) => {
  return time && name ? moment(time).format("YYYY-MM-DD HH:mm:ss") : '--';
};

onBeforeMount(() => {
  getTreeData();
});

</script>

<style lang="scss" scoped>
.tree-wrap {
  height: 500px;
  position: relative;
  :deep(.zm-tree-org) {
    padding: 15px 0 0 0;
    .zoom-container {
      overflow: auto; // 在允许视图滚动的同时,影藏未满足滚动时的滚动槽 
      .tree-org>.tree-org-node {
        padding: 3px 0 10px 0;  // tree-org 组件节点间距调整
        .tree-org-node__children {
          display: flex;
        }
      }
      .tree-org-node {
        flex-shrink: 0; // 防止盒子被压缩
        .tree-org-node__text {
          text-align: left;
          .myb-ellipsis-1 {
            max-width: 370px;
            display: inline-block;
          }
          .no-cursor-pointer {
            cursor: default;
          }
        }
      }
    }
  }
}
// 影藏放大图标
:deep(.zoom-out) {
  display: none;
}
// 影藏缩小图标
:deep(.zoom-in) {
  display: none;
}
</style>

END...

🎯 Flutter 拖拽选择组件:flutter_drag_selector —— 像选文件一样选择列表项

作者 达达尼昂
2025年11月17日 14:08

在 Flutter 开发桌面端应用中,我们经常需要在一个滚动列表(如 ListViewGridViewWrap)中让用户批量选择多个项目。传统的做法是点击每个项目逐一选中,或使用“全选”按钮,但这在项目数量多、布局密集的场景下体验不佳。

flutter_drag_selector 正是为解决这一痛点而生的轻量级 Flutter 插件。它让你像在桌面系统中用鼠标框选文件一样,通过拖拽鼠标(或手指)划出一个区域,自动选中该区域内的所有子组件,大幅提升交互效率。

✨ 核心功能概览

  • 拖拽框选:按住鼠标左键(或触摸屏长按)并拖动,实时绘制一个半透明选择框。
  • 自动识别:自动检测拖拽区域内所有被 SelectableItem 包裹的子组件,并动态选中/取消选中。
  • 可定制:支持自定义选择区域的样式(颜色、圆角、边框)、选中状态回调、滚动容器控制等。

img_introduce.gif

🛠️ 如何使用?

1. 添加依赖

在你的 pubspec.yaml 文件中添加:

dependencies:
  flutter_drag_selector: ^latest

2. 包装你的列表

将你的滚动容器(如 SingleChildScrollView + Wrap)用 CursorSelectorWidget 包裹,并用 SelectableItem 包裹每一个可选项目。

import 'package:flutter/material.dart';
import 'package:flutter_drag_selector/flutter_drag_selector.dart';

class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _list = List.generate(50, (i) => i);
  final _controller = StreamController<(Key?, bool)>.broadcast(); // 用于更新 UI 状态
  final scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    _controller.close();
    scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    Widget buildBox(int index) {
      final id = ValueKey<int>(index);
      return StreamBuilder<(Key?, bool)>(
        stream: _controller.stream.where((e) => e.$1 == id),
        builder: (ctx, snapshot) {
          return SelectableItem(
            key: id,
            child: GestureDetector(
              onTap: () {
                debugPrint('tap -> $index');
              },
              child: Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                  color: index % 2 == 0 ? Colors.yellow : Colors.lightBlueAccent,
                ),
                alignment: Alignment.center,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text('$index', style: const TextStyle(fontSize: 20)),
                    const SizedBox(width: 20),
                    Icon(
                      (snapshot.data?.$2 ?? false)
                          ? Icons.check_box
                          : Icons.check_box_outline_blank,
                      size: 40,
                      color: Colors.red,
                    )
                  ],
                ),
              ),
            ),
          );
        },
      );
    }

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('拖拽选择示例')),
        body: CursorSelectorTheme(
          data: CursorSelectorThemeData(
            selectedAreaDecoration: BoxDecoration(
              color: Colors.blue.withOpacity(0.4),
              borderRadius: BorderRadius.circular(10),
            ),
          ),
          child: CursorSelectorWidget(
            scrollController: scrollController, // 控制滚动
            selectedChangedCallback: (selection) {
              // 选中状态变更回调,用于更新 UI
              _controller.add(selection);
            },
            child: SingleChildScrollView(
              controller: scrollController,
              child: Wrap(
                spacing: 10,
                runSpacing: 10,
                children: _list.map<Widget>(buildBox).toList(),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

3. 效果说明

  • 用户按住鼠标左键(或在移动端长按)并拖动,会看到一个蓝色半透明矩形框跟随鼠标移动。
  • 每次SelectableItem的选中状态变化,都会通过 selectedChangedCallback 回调返回 (Key, isSelected),开发者可据此更新数据模型或 UI。

🎨 自定义主题

你可以通过 CursorSelectorThemeCursorSelectorThemeData 自定义选择框的视觉样式:

CursorSelectorTheme(
  data: CursorSelectorThemeData(
    selectedAreaDecoration: BoxDecoration(
      color: Colors.green.withOpacity(0.3),
      border: Border.all(color: Colors.green, width: 2),
      borderRadius: BorderRadius.circular(8),
    )
  ),
  child: CursorSelectorWidget(...),
)

结语

如有设计不佳的点,欢迎指出。

👉 GitHub 地址github.com/bladeofgod/…

👉 Pub 地址pub.dev/packages/fl…

从零构建 React Native 导航体系-React Navigation

作者 少卿
2025年11月17日 13:39

一、React Navigation v7 介绍

React Navigation v7 在架构和性能上都有显著优化:

  • 原生性能提升:推荐使用 @react-navigation/native-stack,利用原生导航控制器实现更流畅的动画
  • 模块化设计:核心包与导航器分离,按需安装减少包体积
  • 统一的配置 API:无论是 Stack、Tab 还是 Drawer,配置方式更加一致
  • 更好的 TypeScript 支持:内置类型推断,开发体验更友好

二、环境搭建:三步完成安装配置

步骤 1:安装核心依赖

npm install @react-navigation/native

步骤 2:添加原生能力库

这两个库是性能优化的关键,必须安装:

npm install react-native-screens react-native-safe-area-context

iOS 用户注意:安装后需执行 npx pod-install ios 链接原生代码。

步骤 3:按需安装导航器

# 堆栈导航(最常用)
npm install @react-navigation/native-stack

# 底部标签导航
npm install @react-navigation/bottom-tabs

# 抽屉导航
npm install @react-navigation/drawer

三、核心基础:Stack Navigator 实战

Stack Navigator 是移动应用的基石,它管理页面的堆栈历史,支持手势返回和切换动画。

3.1 创建你的第一个导航器

屏幕组件screens/Home.jsx):

import { SafeAreaView, StyleSheet, Text, Pressable } from 'react-native';

const Home = ({ navigation }) => {
    return (
        <SafeAreaView style={styles.container}>
            <Text style={styles.title}>Home Screen</Text>
            <Pressable 
                onPress={() => navigation.navigate('Profile')}
                style={styles.button}
            >
                <Text style={styles.buttonText}>→ 跳转到 Profile</Text>
            </Pressable>
        </SafeAreaView>
    );
}

const styles = StyleSheet.create({
    container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
    title: { fontSize: 24, marginBottom: 20 },
    button: { 
        backgroundColor: '#4BC1D2', 
        padding: 12, 
        borderRadius: 8 
    },
    buttonText: { color: '#fff', fontWeight: 'bold' }
});

根组件配置App.js):

import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import Home from './screens/Home';
import Profile from './screens/Profile';

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator initialRouteName="Home">
        <Stack.Screen name="Home" component={Home} />
        <Stack.Screen 
          name="Profile" 
          component={Profile}
          options={{ title: '个人中心' }}  // 自定义标题
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

关键点解析

  • NavigationContainer :整个应用的导航大脑,管理状态与深度链接
  • navigation.navigate('RouteName') :最核心的跳转方法,自动管理堆栈
  • 所有屏幕自动接收 navigation 属性 :无需手动传递

四、丰富导航体验:Tab 与 Drawer

4.1 Bottom Tabs:主流 App 的首选

底部标签栏让用户能快速切换核心功能模块:

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Ionicons from 'react-native-vector-icons/Ionicons';

const Tab = createBottomTabNavigator();

function AppTabs() {
  return (
    <Tab.Navigator
      screenOptions={({ route }) => ({
        // 动态图标配置
        tabBarIcon: ({ focused, color, size }) => {
          const iconMap = {
            Home: focused ? 'ios-home' : 'ios-home-outline',
            Settings: focused ? 'ios-settings' : 'ios-settings-outline',
          };
          return <Ionicons name={iconMap[route.name]} size={size} color={color} />;
        },
        tabBarActiveTintColor: '#4BC1D2',  // 激活颜色
        tabBarInactiveTintColor: '#999',     // 非激活颜色
        headerShown: false,  // 隐藏堆栈标题,由内部导航器管理
      })}
    >
      <Tab.Screen name="Home" component={HomeStackScreen} />
      <Tab.Screen name="Settings" component={SettingsScreen} />
    </Tab.Navigator>
  );
}

4.2 Drawer:侧边菜单的优雅实现

适合功能模块较多、需要层级导航的应用:

import { createDrawerNavigator } from '@react-navigation/drawer';

const Drawer = createDrawerNavigator();

function AppDrawer() {
  return (
    <Drawer.Navigator
      drawerPosition="left"  // 抽屉从左侧滑出
      drawerStyle={{ width: 280 }}  // 自定义宽度
      drawerContentOptions={{
        activeTintColor: '#4BC1D2',
        labelStyle: { fontSize: 16 },
      }}
    >
      <Drawer.Screen name="Main" component={AppTabs} />
      <Drawer.Screen name="Profile" component={ProfileScreen} />
    </Drawer.Navigator>
  );
}

五、导航器组合:构建复杂应用结构

真实场景通常是导航器嵌套:标签导航包含堆栈导航,抽屉导航包裹标签导航。

// 1. 首页模块的堆栈导航
function HomeStackScreen() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="HomeMain" component={HomeScreen} />
      <Stack.Screen name="Details" component={DetailsScreen} />
    </Stack.Navigator>
  );
}

// 2. 设置模块的堆栈导航
function SettingsStackScreen() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="SettingsMain" component={SettingsScreen} />
      <Stack.Screen name="Account" component={AccountScreen} />
    </Stack.Navigator>
  );
}

// 3. 底部标签导航组合两个堆栈
function AppTabs() {
  return (
    <Tab.Navigator>
      <Tab.Screen name="HomeTab" component={HomeStackScreen} />
      <Tab.Screen name="SettingsTab" component={SettingsStackScreen} />
    </Tab.Navigator>
  );
}

// 4. 根组件:抽屉包裹标签导航
export default function App() {
  return (
    <NavigationContainer>
      <Drawer.Navigator>
        <Drawer.Screen name="App" component={AppTabs} />
      </Drawer.Navigator>
    </NavigationContainer>
  );
}

设计模式解读

  • 模块化:每个功能模块独立管理自己的页面堆栈
  • 职责分离:Tab 负责一级导航,Stack 负责二级页面流转
  • 用户体验:保持底部标签常驻,堆栈跳转不影响全局导航

六、页面通信:参数传递与返回处理

6.1 向下一个页面传参

// 发送方:HomeScreen
navigation.navigate('Details', {
  itemId: 86,
  itemName: 'React Navigation 指南',
  timestamp: Date.now()
});
// 接收方:DetailsScreen
function DetailsScreen({ route, navigation }) {
  const { itemId, itemName } = route.params;
  
  return (
    <View>
      <Text>商品ID: {itemId}</Text>
      <Text>商品名称: {itemName}</Text>
    </View>
  );
}

6.2 返回时携带数据

// DetailsScreen 返回时传参
navigation.navigate('Home', { result: 'success' });

// HomeScreen 接收返回参数
useEffect(() => {
  if (route.params?.result) {
    console.log('操作结果:', route.params.result);
  }
}, [route.params]);

6.3 useNavigation Hook:在深层组件中导航

当组件未直接接收 navigation prop 时:

import { useNavigation } from '@react-navigation/native';

function DeepChildComponent() {
  const navigation = useNavigation();
  
  return (
    <Button 
      title="返回首页" 
      onPress={() => navigation.navigate('Home')} 
    />
  );
}

七、进阶定制:让导航更贴合你的 App

7.1 自定义头部样式

<Stack.Screen 
  name="Details" 
  component={DetailsScreen}
  options={{
    headerStyle: { backgroundColor: '#f4511e' },
    headerTintColor: '#fff',  // 返回按钮和标题颜色
    headerTitleStyle: { fontWeight: 'bold' },
    headerRight: () => (
      <Button onPress={() => alert('分享')} title="分享" />
    ),
  }}
/>

7.2 深度链接(Deep Linking)

让外部链接能直接打开 App 内特定页面:

import { Linking } from 'react-native';

const linking = {
  prefixes: ['myapp://', 'https://myapp.com'],
  config: {
    screens: {
      Home: 'home',
      Details: 'details/:itemId',  // 动态参数
      Profile: 'profile',
    },
  },
};

function App() {
  return (
    <NavigationContainer linking={linking}>
      <Stack.Navigator>
        {/* ... */}
      </Stack.Navigator>
    </NavigationContainer>
  );
}

7.3 权限路由守卫

在渲染前进行权限检查:

function RequireAuth({ children }) {
  const { hasToken } = useAuth();
  const location = useLocation();
  
  const publicPaths = ["/login", "/register"];
  const isPublic = publicPaths.includes(location.pathname);
  
  if (!hasToken && !isPublic) {
    return <Navigate to="/login" replace state={{ from: location }} />;
  }
  
  return children;
}

八、版本升级指南与兼容性

v7 与旧版本的主要差异

表格

复制

特性 v6/v7 推荐 旧版本 (v4/v5) 说明
堆栈导航器 @react-navigation/native-stack react-navigation-stack 原生性能,API 更简洁
Tab 导航器 @react-navigation/bottom-tabs react-navigation-tabs 独立成包,配置更灵活
Drawer 导航器 @react-navigation/drawer react-navigation-drawer 手势体验优化

提示@react-navigation/stack 已逐步被 @react-navigation/native-stack 替代,新项目强烈推荐使用后者以获得原生性能。


九、总结

React Navigation v7 通过模块化架构原生性能优化,成为 React Native 导航的最佳选择。记住三个核心原则:

  1. @react-navigation/native 是必需基础 :所有项目都从此开始
  2. 根据场景选择导航器:Stack 处理页面流,Tab 负责一级导航,Drawer 提供全局菜单
  3. 嵌套而非平铺:复杂应用采用导航器组合,保持结构清晰

现在打开你的编辑器,开始构建流畅的导航体验吧!


参考资料

学习React-DnD:实现 TodoList 简单拖拽功能

作者 Wect
2025年11月17日 13:33

本文将为您带来基础 TodoList 项目的进阶教程,重点讲解如何使用 React-DnD 库为任务列表添加拖拽排序功能。建议读者先掌握基础版本实现后再继续阅读本文;若尚未了解基础 TodoList 功能,请先查阅相关教程。

EasyTodoList.gif

一、引言

在基础 TodoList 项目中,我们实现了任务的添加、状态切换和删除等核心功能。为了提升用户体验,拖拽排序是一个非常实用的功能,它允许用户通过直观的拖放操作来重新排列任务顺序。React-DnD 是 React 生态中处理拖拽操作的优秀库,它提供了声明式 API,让拖拽功能的实现变得简单而优雅。

与原生拖拽 API 相比,React-DnD 封装了复杂的事件处理逻辑,将拖拽行为拆解为可复用的组件逻辑,同时支持多种拖拽场景扩展,非常适合在 TodoList 这类需要灵活交互的应用中使用。

二、项目结构调整

为了使拖拽功能的代码结构清晰、可维护,我们基于基础 TodoList 项目进行如下结构调整,新增拖拽相关配置目录,并明确各组件的职责边界:

src/
├── App.jsx              # 应用入口组件,添加 DnDProvider 包裹全局
├── App.css              # 全局样式文件,新增拖拽相关视觉样式
├── components/
│   ├── TodoList/
│   │   └── TodoList.jsx # 任务列表容器,渲染 TodoItem 列表
│   └── TodoItem/
│       └── TodoItem.jsx # 核心改造组件,同时作为拖拽源和放置目标
├── context/
│   └── TodoContext/     # 任务状态管理,新增拖拽排序方法
├── dnd/                 # 新增:拖拽相关配置目录,集中管理类型定义
│   └── types.js         # 定义拖拽类型常量,实现类型统一管理
└── main.jsx             # 应用挂载入口,无额外修改

三、完整实现步骤

按以下步骤完成功能落地,逐步完成拖拽排序功能的集成,每一步都包含具体代码和关键说明:

1. 安装 React-DnD 核心依赖

首先需要安装 React-DnD 库及其 HTML5 后端(适用于桌面端浏览器场景),如果是移动端应用,可后续替换为触摸后端:

# 使用 npm 安装
npm install react-dnd react-dnd-html5-backend

# 或使用 yarn 安装
yarn add react-dnd react-dnd-html5-backend

2. 定义拖拽类型常量

dnd/types.js 文件中定义拖拽类型,采用常量形式便于后续维护和复用,避免硬编码导致的错误:

// dnd/types.js
// 定义 Todo 任务的拖拽类型,命名清晰便于区分其他拖拽项
const ItemTypes = {
  TODO_ITEM: 'todo_item',
}

// 导出拖拽类型常量
export default ItemTypes; 

3. 配置全局 DnDProvider

React-DnD 通过 DnDProvider 为整个应用提供拖拽上下文,需要在应用入口组件 App.jsx 中包裹全局组件,并配置 HTML5 后端:

import React from 'react'
import './App.css'

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'
import TodoProvider from '@/context/TodoContext/TodoProvider'

import TodoList from '@/components/TodoList/TodoList'

function App() {

  return (
    <DndProvider backend={HTML5Backend}>
      <TodoProvider>
          <TodoList />
      </TodoProvider>
    </DndProvider>
  )
}

export default App

4. 实现 TodoItem 拖拽功能

我们需要改造 TodoItem.jsx 组件,使其同时具备拖拽源(DragSource)和放置目标(DropTarget)功能,这是实现拖拽排序的关键:

改造要点:

  1. 拖拽源功能:启用待办事项的拖拽操作,设置拖拽数据
  2. 放置目标功能:接收其他待办事项的拖拽,处理放置操作
  3. 交互优化:确保拖拽过程流畅,实现自然的排序效果
// components/TodoItem/TodoItem.jsx
import React, { useRef } from 'react';
import useTodoContext from '@/context/TodoContext/useTodoContext';

import { useDrag, useDrop } from 'react-dnd';
import ItemTypes from '@/dnd/types';

export default function TodoItem({ todo, index }) {
  const { todos, deleteTodo, toggleComplete, reorderTodos } = useTodoContext();
  const divRef = useRef(null);
  // 确保todo存在
  if (!todo) {
    return null;
  }

  // 拖拽功能实现
  const [{ isDragging }, dragSourceRef] = useDrag(
    {
      // 拖拽类型,用于和目标源的accept属性匹配
      type: ItemTypes.TODO_ITEM,
      // 拖拽时传递的数据,包含当前拖拽项的id和索引
      item: { id: todo.id, index },
      // 自定义判断当前项是否正在被拖拽的逻辑
      // 当拖拽数据存在且id与当前项匹配时,视为正在拖拽
      isDragging: (monitor) => {
        return monitor.getItem() !== null && monitor.getItem().id === todo.id;
      },
      // 收集拖拽状态的回调,返回需要的状态数据
      collect: (monitor) => {
        return {
          // 获取当前是否处于拖拽状态(如果不设置isDragging配置,会使用默认实现)
          // isDragging 如果没有设置 则会调用默认的实现
          isDragging: monitor.isDragging(),
        }
      }

    }
    // 依赖数组:当todos发生变化时,重新创建拖拽源配置
    , [todos])

  const [{ isOver, canDrop }, dropTargetRef] = useDrop(
    {
      // 接受的拖拽类型,与拖拽源的type匹配
      accept: ItemTypes.TODO_ITEM,
      // 放置目标自身的数据(此处为空对象,可根据需要添加)
      item: {},
      hover: (item) => {
        // item 是拖拽源  todo 是接受拖拽
        // console.log(item, todo);

        if (item.id === todo.id) return;

        // 直接使用索引会导致拖拽混乱
        // 在拖拽过程中列表顺序发生变化(比如多次快速拖拽), item.index 仍然会保持 初始拖拽时的旧索引 ,而不是当前列表中的实际索引。
        // console.log(item.index, index);
        // reorderTodos(item.index, index);

        // 通过 todos.findIndex 的方式获取index更可靠
        // console.log(item.id, todo.id);
        const sourceIndex = todos.findIndex(oneTodo => oneTodo.id === item.id);
        const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);
        // console.log(sourceIndex, destinationIndex);
        reorderTodos(sourceIndex, destinationIndex);

      },
      // 收集放置状态的回调,返回需要的状态数据
      collect: (monitor) => {
        return {
          // 当前是否有拖拽项悬停在上方
          isOver: monitor.isOver(),
          // 当前目标是否可以接受拖拽项
          canDrop: monitor.canDrop
        }
      },
    }
    // 依赖数组:当todos发生变化时,重新创建放置目标配置
    , [todos])

  // 注意 Drag 和 Drop 必须添加todos依赖 如果不添加 容易拖拽状态不同步 等问题  显示混乱
  dropTargetRef(dragSourceRef(divRef))

  return (
    // 拖拽时  会添加 isDragging 类名  会使当前组件 透明度 降低
    <div className={`todo-item${isDragging ? ' isDragging' : ''}`} ref={divRef}>
      <div className="todo-item-content">
        <input
          type="checkbox"
          id={`todo-${todo.id}`}
          className="todo-checkbox"
        />
        <label
          htmlFor={`todo-${todo.id}`}
          className={`todo-text ${todo.completed ? 'completed' : ''}`}
        >
          {todo.text}
        </label>
      </div>
      <button
        onClick={() => toggleComplete(todo.id)}
        className={`todo-complete-btn ${todo.completed ? 'completed' : ''}`}
        aria-label={todo.completed ? "标记为未完成" : "标记为已完成"}
      >
        {todo.completed ? "已完成" : "未完成"}
      </button>
      <button
        onClick={() => deleteTodo(todo.id)}
        className="todo-delete-btn"
        aria-label="删除任务"
      >
        ×
      </button>
    </div>
  );
}

五、常见问题及解决方案

1. 拖拽一次后样式不生效

问题描述:首次拖拽任务后,再次拖拽时透明度样式不再正确应用。

原因分析:React-DnD 默认的 monitor.isDragging() 方法在组件重新渲染后,无法准确识别当前正在被拖拽的是哪个具体组件,导致状态判断错误。

解决方案:实现基于任务 ID 的自定义拖拽状态判断,在 collect 函数中使用相同的逻辑:

isDragging: (monitor) => {
    return monitor.getItem() !== null && monitor.getItem().id === todo.id;
}

2. 拖拽位置计算不准确

问题描述:拖拽排序后,任务的实际位置与预期不符,出现位置错乱。

原因分析:直接使用组件索引进行位置计算时,没有考虑到列表动态更新导致的索引变化,容易产生闭包陷阱。

解决方案:使用 findIndex 方法基于任务 ID 实时查找最新位置,确保排序操作的准确性:

const sourceIndex = todos.findIndex(oneTodo => oneTodo.id === item.id);
const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

3. 状态不同步问题

问题描述:拖拽操作完成后,组件界面显示与实际数据状态不一致。

原因分析useDraguseDrop 钩子的依赖数组配置不完整,导致组件没有在相关状态变化时重新计算。

解决方案:确保依赖数组包含所有必要的状态和属性:

// 对于 useDrag
}, [todos]);

// 对于 useDrop
}, [todos]);

六、总结

在本教程中,我们成功地在基础 TodoList 应用上集成了 React-DnD 库,实现了任务的拖拽排序功能。通过系统性的实现步骤,我们构建了一个用户体验良好的交互式待办事项应用:

  1. 项目结构优化:创建了专门的拖拽配置目录,使代码组织更加清晰
  2. React-DnD 基础配置:通过 DnDProvider 和 HTML5Backend 配置了拖拽环境
  3. 状态管理扩展:在 TodoContext 中实现了基于 Reducer 的拖拽排序逻辑
  4. 组件功能增强:将 TodoItem 组件改造为同时支持拖拽源和放置目标的复合组件
  5. 用户体验优化:添加了拖拽过程中的视觉反馈样式,提升交互体验

Google Map、Solar Api

2025年11月17日 11:32

基于 React 和 @react-google-maps/api 库渲染 Google Map 地图,提供地址搜索补全、根据详细地址 / placeId 获取地址的详细信息、点击地图获取经纬度等功能。支持地图类型选择、地图多语言、多项操作控件、快捷键操作等配置。

安装库

npm i @react-google-maps/api
npm i -D @types/google.maps

加载地图资源库

import { useLoadScript } from "@react-google-maps/api";

export default function GoogleMapComponent() {
  const { isLoaded } = useLoadScript({
    // google map key
    googleMapsApiKey: '',
    // 需要额外加载其他的资源库
    libraries: [],
    // 地图语言环境
    language: "en",
  });
}

渲染地图组件

options全参数文档: 因Google文档是原生的,我们使用的组件是基于 React 封装过后的,所以使用上可能会有部分差异。

地图控件文档: developers.google.com/maps/docume…

import { GoogleMap, useLoadScript } from "@react-google-maps/api";
import { Skeleton } from "antd";
import { useCallback, useState } from "react";
// 需要自行到官网去申请
const apiKey = "AAAAAAAAAAAAAAAAA";
export default function GoogleMapComponent() {
  const [defaultCenter] = useState({
    lat: 37.44491,
    lng: -122.13925,
  });
  const { isLoaded } = useLoadScript({
    googleMapsApiKey: apiKey,
    libraries: [],
    language: "en",
  });

  // 地图实例
  const [mapInstance, setMapInstance] = useState<google.maps.Map | null>(null);
  const onLoad = useCallback((map: google.maps.Map) => {
    setMapInstance(map);
  }, []);

  return isLoaded ? (
    <GoogleMap
      mapContainerStyle={{
        width: "100%",
        height: "100%",
        borderRadius: "0",
      }}
      // 地图初始化时的中心点
      center={defaultCenter}
      zoom={18}
      // 地图渲染完成能拿到map实例
      onLoad={onLoad}
      options={{
        maxZoom: 22,
        mapTypeId: "hybrid", // 地图类型
        tilt: 0, // 倾斜角度
        disableDefaultUI: true, // 禁用所有控件UI, 优先级低于设置指定的控件
        fullscreenControl: true, // 全屏按钮
        mapTypeControl: true, // 地图类型切换按钮
        streetViewControl: true, // 3d道路漫游
        cameraControl: true, // 移动缩放
        rotateControl: true, // 旋转倾斜
        scaleControl: true, // 比例尺
        gestureHandling: "greedy", // 手势处理方式
        keyboardShortcuts: true, // 键盘快捷键操作
        disableDoubleClickZoom: true, // 禁用双击缩放
        zoomControl: true, // 缩放按钮
        // 配置地图展示的边界
        restriction: {
          // 限制地图的边界, 东南西北四个边界坐标
          latLngBounds: {
            north: 49.345786,
            east: -122.631844,
            south: 24.396308,
            west: 113.569345,
          },
          // 是否严格限制在边界内, 默认true
          strictBounds: false,
        },
      }}
    />
  ) : (
    <Skeleton active paragraph={{ rows: 4 }} />
  );
}
  • mapTypeId : 地图类型

    • roadmap 显示默认的道路地图视图。这是默认地图类型
    • terrain 根据地形信息显示自然地图(地形图), 略去了建筑物
    • satellite 显示 Google 地球卫星图像
    • hybrid 混合显示普通视图和卫星视图,展示道路、建筑物名称
  • gestureHandling : 手势操作

    • auto : (默认)手势处理是greedy还是cooperative,取决于网页是否可滚动或是否位于 iframe 中。

    • greedy : 允许所有手势,所有触摸手势和滚动事件都会平移或缩放地图。

    • cooperative : 允许部分手势。

      • 页面有滚动条时,滚动事件和单指触摸手势会滚动网页,需要按住 CtrlCommand 键才可缩放,平移地图。
      • 双指触摸手势可平移和缩放地图,双指滑动会滚动网页页面。
    • none : 禁用所有手势,用户无法通过手势平移或缩放地图,包括双击鼠标左右键的缩放事件。若要完全停用平移和缩放地图的功能,必须添加以下两个选项:gestureHandling: nonezoomContro: false,因为 zoomContro 的控件任可控制地图。

  • mapInstance : 地图实例。可以动态 获取/设置 当前 GoogleMap 组件的各项属性参数,移动到指定位置等。

Marker标记点

需要在 libraries 加载 marker 库。

文档地址:developers.google.com/maps/docume…

const { isLoaded } = useLoadScript({
  googleMapsApiKey: '',
  libraries: ["marker"],
  language: "en",
});

添加标记点前,需等待 GoogleMap 已经加载好,拿到 mapInstance 实例。

配置项参数

参数名 类型 描述
animation google.maps.Animation 将标记添加到地图时播放哪个动画
clickable boolean 点击
draggable boolean 拖动
icon stringgoogle.maps.Icongoogle.maps.Symbol 图标svg / image可以传地址url
title string 鼠标hover时提示文字
map google.maps.Map 地图实例
opacity number (0 - 1) 透明度
optimization boolean 优化。通过将多个标记渲染为单个标记来提高性能。
visible boolean 是否可见
zIndex number 图层

Marker类

new google.maps.Marker 此方法截止 2024年2月21 日起弃用,后续不做维护,但任可以继续正常使用。

const [markerInstance, setMarkerInstance] = useState<google.maps.Marker | null>(null);

function createMarker(map?: google.maps.Map) {
    if (!map || markerInstance) return;
    const marker = new google.maps.Marker({
      // 传入map实例(渲染在哪个地图上)可以不传,通过 marker.setMap(map)设置;
      map,
      // 定位
      position: { lat: 37.44491, lng: -122.13925 },
      // 拖动
      draggable: false,
    });
    setMarkerInstance(marker);
}

修改 marker 的位置

// 获取地图中心点经纬度
const center = mapInstance.getCenter();
// 接收参数类型: google.maps.LatLng
markerInstance.setPosition(center);

移除 marker

markerInstance.setMap(null);

监听点击事件

// Add a click listener for each marker, and set up the info window.
const listener = markerInstance.addListener('click', ({ domEvent, latLng }) => {
    const { target } = domEvent;
});

移除点击事件

// Remove the listener.
google.maps.event.removeListener(listener);

AdvancedMarkerElement - 高级标记

new google.maps.marker.AdvancedMarkerElement 相对性能更好,后续开始维护这个新库。

需要设置地图 MapId 开启功能。高级标记需要地图 ID,如果缺少地图 ID,则无法加载高级标记。

文档地址:developers.google.com/maps/docume…

const { isLoaded } = useLoadScript({
  googleMapsApiKey: '',
  libraries: ["marker"],
  language: "en",
  mapIds: ["DEMO_MAP_ID", "CLOUD_BASED_MAP_ID"],
});


// 渲染地图组件时。这里需要配置mapId与useLoadScript里面的对应
<GoogleMap  options={{ mapId: 'CLOUD_BASED_MAP_ID' }} />
  • CLOUD_BASED_MAP_ID : Google 提供的云基础 Map ID,具有一些基础功能;

  • DEMO_MAP_ID : 为了演示提供的;

  • 自定义 Map ID : 需要在 Google Cloud Console 中创建;

PinElement

文档地址: developers.google.com/maps/docume…

PinElement 是一个通过配置项渲染出的DOM 元素,通常搭配AdvancedMarkerElement 一起使用。可以自定义形状,样式配置,传入 SVG ICON DOM 等。

创建AdvancedMarkerElement标记

const [advancedMarker, setAdvancedMarker] = useState<google.maps.marker.AdvancedMarkerElement | null>(null);
function createAdvancedMarker(map?: google.maps.Map) {
    if (!map) return;
    const latLng = {
      lat: 37.44496,
      lng: -122.1393,
    };
    const pinElement = new google.maps.marker.PinElement({
      scale: 1.2,
      background: "#FF5722",
      borderColor: "#FFFFFF",
      glyph: "🏠",
      glyphColor: "#FFFFFF",
    });
    const buildingMarker = new google.maps.marker.AdvancedMarkerElement({
      map,
      position: {
        lat: latLng.lat,
        lng: latLng.lng,
      },
      // 鼠标hover时的提示文字
      title: `建筑物中心 lat: ${latLng.lat}, lng: ${latLng.lng}`,
      // 渲染的元素,可以使用PinElement,也可以使用 document.createElement('div') 传递一个dom
      content: pinElement.element,
    });
    setAdvancedMarker(buildingMarker);
}

修改marker位置

if (advancedMarker && mapInstance) {
  // 获取地图中心点经纬度
  const center = mapInstance.getCenter();
  // 接收参数类型: google.maps.LatLng
  advancedMarker.position = center;
}

清除marker

advancedMarker && (advancedMarker.map = null);

Marker组件

用法同上, 改成了标签形式渲染。

import { GoogleMap, Marker } from "@react-google-maps/api";


<GoogleMap>
  {/* 
    可以在这里使数组map渲染多个。
    Marker标签不能直接传递dom渲染,可以使用new marker在这里map
    */}
    <Marker
        position={defaultCenter}
        title={`lat: ${defaultCenter.lat}, lng: ${defaultCenter.lng}`}
        icon={{
          path: google.maps.SymbolPath.CIRCLE,
          scale: 10,
          fillColor: "#FF5722",
          fillOpacity: 1,
          strokeColor: "#FF5722",
        }}
      />
</GoogleMap>

信息窗口

文档地址:developers.google.com/maps/docume…

const [infoWindow, setInfoWindow] = useState<google.maps.InfoWindow | null>(null);
// 地图加载完成回调
const onLoad = useCallback((map: google.maps.Map) => {
  // 创建 InfoWindow
  const infoWindowInstance = new google.maps.InfoWindow();
  setInfoWindow(infoWindowInstance);
}, []);

窗口内容:可以传递模版字符串、DOM节点

const content = `
  <div style="padding: 10px; min-width: 200px;">
    <h4 style="margin: 0 0 10px 0;">位置信息</h4>
    <p style="margin: 5px 0;"><strong>纬度:</strong> ${lat.toFixed(6)}</p>
    <p style="margin: 5px 0;"><strong>经度:</strong> ${lng.toFixed(6)}</p>
    <button 
      id="switch-building-btn"
      style="
        background: #1890ff; 
        color: white; 
        border: none; 
        padding: 8px 16px; 
        border-radius: 4px; 
        cursor: pointer;
        margin-top: 10px;
        width: 100%;
      "
    >
      更换建筑物
    </button>
  </div>
`;
// 设置内容
infoWindow.setContent(content);

定位

// 接收参数类型为 google.maps.LatLng
// 可以通过new google.maps.LatLng(location.lat, location.lng)转换
infoWindow.setPosition({
    lat: () => number,
    lng: () => number
});

框口显示、关闭

// 显示
infoWindow.open(mapInstance);
// 关闭窗口
infoWindow.close();

点击地图,获取点击位置的经纬度,触发自定义按钮事件

const switchBuilding = (lat: number, lng: number) => {
    console.log(lat, lng);
};
const handleMapClick = (event: google.maps.MapMouseEvent) => {
    if (!event.latLng || !infoWindow) return;
    const lat = event.latLng.lat();
    const lng = event.latLng.lng();
    
    // 创建 InfoWindow 内容
    const content = `
        <div style="padding: 10px; min-width: 200px;">
          <h4 style="margin: 0 0 10px 0;">位置信息</h4>
          <p style="margin: 5px 0;"><strong>纬度:</strong> ${lat.toFixed(6)}</p>
          <p style="margin: 5px 0;"><strong>经度:</strong> ${lng.toFixed(6)}</p>
          <button 
            id="switch-building-btn"
            style="
              background: #1890ff; 
              color: white; 
              border: none; 
              padding: 8px 16px; 
              border-radius: 4px; 
              cursor: pointer;
              margin-top: 10px;
              width: 100%;
            "
          >
            更换建筑物
          </button>
        </div>
      `;
    
    infoWindow.setContent(content);
    infoWindow.setPosition(event.latLng);
    infoWindow.open(mapInstance);
    
    // 添加事件监听器
    google.maps.event.addListenerOnce(infoWindow, 'domready', () => {
      const button = document.getElementById('switch-building-btn');
      if (button) {
        button.addEventListener('click', () => {
          switchBuilding(lat, lng);
        });
      }
    });
};
<GoogleMap
    ...props
    onClick={handleMapClick}
/>

模版字符串内,也可以直接使用

`onclick="${switchBuilding(lat, lng)}"`

但是这样会需要 switchBuilding 挂到全局作用域中,而且会立即执行一次。

地址搜索

提供地址补全、附近搜索、指定类型建筑物搜索、地点详情等功能;

文档:developers.google.com/maps/docume…

需要在 libraries 加载 places

const { isLoaded } = useLoadScript({
  googleMapsApiKey: '',
  libraries: ["places"],
  language: "en",
});

地址数据字段文档:developers.google.com/maps/docume…

地点类型文档:developers.google.com/maps/docume…

PlaceId 文档地址: developers.google.com/maps/docume…

初始化 Place 类实例;

// 代码补全
const searchServiceRef = useRef<google.maps.places.AutocompleteService | null>(null);
// geocoder地址转换
const geocoderServiceRef = useRef<google.maps.places.PlacesService | null>(null);

const onLoad = (map: google.maps.Map) => {
    if (google.maps.places) {
      searchServiceRef.current = new google.maps.places.AutocompleteService();
      geocoderServiceRef.current = new google.maps.Geocoder();
    }
}


<GoogleMap onLoad={onLoad}></GoogleMap>

地址自动补全(根据输入文字,提供地点预测结果)

const [searchValue, setSearchValue] = useState('');
const [searchOptions, setSearchOptions] = useState<{ value: string; label: string }[]>([]);
const [isSearching, setIsSearching] = useState(false);
const handleSearch = async (value: string) => {
    setSearchValue(value);
    if (!value.trim() || !searchServiceRef.current) {
      setSearchOptions([]);
      return;
    }
    setIsSearching(true);
    try {
      searchServiceRef.current.getPlacePredictions({
        input: value,
        // 指定搜索地点类型,建筑物类型
        types: ['geocode', 'establishment'],
        language: 'zh-CN',
      }, (predictions, status) => {
        if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
          const options = predictions.map(prediction => ({
            value: prediction.place_id, // 地点id
            label: prediction.description, // 详细地址描述
          }));
          setSearchOptions(options);
        } else {
          setSearchOptions([]);
        }
      });
    } catch (error) {
      console.error('搜索地址时出错:', error);
      setSearchOptions([]);
    } finally {
      setIsSearching(false);
    }
};

地址自动补全(新接口)

文档地址: developers.google.com/maps/docume…

const token = new google.maps.places.AutocompleteSessionToken();
const { suggestions } =
await google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions(
  {
    sessionToken: token,
    input: value,
    language: "en-US",
  }
);
console.log(suggestions);

const options = suggestions.map((prediction) => ({
    value: prediction.placePrediction?.placeId || "",
    label: prediction.placePrediction?.text.toString() || "",
}));
setSearchOptions(options);

这里有一个会话的概念,会话令牌将用户自动补全搜索的查询和选择阶段归入不同的会话,以便进行结算费用。

通过调用 fetchFields 结束会话

let place = suggestions[0].placePrediction.toPlace(); // Get first predicted place.
await place.fetchFields({  fields: ["displayName", "formattedAddress"],});

placeId 获取地址信息

通过Place类, 根据placeId获取地址的部分简单信息

const place = new google.maps.places.Place({
  id: placeId,
  requestedLanguage: 'en'
});
await place.fetchFields({ fields: ['displayName', 'formattedAddress', 'location'] });
console.log(place.displayName);
console.log(place.formattedAddress);
console.log(place.location?.lat(), place.location?.lng());

geocode

通过 geocoderServiceRef.current?.geocode 方法,可以根据 placeIdaddress: 地址location: 经纬度获取所在位置的详细信息。这三个字段只能同时取一个。

通过上面的地址补全,拿到地址列表后,可以拿到 placeId,随后可以拿到经纬度,定位到指定区域

const handleSelectAddress = (placeId: string) => {
    geocoderServiceRef.current?.geocode({
      placeId,
      language: 'en',
    }, (results, status) => {
      if (status === google.maps.GeocoderStatus.OK && results?.[0]) {
        if (mapInstance) {
          const { lat, lng } = results[0].geometry.location;
          // 移动地图到新位置
          mapInstance.panTo({
            lat: lat(),
            lng: lng(),
          });
          mapInstance.setZoom(20); // 设置合适的缩放级别
        }
      }
    });
}

HTML部分

import { AutoComplete } from "antd";

<AutoComplete
    value={searchValue}
    options={searchOptions}
    onSearch={handleSearch}
    onSelect={(value) => handleSelectAddress(value)}
    style={{ width: "100%" }}
    notFoundContent={isSearching ? <Spin size="small" /> : null}
    allowClear
>
<Input.Search
  size="large"
  placeholder="搜索地址..."
  loading={isSearching}
  onSearch={handleSearch}
  style={{
    borderRadius: 8,
    boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
  }}
/>
</AutoComplete>

定位到指定区域后,会触发 GoogleMap 的 地图中心点改变时间,随后再通过经纬度,更加准确的获取到具体的地址信息。

const [loading, setLoading] = useState(false);
const handleCenterChanged = async () => {
    if (!geocoderServiceRef.current || !mapInstance) return;
    setLoading && setLoading(true);

    try {
      const center = mapInstance.getCenter(); 

      geocoderServiceRef.current.geocode(
        {
          location: center,
          language: "en"
        },
        (results, status) => {
          if (status === google.maps.GeocoderStatus.OK && results?.[0]) {
            console.log(results[0]);

            const {
              address_components,
              formatted_address,
              place_id,
              geometry,
            } = results[0] || {};
            const locationInfo = {
              ...location,
              // 地址
              address: formatted_address || "",
              // 地址id
              placeId: place_id || "",
              // 经纬度
              lat: geometry?.location?.lat(),
              lng: geometry?.location?.lng(),
              // 邮政编码
              postalCode: address_components?.find((component) =>
                component.types.includes("postal_code")
              )?.long_name,
              // 国家
              country: address_components?.find((component) =>
                component.types.includes("country")
              )?.long_name,
              // 国家简称
              countryShortName: address_components?.find((component) =>
                component.types.includes("country")
              )?.short_name,
              // 县
              county: address_components?.find((component) =>
                component.types.includes("administrative_area_level_2")
              )?.long_name,
              // 州
              state: address_components?.find((component) =>
                component.types.includes("administrative_area_level_1")
              )?.long_name,
              // 州简称
              stateShortName: address_components?.find((component) =>
                component.types.includes("administrative_area_level_1")
              )?.short_name,
              // 地区
              area: address_components?.find((component) =>
                component.types.includes("administrative_area_level_3")
              )?.long_name,
              // 城市
              city: address_components?.find((component) =>
                component.types.includes("locality")
              )?.long_name,
              // 街道号
              streetNumber: address_components?.find((component) =>
                component.types.includes("street_number")
              )?.long_name,
              // 街道名
              streetName: address_components?.find((component) =>
                component.types.includes("route")
              )?.long_name,
            };
            // 更新input搜索值
            setSearchValue(formatted_address || "");
          }
        }
      );
    } finally {
      setLoading && setLoading(false);
    }
  };
    
  
  <GoogleMap onCenterChanged={handleCenterChanged} />

基于 Google Solar Api 获取建筑物屋顶面积、年日照时长、基于Solar Api估算推荐的光伏板规格能够安装的最大太阳能光伏板数量、年发电量、安装成本、能源覆盖率等;通过 Layer 图层渲染建筑物周边各时段光照图;根据安装等光伏板数量推荐最合适的安装位置。

demo地址:solar-potential-kypkjw5jmq-uc.a.run.app/

Solar Api 文档:developers.google.com/maps/docume…

接口需要在后台开通apiKeySolar权限

建筑物太阳能发电潜力

buildingInsights可提供有关建筑物位置、尺寸和太阳能发电潜力的数据分析。具体而言,您可以获取以下方面的信息:

  • 太阳能发电潜力,包括太阳能电池板的大小、年日照量、碳抵消系数等
  • 太阳能板的位置、朝向和发电量
  • 最佳太阳能布局的估算月度电费以及相关费用和益处

Api

查找与查询点的中心点距离最近的建筑物。如果查询点周围约 50 米内没有建筑物,则返回代码为 NOT_FOUND 的错误。

参数
location 必需。API 用于查找最近已知建筑物的经纬度。
requiredQuality 可选。结果中允许的最低质量级别。系统不会返回质量低于此值的结果。如果未指定此参数,则相当于仅限于“高”质量。

Solar Api覆盖范围:developers.google.com/maps/docume…

requiredQuality - 枚举
IMAGERY_QUALITY_UNSPECIFIED 质量未知。
HIGH 太阳能数据来自于在低空拍摄的航拍图像,并以 0.1 米/像素的处理分辨率处理。
MEDIUM 太阳能数据来自高海拔拍摄的增强型航拍图像,处理分辨率为 0.25 米/像素。
BASE 太阳能数据派生自以 0.25 米/像素的像素间隔处理的增强型卫星图像。注意:只有在请求中设置了 experiments=EXPANDED_COVERAGE 时,此枚举才可用。
export async function findClosestBuilding(
  location: {lat: number, lng: number},
  apiKey: string,
): Promise<BuildingInsightsResponse> {
  const args = {
    'location.latitude': location.lat,
    'location.longitude': location.lng,
  };
  const params = new URLSearchParams({ ...args, key: apiKey, requiredQuality: 'MEDIUM' });
  // https://developers.google.com/maps/documentation/solar/reference/rest/v1/buildingInsights/findClosest
  return fetch(`https://solar.googleapis.com/v1/buildingInsights:findClosest?${params}`).then(
    async (response) => {
      const content = await response.json();
      if (response.status != 200) {
        console.error('findClosestBuilding\n', content);
        throw content;
      }
      // console.log('buildingInsightsResponse', content);
      return content;
    },
  );
}

ApiResponse响应数据

const buildObj = {
    "name": "buildings/ChIJh0CMPQW7j4ARLrRiVvmg6Vs", // 相应建筑物的资源名称,格式为 buildings/{place_id}。
    // 位于建筑物中心附近的一个点。
    "center": {
        "latitude": 37.4449439,
        "longitude": -122.13914659999998
    },
    // 建筑物影像的拍摄日期。底层图像的获取日期。这是一个近似值。
    "imageryDate": {
        "year": 2022,
        "month": 8,
        "day": 14
    },
    "postalCode": "94303", // 邮政编码(例如美国邮政编码)所涵盖的区域。
    "administrativeArea": "CA", // 包含此建筑物的行政区 1(例如,在美国,是指州)。例如,在美国,缩写可能为“MA”或“CA”。
    "statisticalArea": "06085511100", // 统计区域(例如美国人口普查区)。
    "regionCode": "US", // 相应建筑物所在国家/地区的区域代码。
    // 建筑物的太阳能发电潜力。
    "solarPotential": {
        "maxArrayPanelsCount": 1163, // 屋顶上可容纳的最大面板数量。
        "maxArrayAreaMeters2": 1903.5983, // 屋顶上可容纳的最大面板面积(以平方米为单位)。
        "maxSunshineHoursPerYear": 1802, // 屋顶上任意一点每年接收的日照小时数上限(1 太阳小时 = 每千瓦 1 千瓦时)
        "carbonOffsetFactorKgPerMwh": 428.9201, // 每兆瓦时电力产生的二氧化碳排放量(碳排放强度,以千克为单位)。
        "wholeRoofStats": { // 分配给某个屋顶细分的屋顶部分的总大小和日照百分位数。尽管名称如此,但这可能并不包括整个建筑物
            "areaMeters2": 2399.3958, // 屋顶的总面积(以平方米为单位),这是屋顶面积(考虑了倾斜度),而不是地面占地面积。。
            // 屋顶上任意一点每年接收的日照小时数百分位数。
            "sunshineQuantiles": [
                351,
                1396,
                1474,
                1527,
                1555,
                1596,
                1621,
                1640,
                1664,
                1759,
                1864
            ],
            "groundAreaMeters2": 2279.71 // 屋顶或屋顶片段覆盖的地面占地面积(以平方米为单位)。
        },
        "roofSegmentStats": [
            {
                "pitchDegrees": 11.350553, // 屋顶片段相对于理论地平面的角度。0 = 平行于地面,90 = 垂直于地面。
                // 屋顶片段所指的罗盘方向。0 = 北,90 = 东,180 = 南。对于“平坦”屋顶片段(pitchDegrees 非常接近 0),方位角未定义清楚,因此为保持一致,我们将其任意定义为 0(北)。
                "azimuthDegrees": 269.6291, // 屋顶片段相对于理论地平面的方位角。0 = 北,90 = 东,180 = 南,270 = 西。
                "stats": {
                    "areaMeters2": 452.00052,
                    "sunshineQuantiles": [
                        408,
                        1475,
                        1546,
                        1575,
                        1595,
                        1606,
                        1616,
                        1626,
                        1635,
                        1643,
                        1761
                    ],
                    "groundAreaMeters2": 443.16
                },
                "center": {
                    "latitude": 37.444972799999995,
                    "longitude": -122.13936369999999
                },
                // 相应建筑物的边界框。
                "boundingBox": {
                    "sw": {
                        "latitude": 37.444732099999996,
                        "longitude": -122.1394224
                    }, // 边界框的西南角。
                    "ne": {
                        "latitude": 37.4451909,
                        "longitude": -122.13929279999999
                    } // 边界框的东北角。
                },
                "planeHeightAtCenterMeters": 10.7835045 // 屋顶片段平面在 center 指定的点       处的高度(以米为单位,海拔高度)。与屋顶板的坡度、方位角和中心位置一起,这完全定义了屋顶板片平面。
            }
        ],
        // 屋顶太阳能面板配置。
        "solarPanelConfigs": [
            {
                "panelsCount": 4, // 屋顶上安装的面板数量。
                "yearlyEnergyDcKwh": 1819.8662, // 屋顶上安装的面板每年产生的能量(以千瓦时为单位)。
                // 此布局中至少带有 1 个面板的每个屋顶板块的生产信息。roofSegmentSummaries[i] 用于描述第 i 个屋顶片段,包括其大小、预期产量和方向。
                "roofSegmentSummaries": [
                    {
                        "pitchDegrees": 12.273684, // 倾斜角度
                        "azimuthDegrees": 179.12555, // 屋顶片段所指的罗盘方向
                        "panelsCount": 4, // 此片段安装的太阳能板数量
                        "yearlyEnergyDcKwh": 1819.8663, // 此片段安装的太阳能板每年产生的能量(以千瓦时为单位)。
                        "segmentIndex": 1 // 此片段在 roofSegmentStats 数组中的索引。
                    }
                ]
            }
        ],
        // 用于计算采用太阳能后可节省的电费,前提是每月电费和电力供应商已知。它们按每月账单金额从低到高排序。如果 Solar API 没有足够的信息来对位于相应区域的建筑物执行财务计算,则此字段将为空。
        "financialAnalyses": [
            {
                "monthlyBill": {
                    "currencyCode": "USD", // 货币代码。
                    "units": "20" // 假设的每月电费。
                },
                "panelConfigIndex": -1 // 此配置在 solarPanelConfigs 数组中的索引。-1,表示没有布局。在这种情况下,系统会省略其余子消息。
            },
            {
                "monthlyBill": {
                    "currencyCode": "USD",
                    "units": "25"
                },
                "panelConfigIndex": -1
            },
            {
                "monthlyBill": {
                    "currencyCode": "USD",
                    "units": "30"
                },
                "panelConfigIndex": -1
            },
            {
                "monthlyBill": {
                    "currencyCode": "USD",
                    "units": "35"
                },
                "panelConfigIndex": 0,
                "financialDetails": {
                    "initialAcKwhPerYear": 1546.8864, // 这个索引布局下安装的面板每年产生的能量(以千瓦时为单位)。
                    "remainingLifetimeUtilityBill": {
                        "currencyCode": "USD",
                        "units": "2563" // 太阳能板使用寿命内,非太阳能发电所产生的电费
                    },
                    "federalIncentive": {
                        "currencyCode": "USD",
                        "units": "1483" // 可获得的联邦补贴金额;如果用户购买(无论是否通过贷款)太阳能板,则适用此属性。以下补贴均是
                    },
                    "stateIncentive": {
                        "currencyCode": "USD" // 可从州级补贴中获得的金额;
                    },
                    "utilityIncentive": {
                        "currencyCode": "USD" // 可从公共事业补贴中获得的金额;
                    },
                    "lifetimeSrecTotal": {
                        "currencyCode": "USD" // 用户在太阳能板的使用寿命内可通过太阳能可再生能源抵扣获得的金额;
                    },
                    "costOfElectricityWithoutSolar": {
                        "currencyCode": "USD",
                        "units": "10362" // 如果用户未安装太阳能板,在整个生命周期内需要支付的电费总金额。
                    },
                    "netMeteringAllowed": true, // 如果为 true,则表示用户可以将其太阳能发电量与公共事业公司共享,并从公共事业公司获得电费。(是否允许净计量。)
                    "solarPercentage": 86.7469, // 用户由太阳能供电的百分比 (0-100)。适用于第一年,但对于未来几年,数据大致准确。
                    "percentageExportedToGrid": 52.136684 // 我们假设太阳能发电量中有百分之几(0-100)会输出到电网,该百分比基于第一季度的发电量。如果不允许净计量,这会影响计算结果。
                },
                // 租赁太阳能板的费用和好处。
                "leasingSavings": {
                    "leasesAllowed": true, // 此管辖区是否允许租赁(某些州不允许租赁)。如果此字段为 false,则应忽略此消息中的值。
                    "leasesSupported": true, // 财务计算引擎是否支持在此管辖区内使用租赁。这与 leasesAllowed 无关:在某些地区,允许租赁,但在财务模型无法处理的情况下。
                    // 预计的租赁年费用。
                    "annualLeasingCost": {
                        "currencyCode": "USD",
                        "units": "335", // 具体金额,整数位
                        "nanos": 85540771
                    },
                    // 在生命周期内节省(或未节省)多少钱。
                    "savings": {
                        // 第一年的节省金额。
                        "savingsYear1": {
                            "currencyCode": "USD",
                            "units": "-10"
                        },
                        // 前20年的节省金额。
                        "savingsYear20": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        // 前20年的节省金额的现值。
                        "presentValueOfSavingsYear20": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        },
                        // 是否财务可行。
                        "financiallyViable": true,
                        // 整个面板生命周期内节省的金额
                        "savingsLifetime": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        // 整个面板生命周期内节省的金额的现值。
                        "presentValueOfSavingsLifetime": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        }
                    }
                },
                // 现金购买的成本和效益
                "cashPurchaseSavings": {
                    // 税收优惠前的初始费用:必须自掏腰包支付的金额。与 upfrontCost(扣除税收优惠)相对。
                    "outOfPocketCost": {
                        "currencyCode": "USD",
                        "units": "5704"
                    },
                    // 扣除税收优惠后的初始费用:是指第一年必须支付的金额。与 outOfPocketCost(税收优惠之前)相对。
                    "upfrontCost": {
                        "currencyCode": "USD",
                        "units": "4221"
                    },
                    // 所有退税的价值。
                    "rebateValue": {
                        "currencyCode": "USD",
                        "units": "1483",
                        "nanos": 40039063
                    },
                    // 收回投资所需的年数。负值表示在生命周期内永远不会收回投资。
                    "paybackYears": 11.5,
                    "savings": {
                        "savingsYear1": {
                            "currencyCode": "USD",
                            "units": "325"
                        },
                        "savingsYear20": {
                            "currencyCode": "USD",
                            "units": "7799"
                        },
                        "presentValueOfSavingsYear20": {
                            "currencyCode": "USD",
                            "units": "1083",
                            "nanos": 500244141
                        },
                        "financiallyViable": true,
                        "savingsLifetime": {
                            "currencyCode": "USD",
                            "units": "7799"
                        },
                        "presentValueOfSavingsLifetime": {
                            "currencyCode": "USD",
                            "units": "1083",
                            "nanos": 500244141
                        }
                    }
                },
                // 分期的成本和效益
                "financedPurchaseSavings": {
                    // 贷款的年度还款额。
                    "annualLoanPayment": {
                        "currencyCode": "USD",
                        "units": "335",
                        "nanos": 85540771
                    },
                    // 所有税款返还金额(包括联邦投资税抵免 [ITC])。
                    "rebateValue": {
                        "currencyCode": "USD"
                    },
                    // 这组计算中假设的贷款利率。
                    "loanInterestRate": 0.05,
                    // 在生命周期内节省(或未节省)多少钱。
                    "savings": {
                        "savingsYear1": {
                            "currencyCode": "USD",
                            "units": "-10"
                        },
                        "savingsYear20": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        "presentValueOfSavingsYear20": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        },
                        "financiallyViable": true,
                        "savingsLifetime": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        "presentValueOfSavingsLifetime": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        }
                    }
                }
            }
        ],
        "panelCapacityWatts": 400, // 计算中使用的面板的容量(以瓦为单位)。
        "panelHeightMeters": 1.879, // 计算中使用的面板的高度(竖屏模式,以米为单位)。
        "panelWidthMeters": 1.045, // 计算中使用的面板的高度(纵向模式,以米为单位)。
        "panelLifetimeYears": 20, // 太阳能电池板的预期寿命(以年为单位)。此值用于财务计算。
        // 建筑物的统计信息。
        "buildingStats": {
            "areaMeters2": 2533.1233, // 屋顶面积
            "sunshineQuantiles": [
                348,
                1376,
                1460,
                1519,
                1550,
                1590,
                1618,
                1638,
                1662,
                1756,
                1864
            ],
            "groundAreaMeters2": 2356.03 // 屋顶占地面积
        },
        // 屋顶太阳能面板配置。
        "solarPanels": [
            {
                "center": {
                    "latitude": 37.4449659,
                    "longitude": -122.139089
                },
                // 面板的方向。
                /**
                 * 
                 * SOLAR_PANEL_ORIENTATION_UNSPECIFIED  面板方向未知。
                    LANDSCAPE   LANDSCAPE 面板的长边垂直于其所在屋顶片段的方位角方向。
                    PORTRAIT    PORTRAIT 面板的长边与其所在屋顶片段的方位角方向平行。
                 * 
                 */
                "orientation": "LANDSCAPE",
                // 此布局在一年内能捕获多少太阳能(以直流千瓦时为单位)。
                "yearlyEnergyDcKwh": 455.40714,
                "segmentIndex": 1
            }
        ],
        "imageryQuality": "HIGH", // 用于计算此建筑物数据的图像质量。
        "imageryProcessedDate": {
            "year": 2023,
            "month": 8,
            "day": 4
        } // 此图像的处理完成时间。
    }
}

建筑物边界

restriction: {
  latLngBounds: {
    north: 37.4452261, // boundingBox.ne.latitude
    east: -122.13873059999999, // boundingBox.ne.longitude
    south: 37.444723499999995, // boundingBox.sw.latitude
    west: -122.13943150000001, // boundingBox.sw.longitude
  },
  strictBounds: false,
},

屋顶光伏板铺设

本质上是在地图上绘制多边形,根据经纬度计算,将其摆放铺设在屋顶上。

如果 buildingInsights 响应不包含 solarPanelConfigs 字段,则表示建筑物已正确处理,但我们无法在屋顶上安装太阳能板。如果屋顶太小而无法放置太阳能板,或者阴影太多而导致太阳能板无法产生大量能量,则可能会发生这种情况。

需要在 libraries 加载 geometry库;

const { isLoaded } = useLoadScript({
    googleMapsApiKey: '',
    libraries: ["marker", "places", "geometry"],
    language: "en",
    mapIds: ["CLOUD_BASED_MAP_ID", "DEMO_MAP_ID"],
  });

颜色转换工具函数

文档:developers.google.com/maps/docume…

  • 指定颜色范围,生成颜色深浅范围值,用于光伏板铺设时,区分发电量
/**
 * Creates an {r, g, b} color palette from a hex list of colors.
 *
 * Each {r, g, b} value is a number between 0 and 255.
 * The created palette is always of size 256, regardless of the number of
 * hex colors passed in. Inbetween values are interpolated.
 *
 * @param  {string[]} hexColors  List of hex colors for the palette.
 * @return {{r, g, b}[]}         RGB values for the color palette.
 */
export function createPalette(hexColors: string[]): { r: number; g: number; b: number }[] {
  // Map each hex color into an RGB value.
  const rgb = hexColors.map(colorToRGB);
  // Create a palette with 256 colors derived from our rgb colors.
  const size = 256;
  const step = (rgb.length - 1) / (size - 1);
  return Array(size)
    .fill(0)
    .map((_, i) => {
      // Get the lower and upper indices for each color.
      const index = i * step;
      const lower = Math.floor(index);
      const upper = Math.ceil(index);
      // Interpolate between the colors to get the shades.
      return {
        r: lerp(rgb[lower].r, rgb[upper].r, index - lower),
        g: lerp(rgb[lower].g, rgb[upper].g, index - lower),
        b: lerp(rgb[lower].b, rgb[upper].b, index - lower),
      };
    });
}

/**
 * Convert a hex color into an {r, g, b} color.
 *
 * @param  {string} color  Hex color like 0099FF or #0099FF.
 * @return {{r, g, b}}     RGB values for that color.
 */
export function colorToRGB(color: string): { r: number; g: number; b: number } {
  const hex = color.startsWith('#') ? color.slice(1) : color;
  return {
    r: parseInt(hex.substring(0, 2), 16),
    g: parseInt(hex.substring(2, 4), 16),
    b: parseInt(hex.substring(4, 6), 16),
  };
}

/**
 * Normalizes a number to a given data range.
 *
 * @param  {number} x    Value of interest.
 * @param  {number} max  Maximum value in data range, defaults to 1.
 * @param  {number} min  Minimum value in data range, defaults to 0.
 * @return {number}      Normalized value.
 */
export function normalize(x: number, max: number = 1, min: number = 0): number {
  const y = (x - min) / (max - min);
  return clamp(y, 0, 1);
}

获取最推荐的光伏板配置所需变量(也可用于能量、费用计算)

  • buildingInsights : 当前建筑物信息
  • solarPanelConfigs :光伏板的所有排列配置情况
  • panelPaths:当前铺设的光伏板(多边形)配置
  • panelConfigIndex : 当前光伏板数量推荐的配置索引下标

以下变量可通过input输入随时修改。

  • monthlyAverageEnergyBill :月均电费
  • energyCostPerKwh :电费单价 (每千瓦时的能源成本)
  • panelCapacityWatts : kwh,光伏板容量 watts
  • dcToAcDerateInput :%,直流交流转换率(逆变器将太阳能板产生的直流电转换为家庭使用的交流电的效率)
  • installationCostPerWatt : 每瓦安装成本
// 建筑信息
const [buildingInsights, setBuildingInsights] = useState<BuildingInsightsResponse | null>(null);

// 光伏板配置
const solarPanelConfigs = useMemo(() => {
    return buildingInsights?.solarPotential?.solarPanelConfigs || [];
}, [buildingInsights]);

// 面板能量转化率
const panelCapacityRatio = useMemo(() => {
    return (
      panelCapacityWatts /
      (buildingInsights?.solarPotential?.panelCapacityWatts || 0)
    );
}, [panelCapacityWattsInput, buildingInsights]);

// 当前铺设的光伏板数量, 在建筑信息中光伏板配置的所属下标, 建议只通过配置下标来拿到能铺设的光伏板数量,不要直接设置光伏板数量
const [panelConfigIndex, setPanelConfigIndex] = useState<number | null>(null);

// 当前铺设的光伏板路径list
const [panelPaths, setPanelPaths] = useState<(google.maps.PolygonOptions)[]>([]);

// 月均电费
const [monthlyAverageEnergyBill, setMonthlyAverageEnergyBill] = useState<number>(300);

// 每千瓦时能源成本(电费单价)
const [energyCostPerKwh, setEnergyCostPerKwh] = useState<number>(0.31);

// 光伏板容量 watts
const [panelCapacityWatts, setPanelCapacityWatts] = useState<number>(250);

// 直流交流转换率(逆变器将太阳能板产生的直流电转换为家庭使用的交流电的效率)
const [dcToAcDerateInput, setDcToAcDerateInput] = useState<number>(85);

获取推荐配置

根据上方的配置变量的值,初始化时,获取最推荐安装的光伏板配置的下标;

  • 实际年发电消耗:(月均电费 / 电费单价)* 12
  • 当前光伏板配置的消耗:年发电量 * (当前光伏板容量 / 推荐的默认光伏板容量)* (逆变器转换率 / 100)
  • 比较两个值,从所有配置中,遍历出最接近的一项,作为推荐配置。
const findSolarConfigIndex = (data?: BuildingInsightsResponse): number => {
    // 只在panelConfigIndex为null时(初始化)才设置
    if (panelConfigIndex !== null) return panelConfigIndex;
    const res = data || buildingInsights;
    if (!res) return -1;
    
    // 直接计算年能源消耗量,不依赖solar
    const yearlyKwhEnergyConsumption =
      (monthlyAverageEnergyBill / energyCostPerKwh) * 12;
    // 默认光伏板容量
    const defaultPanelCapacity = res.solarPotential.panelCapacityWatts;
    // 找到推荐铺设的光伏板配置的所属下标
    const i = res.solarPotential.solarPanelConfigs.findIndex(
      (config) =>
        config.yearlyEnergyDcKwh *
          (panelCapacityWatts / defaultPanelCapacity) *
          (dcToAcDerateInput / 100) >=
        yearlyKwhEnergyConsumption
    );
    setPanelConfigIndex(i > -1 ? i : 0);
    return i > -1 ? i : 0;
};

获取当前配置下,光伏板的排列路径、配置

  • 遍历所有的光伏板配置;
  • 根据每块板子的 长、宽,粗略得到绘制这块板子所需要的四个点,画成一个矩形;
  • 遍历当前板子的坐标点,根据光伏板中心点经纬度坐标、排列方向、倾斜角度,得到当前板子在地图上的经纬度路径 path
  • 根据光伏板的年发电量,得到板子的颜色配置下标;
function getPanelPaths(data?: BuildingInsightsResponse, index?: number) {
    const building = data || buildingInsights;
    const configIndex = index || panelConfigIndex;
    if (!building || configIndex === null) {
      setPanelPaths([]);
      return;
    }
    
    // 建筑物太阳能发电潜力
    const solarPotential = building.solarPotential;
    // 根据颜色值范围,生成范围内的颜色深浅值
    const palette = createPalette(['E8EAF6', '1A237E']).map(rgbToColor);
    // 最小年发电量
    const minEnergy = solarPotential.solarPanels.slice(-1)[0].yearlyEnergyDcKwh;
    // 最大年发电量
    const maxEnergy = solarPotential.solarPanels[0].yearlyEnergyDcKwh;
    
    // 获取光伏板路径
    try {
      const roofPanels = solarPotential.solarPanels.map((panel) => {
    
        // 光伏板宽高配置, 单位为米
        const [w, h] = [
          solarPotential.panelWidthMeters / 2,
          solarPotential.panelHeightMeters / 2,
        ];
        const points = [
          { x: +w, y: +h }, { x: +w, y: -h }, { x: -w, y: -h }, 
          { x: -w, y: +h }, { x: +w, y: +h }
        ];
        // 光伏板方向
        const orientation = panel.orientation == "PORTRAIT" ? 90 : 0;
        // 光伏板倾斜角度(相对于地面)
        const azimuth = solarPotential.roofSegmentStats[panel.segmentIndex].azimuthDegrees;
        // 根据年发电量,计算光伏板颜色索引
        const colorIndex = Math.round(
          normalize(panel.yearlyEnergyDcKwh, maxEnergy, minEnergy) * 255
        );
        return {
          paths: points.map(({ x, y }) =>
            google.maps.geometry.spherical.computeOffset(
              { lat: panel.center.latitude, lng: panel.center.longitude },
              Math.sqrt(x * x + y * y),
              Math.atan2(y, x) * (180 / Math.PI) + orientation + azimuth
            )
          ),
          // 边框颜色
          strokeColor: "#B0BEC5",
          strokeOpacity: 0.9,
          strokeWeight: 1,
          // 填充颜色
          fillColor: palette[colorIndex],
          fillOpacity: 0.9,
        };
      });
      console.log(roofPanels);
      setPanelPaths(roofPanels);
    } catch (error) {
      console.error(error);
      setPanelPaths([]);
    }
}

实际渲染

import { Polygon } from "@react-google-maps/api";
  • 调用 buildingInsights:findClosest 接口,获取建筑物信息,获取推荐配置,得到当前建筑物的光伏板铺设路径配置;
async function getBuildingInsights({ lat, lng }: { lat: number, lng: number }) {
    if (!lat || !lng) return;
    const buildingInsights = await findClosestBuilding({ lat, lng }, apiKey);
    setBuildingInsights(buildingInsights);
    const configIndex = findSolarConfigIndex(buildingInsights);
    getPanelPaths(buildingInsights, configIndex);
}
  • 使用 Polygon 组件渲染
import { useMemo } from "react";

return <GoogleMap>
    (panelPaths || []).map((item, index) => {
    let roofPanelCount = panelConfigIndex !== null && panelConfigIndex >= 0 ? solarPanelConfigs[panelConfigIndex].panelsCount : 0;
      if (index < roofPanelCount) {
        return (
          <Polygon
            key={index + "roof"}
            options={{
              ...item,
              clickable: false,
              visible: true,
            }}
            paths={item.paths || []}
          />
        );
      }
  })
</GoogleMap>

动态修改光伏板数量

  • 最少的光伏板数量: solarPanelConfigs[0].panelsCount

  • 最大的光伏板数量: solarPanelConfigs[solarPanelConfigs.length - 1].panelsCount

    • 所有的板子配置: buildingInsights.solarPotential?.solarPanels?.length
  • 最大光伏板配置下标: solarPanelConfigs.length - 1

import { Slider } from "antd";


// 光伏板配置
const solarPanelConfigs = useMemo(() => {
    return buildingInsights?.solarPotential?.solarPanelConfigs || [];
}, [buildingInsights]);



{solarPanelConfigs.length > 0 && (
    <div>
      <div>
        <div>
          当前面板数量:{" "}
          {panelConfigIndex || panelConfigIndex === 0
            ? solarPanelConfigs[panelConfigIndex].panelsCount
            : "--"}
        </div>
        <div>
          最大面板数量:{" "}
          {solarPanelConfigs[solarPanelConfigs.length - 1].panelsCount}
        </div>
      </div>
      <Slider
        defaultValue={panelConfigIndex ?? 0}
        value={panelConfigIndex ?? 0}
        step={1}
        max={solarPanelConfigs.length - 1}
        min={0}
        tooltip={{
          open: false,
        }}
        onChange={(value) => {
          setPanelConfigIndex(Number(value || 0));
        }}
      />
    </div>
  )}

常用能源数据,用于能耗、成本费用计算

  • 建筑物的太阳能发电潜力

    • const solarPotential = buildingInsights.solarPotential;
      

年日照时长

  • h - 小时
const maxSunshineHoursPerYear = buildingInsights.solarPotential?.maxSunshineHoursPerYear.toFixed(2)

屋顶面积

  • m² - 平方米
const roofArea = buildingInsights.solarPotential?.wholeRoofStats?.areaMeters2.toFixed(2)

CO₂ 减排量

  • Kg/MWh
const co2 = buildingInsights.solarPotential?.carbonOffsetFactorKgPerMwh.toFixed(2)

年发电量 - 单位:kwh

  • 最小值

    • const minEnergy = solarPotential.solarPanels.slice(-1)[0].yearlyEnergyDcKwh;
      
  • 最大值

    • const maxEnergy = solarPotential.solarPanels[0].yearlyEnergyDcKwh;
      
  • 当前数量的板子年发电量

    • const currEnergy = solarPanelConfigs[panelConfigIndex]?.yearlyEnergyDcKwh ?? 0) * panelCapacityRatio
      

安装尺寸容量

  • Kw - 千瓦
  • 计算公式:(光伏板数量 * 面板容量) / 1000
// 太阳能板安装容量 (kW)
 const installationSizeKw = useMemo(() => {
    let roofCapacity = 0;
    if (
      solarPanelConfigs &&
      solarPanelConfigs.length > 0 &&
      panelConfigIndex !== null &&
      panelConfigIndex >= 0
    ) {
      roofCapacity = (solarPanelConfigs[configId].panelsCount * panelCapacityWatts) / 1000;
    }
    return roofCapacity;
}, [panelConfigIndex, panelCapacityWatts, solarPanelConfigs]);

安装成本

  • 计算公式: 每瓦安装成本 * 安装尺寸容量 * 1000
const installationCostTotal = useMemo(() => {
    return installationCostPerWatt * installationSizeKw * 1000;
}, [installationCostPerWatt, installationSizeKw]);

以下计算需要新增一些变量,通过input输入可修改

文档地址:developers.google.com/maps/docume…

  • solarIncentives :太阳能激励措施
  • installationLifeSpan :太阳能光伏板使用寿命 - 年
  • efficiencyDepreciationFactor :太阳能板每年的效率下降幅度。 针对美国境内的地理位置使用 0.995(每年减少 0.5%)。
  • costIncreaseFactor :每年成本增加百分比。针对美国境内的位置使用 1.022(每年上调 2.2%)
  • discountRate :货币每年增值率。针对美国境内的位置使用 1.04(每年增长 4%)

能源覆盖率

  • % - 百分比

  • 计算公式:

    • 初始年交流发电量 = 当前面板配置年发电量 * 面板转换比 * 逆变器转换比
    • 每年交流电发电量(考虑效率衰减) = 初始年交流发电量 * 每年效率下降百分比 * 第几年
    • 年能源消耗 = (月均电费 / 光伏板瓦数) * 12
    • 能源覆盖率 = (第一年交流发电量 / 年能源消耗) * 100
// 初始年交流发电量 (kWh/年)
 const initialAcKwhPerYear = useMemo(() => {
    if (
      solarPanelConfigs &&
      solarPanelConfigs.length > 0 &&
      panelConfigIndex !== null &&
      panelConfigIndex >= 0
    ) {
      return (
        solarPanelConfigs[panelConfigIndex].yearlyEnergyDcKwh *
        panelCapacityRatio *
        (dcToAcDerateInput / 100)
      );
    }
    return 0;
}, [dcToAcDerateInput, panelCapacityRatio, configId, solarPanelConfigs]);

// 每年交流发电量数组 (考虑效率衰减)
const yearlyProductionAcKwh = useMemo(() => {
    return [...Array(installationLifeSpan).keys()].map(
      (year) => initialAcKwhPerYear * efficiencyDepreciationFactor ** year
    );
}, [initialAcKwhPerYear, installationLifeSpan, efficiencyDepreciationFactor]);
  
// 年能源消耗量 (kWh/年)
const yearlyKwhEnergyConsumption = useMemo(() => {
    return (monthlyAverageEnergyBill / energyCostPerKwh) * 12;
}, [monthlyAverageEnergyBill, energyCostPerKwh]);
  
// 能源覆盖率 (%)
const energyCovered = useMemo(() => {
    return (yearlyProductionAcKwh[0] / yearlyKwhEnergyConsumption) * 100;
  }, [
    yearlyProductionAcKwh,
    yearlyKwhEnergyConsumption,
    monthlyAverageEnergyBill,
]);

不使用太阳能的成本

  • 计算公式:太阳能光伏板使用周期内,每年的电费成本相加加
// 每年不使用太阳能的电费成本数组
const yearlyCostWithoutSolar = useMemo(
    () =>
      [...Array(installationLifeSpan).keys()].map(
        (year) =>
          (monthlyAverageEnergyBill * 12 * costIncreaseFactor ** year) /
          discountRate ** year
      ),
    [
      monthlyAverageEnergyBill,
      costIncreaseFactor,
      discountRate,
      installationLifeSpan,
    ]
);

  // 总的不使用太阳能的电费成本
  const totalCostWithoutSolar = useMemo(() => {
    return yearlyCostWithoutSolar.reduce((x, y) => x + y, 0);
  }, [yearlyCostWithoutSolar]);

使用太阳能的成本

  • 计算公式

    • 太阳能板安装总成本 = 每瓦安装成本 * 太阳能板安装容量 * 1000
    • 使用太阳能总成本 = 太阳能板安装总成本 + 剩余寿命期间的电费账单 - 太阳能激励措施奖励
// 太阳能板安装总成本 (美元)
const installationCostTotal = useMemo(() => {
    return installationCostPerWatt * installationSizeKw * 1000;
}, [installationCostPerWatt, installationSizeKw]);

// 剩余寿命期间的电费账单 (考虑太阳能发电后的净成本)
  const remainingLifetimeUtilityBill = useMemo(() => {
    return yearlyProductionAcKwh
      .map((yearlyKwhEnergyProduced, year) => {
        const billEnergyKwh =
          yearlyKwhEnergyConsumption - yearlyKwhEnergyProduced;
        const billEstimate =
          (billEnergyKwh * energyCostPerKwh * costIncreaseFactor ** year) /
          discountRate ** year;
        return Math.max(billEstimate, 0);
      })
      .reduce((x, y) => x + y, 0);
  }, [
    energyCostPerKwh,
    costIncreaseFactor,
    discountRate,
    yearlyKwhEnergyConsumption,
    yearlyProductionAcKwh,
  ]);

  // 使用太阳能的终身总成本 (美元)
  const totalCostWithSolar = useMemo(() => {
    return (
      installationCostTotal + remainingLifetimeUtilityBill - solarIncentives
    );
  }, [installationCostTotal, remainingLifetimeUtilityBill, solarIncentives]);

使用太阳能板,回本的年份

// 每年电费估算数组 (使用太阳能后)
const yearlyUtilityBillEstimates = useMemo(() => {
    return yearlyProductionAcKwh.map((yearlyKwhEnergyProduced, year) => {
      const billEnergyKwh =
        yearlyKwhEnergyConsumption - yearlyKwhEnergyProduced;
      const billEstimate =
        (billEnergyKwh * energyCostPerKwhInput * costIncreaseFactor ** year) /
        discountRate ** year;
      return Math.max(billEstimate, 0);
    });
  }, [
    yearlyKwhEnergyConsumption,
    energyCostPerKwhInput,
    costIncreaseFactor,
    discountRate,
    yearlyProductionAcKwh,
]);

  // 回本年份 (从安装开始计算)
const breakEvenYear = useMemo(() => {
    let costWithSolar = 0;
    const cumulativeCostsWithSolar = yearlyUtilityBillEstimates.map(
      (billEstimate, i) =>
        (costWithSolar +=
          i == 0
            ? billEstimate + installationCostTotal - solarIncentives
            : billEstimate)
    );
    let costWithoutSolar = 0;
    const cumulativeCostsWithoutSolar = yearlyCostWithoutSolar.map(
      (cost) => (costWithoutSolar += cost)
    );
    return cumulativeCostsWithSolar.findIndex(
      (costWithSolar, i) => costWithSolar <= cumulativeCostsWithoutSolar[i]
    );
  }, [
    yearlyUtilityBillEstimates,
    installationCostTotal,
    solarIncentives,
    yearlyCostWithoutSolar,
]);

Data Layers

dataLayers 端点可提供指定地点周围区域的详细太阳能信息。该端点会返回 17 个可下载的 TIFF 文件,包括:

  • 数字地表模型 (DSM)
  • RGB 复合图层(航空影像)
  • 用于标识分析边界的掩码图层
  • 年太阳辐射量,或给定表面的年产量
  • 每月太阳辐射量,或给定表面的每月产量
  • 每小时遮阳度(24 小时)

Api

curl -X GET "https://solar.googleapis.com/v1/dataLayers:get?location.latitude=37.4450&location.longitude=-122.1390&radiusMeters=100&view=FULL_LAYERS&requiredQuality=HIGH&exactQualityRequired=true&pixelSizeMeters=0.5&key=YOUR_API_KEY"
参数 类型 描述
location LatLng 必需。获取数据的地区中心的经纬度
radiusMeters number 必需。半径(以米为单位),用于定义应返回数据的中心点周围的区域。此值存在以下限制:- 您可以随时指定不超过 100 米的任何值。
  • 可以指定超过 1 亿的值,前提是 radiusMeters <= pixelSizeMeters * 1000

  • 对于大于 175 米的值,请求中的 DataLayerView 不得包含月度通量或每小时阴影。 | | view | DataLayerView | 可选。要返回的太阳能信息的子集类型。FULL_LAYERS 全部返回。 | | requiredQuality | ImageryQuality | 可选。结果中允许的最低质量级别。不指定,默认为 HIGH 高质量。 | | pixelSizeMeters | number | 可选。要返回的数据的最小比例(以每像素米为单位)。支持0.1、0.25、0.5、1.0 | | exactQualityRequired | boolean | 可选。是否要求图像质量完全一致。若指定 requiredQualityMEDIUM 中等,但是能查到 HIGH 质量,会优先返回HIGH 质量,指定了 exactQualityRequired 则只会返回 MEDIUM 。 |

ApiResponse响应数据

详细文档:

developers.google.com/maps/docume…

注意: 响应数据中的网址仅在初始请求后一小时内有效。如需访问超出此时间范围的网址,您必须再次向 dataLayers 端点发送请求。

地图叠加层

文档:developers.google.com/maps/docume…

使用 GroundOverlay 对象,可以在地图上叠加覆盖任意层内容,只需要指定边界范围、覆盖内容即可。

叠加 GeoJsondevelopers.google.com/maps/docume…

搭配 Layers Api 叠加日照、阴影面积等图层;

// https://developers.google.com/maps/documentation/solar/reference/rest/v1/dataLayers
type LayerId = 'mask' | 'dsm' | 'rgb' | 'annualFlux' | 'monthlyFlux' | 'hourlyShade';
  
// 图层类型
const layerOption: { label: string; value: LayerId | "none" }[] = [
  { label: "无图层", value: "none" },
  { label: "屋顶遮罩", value: "mask" },
  { label: "数字表面模型", value: "dsm" },
  { label: "航空图像", value: "rgb" },
  { label: "年日照", value: "annualFlux" },
  { label: "月日照", value: "monthlyFlux" },
  { label: "小时阴影", value: "hourlyShade" },
];

// 月份名称
const monthNames = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

// 当前图层
const [layerId, setLayerId] = useState<LayerId | "none">("none");
// 当前图层实例信息
const [layerInstance, setLayerInstance] = useState<any>(null);
// layer Api Data
const [layerData, setLayerData] = useState<any>(null);
// GroundOverlay对象(盖在图层上的数据)
const [overlays, setOverlays] = useState<google.maps.GroundOverlay[] | null>(null);

const [month, setMonth] = useState(0);
const [day, setDay] = useState(14);
const [hour, setHour] = useState(0);

// 是否展示遮罩层
const [showRoof, setShowRoof] = useState(true);

请求接口

需要先拿到一个地点的 经纬度,这里我使用 buildingInsights:findClosest 接口拿到建筑物信息,能够获取到建筑物的 中心点,建筑物边界信息,随后调取 Layer Api 拿到 TIFF 文件。

async function getLayerData(
_layerId: LayerId | "none",
buildData: BuildingInsightsResponse,
) {
    // 中心点经纬度    
    const center = buildData.center;
    // 边界点经纬度
    const ne = buildData.boundingBox.ne;
    const sw = buildData.boundingBox.sw;
    // 计算展示内容半径
    const diameter = google.maps.geometry.spherical.computeDistanceBetween(
      new google.maps.LatLng(ne.latitude, ne.longitude),
      new google.maps.LatLng(sw.latitude, sw.longitude)
    );
    const radius = Math.ceil(diameter / 2);
    // radius半径可以自定义设置,可以不需要计算
    const res = await getDataLayerUrls(center, radius, apiKey);
    setLayerData(res);
    return res;
}

Solar Api下载 TIFF 像素数据

需要安装三个库,用来解析数据。

  • geotiff
  • geotiff-geokeys-to-proj4
  • proj4

import * as geotiff from 'geotiff';
import * as geokeysToProj4 from 'geotiff-geokeys-to-proj4';
import proj4 from 'proj4';

/**
 * Downloads the pixel values for a Data Layer URL from the Solar API.
 *
 * @param  {string} url        URL from the Data Layers response.
 * @param  {string} apiKey     Google Cloud API key.
 * @return {Promise<GeoTiff>}  Pixel values with shape and lat/lon bounds.
 */
export async function downloadGeoTIFF(url: string, apiKey: string): Promise<GeoTiff> {
  console.log(`Downloading data layer: ${url}`);

  // Include your Google Cloud API key in the Data Layers URL.
  const solarUrl = url.includes('solar.googleapis.com') ? url + `&key=${apiKey}` : url;
  const response = await fetch(solarUrl);
  if (response.status != 200) {
    const error = await response.json();
    console.error(`downloadGeoTIFF failed: ${url}\n`, error);
    throw error;
  }

  // Get the GeoTIFF rasters, which are the pixel values for each band.
  const arrayBuffer = await response.arrayBuffer();
  const tiff = await geotiff.fromArrayBuffer(arrayBuffer);
  const image = await tiff.getImage();
  const rasters = await image.readRasters();

  // Reproject the bounding box into lat/lon coordinates.
  const geoKeys = image.getGeoKeys();
  const projObj = geokeysToProj4.toProj4(geoKeys);
  const projection = proj4(projObj.proj4, 'WGS84');
  const box = image.getBoundingBox();
  const sw = projection.forward({
    x: box[0] * projObj.coordinatesConversionParameters.x,
    y: box[1] * projObj.coordinatesConversionParameters.y,
  });
  const ne = projection.forward({
    x: box[2] * projObj.coordinatesConversionParameters.x,
    y: box[3] * projObj.coordinatesConversionParameters.y,
  });

  return {
    // Width and height of the data layer image in pixels.
    // Used to know the row and column since Javascript
    // stores the values as flat arrays.
    width: rasters.width,
    height: rasters.height,
    // Each raster reprents the pixel values of each band.
    // We convert them from `geotiff.TypedArray`s into plain
    // Javascript arrays to make them easier to process.
    rasters: [...Array(rasters.length).keys()].map((i) =>
      Array.from(rasters[i] as geotiff.TypedArray),
    ),
    // The bounding box as a lat/lon rectangle.
    bounds: {
      north: ne.y,
      south: sw.y,
      east: ne.x,
      west: sw.x,
    },
  };
}
// [END solar_api_download_geotiff]

根据不同的图层类型,拿到对应的图层数据,通过 render 函数渲染

渲染到HTML Canvas画布

  • 对应图层类型的色值范围
 export const binaryPalette = ['212121', 'B3E5FC'];
 export const rainbowPalette = ['3949AB', '81D4FA', '66BB6A', 'FFE082', 'E53935'];
 export const ironPalette = ['00000A', '91009C', 'E64616', 'FEB400', 'FFFFF6'];
 export const sunlightPalette = ['212121', 'FFCA28'];
RGB GeoTiff 渲染函数
// [START visualize_render_rgb]
/**
 * Renders an RGB GeoTiff image into an HTML canvas.
 *
 * The GeoTiff image must include 3 rasters (bands) which
 * correspond to [Red, Green, Blue] in that order.
 *
 * @param  {GeoTiff} rgb   GeoTiff with RGB values of the image.
 * @param  {GeoTiff} mask  Optional mask for transparency, defaults to opaque.
 * @return {HTMLCanvasElement}  Canvas element with the rendered image.
 */
export function renderRGB(rgb: GeoTiff, mask?: GeoTiff): HTMLCanvasElement {
  // Create an HTML canvas to draw the image.
  // https://www.w3schools.com/tags/canvas_createimagedata.asp
  const canvas = document.createElement('canvas');

  // Set the canvas size to the mask size if it's available,
  // otherwise set it to the RGB data layer size.
  canvas.width = mask ? mask.width : rgb.width;
  canvas.height = mask ? mask.height : rgb.height;

  // Since the mask size can be different than the RGB data layer size,
  // we calculate the "delta" between the RGB layer size and the canvas/mask
  // size. For example, if the RGB layer size is the same as the canvas size,
  // the delta is 1. If the RGB layer size is smaller than the canvas size,
  // the delta would be greater than 1.
  // This is used to translate the index from the canvas to the RGB layer.
  const dw = rgb.width / canvas.width;
  const dh = rgb.height / canvas.height;

  // Get the canvas image data buffer.
  const ctx = canvas.getContext('2d')!;
  const img = ctx.getImageData(0, 0, canvas.width, canvas.height);

  // Fill in every pixel in the canvas with the corresponding RGB layer value.
  // Since Javascript doesn't support multidimensional arrays or tensors,
  // everything is stored in flat arrays and we have to keep track of the
  // indices for each row and column ourselves.
  for (let y = 0; y < canvas.height; y++) {
    for (let x = 0; x < canvas.width; x++) {
      // RGB index keeps track of the RGB layer position.
      // This is multiplied by the deltas since it might be a different
      // size than the image size.
      const rgbIdx = Math.floor(y * dh) * rgb.width + Math.floor(x * dw);
      // Mask index keeps track of the mask layer position.
      const maskIdx = y * canvas.width + x;

      // Image index keeps track of the canvas image position.
      // HTML canvas expects a flat array with consecutive RGBA values.
      // Each value in the image buffer must be between 0 and 255.
      // The Alpha value is the transparency of that pixel,
      // if a mask was not provided, we default to 255 which is opaque.
      const imgIdx = y * canvas.width * 4 + x * 4;
      img.data[imgIdx + 0] = rgb.rasters[0][rgbIdx]; // Red
      img.data[imgIdx + 1] = rgb.rasters[1][rgbIdx]; // Green
      img.data[imgIdx + 2] = rgb.rasters[2][rgbIdx]; // Blue
      img.data[imgIdx + 3] = mask // Alpha
        ? mask.rasters[0][maskIdx] * 255
        : 255;
    }
  }

  // Draw the image data buffer into the canvas context.
  ctx.putImageData(img, 0, 0);
  return canvas;
}
// [END visualize_render_rgb]
GeoTiff 渲染函数
// [START visualize_render_palette]
/**
 * Renders a single value GeoTiff image into an HTML canvas.
 *
 * The GeoTiff image must include 1 raster (band) which contains
 * the values we want to display.
 *
 * @param  {GeoTiff}  data    GeoTiff with the values of interest.
 * @param  {GeoTiff}  mask    Optional mask for transparency, defaults to opaque.
 * @param  {string[]} colors  Hex color palette, defaults to ['000000', 'ffffff'].
 * @param  {number}   min     Minimum value of the data range, defaults to 0.
 * @param  {number}   max     Maximum value of the data range, defaults to 1.
 * @param  {number}   index   Raster index for the data, defaults to 0.
 * @return {HTMLCanvasElement}  Canvas element with the rendered image.
 */
export function renderPalette({
  data,
  mask,
  colors,
  min,
  max,
  index,
}: {
  data: GeoTiff;
  mask?: GeoTiff;
  colors?: string[];
  min?: number;
  max?: number;
  index?: number;
}): HTMLCanvasElement {
  // First create a palette from a list of hex colors.
  const palette = createPalette(colors ?? ['000000', 'ffffff']);
  // Normalize each value of our raster/band of interest into indices,
  // such that they always map into a value within the palette.
  const indices = data.rasters[index ?? 0]
    .map((x) => normalize(x, max ?? 1, min ?? 0))
    .map((x) => Math.round(x * (palette.length - 1)));
  return renderRGB(
    {
      ...data,
      // Map each index into the corresponding RGB values.
      rasters: [
        indices.map((i: number) => palette[i].r),
        indices.map((i: number) => palette[i].g),
        indices.map((i: number) => palette[i].b),
      ],
    },
    mask,
  );
}

/**
 * Creates an {r, g, b} color palette from a hex list of colors.
 *
 * Each {r, g, b} value is a number between 0 and 255.
 * The created palette is always of size 256, regardless of the number of
 * hex colors passed in. Inbetween values are interpolated.
 *
 * @param  {string[]} hexColors  List of hex colors for the palette.
 * @return {{r, g, b}[]}         RGB values for the color palette.
 */
export function createPalette(hexColors: string[]): { r: number; g: number; b: number }[] {
  // Map each hex color into an RGB value.
  const rgb = hexColors.map(colorToRGB);
  // Create a palette with 256 colors derived from our rgb colors.
  const size = 256;
  const step = (rgb.length - 1) / (size - 1);
  return Array(size)
    .fill(0)
    .map((_, i) => {
      // Get the lower and upper indices for each color.
      const index = i * step;
      const lower = Math.floor(index);
      const upper = Math.ceil(index);
      // Interpolate between the colors to get the shades.
      return {
        r: lerp(rgb[lower].r, rgb[upper].r, index - lower),
        g: lerp(rgb[lower].g, rgb[upper].g, index - lower),
        b: lerp(rgb[lower].b, rgb[upper].b, index - lower),
      };
    });
}

/**
 * Convert a hex color into an {r, g, b} color.
 *
 * @param  {string} color  Hex color like 0099FF or #0099FF.
 * @return {{r, g, b}}     RGB values for that color.
 */
export function colorToRGB(color: string): { r: number; g: number; b: number } {
  const hex = color.startsWith('#') ? color.slice(1) : color;
  return {
    r: parseInt(hex.substring(0, 2), 16),
    g: parseInt(hex.substring(2, 4), 16),
    b: parseInt(hex.substring(4, 6), 16),
  };
}

/**
 * Normalizes a number to a given data range.
 *
 * @param  {number} x    Value of interest.
 * @param  {number} max  Maximum value in data range, defaults to 1.
 * @param  {number} min  Minimum value in data range, defaults to 0.
 * @return {number}      Normalized value.
 */
export function normalize(x: number, max: number = 1, min: number = 0): number {
  const y = (x - min) / (max - min);
  return clamp(y, 0, 1);
}

/**
 * Calculates the linear interpolation for a value within a range.
 *
 * @param  {number} x  Lower value in the range, when `t` is 0.
 * @param  {number} y  Upper value in the range, when `t` is 1.
 * @param  {number} t  "Time" between 0 and 1.
 * @return {number}    Inbetween value for that "time".
 */
export function lerp(x: number, y: number, t: number): number {
  return x + t * (y - x);
}

/**
 * Clamps a value to always be within a range.
 *
 * @param  {number} x    Value to clamp.
 * @param  {number} min  Minimum value in the range.
 * @param  {number} max  Maximum value in the range.
 * @return {number}      Clamped value.
 */
export function clamp(x: number, min: number, max: number): number {
  return Math.min(Math.max(x, min), max);
}
// [END visualize_render_palette]
获取对应类型的render
export interface Palette {
   colors: string[];
   min: string;
   max: string;
 }
 
 export interface Layer {
   id: LayerId;
   render: (showRoofOnly: boolean, month: number, day: number) => HTMLCanvasElement[];
   bounds: Bounds;
   palette?: Palette;
 }
 
 export async function getLayer(
   layerId: LayerId,
   urls: DataLayersResponse,
   googleMapsApiKey: string,
 ): Promise<Layer> {
   const get: Record<LayerId, () => Promise<Layer>> = {
     mask: async () => {
       const mask = await downloadGeoTIFF(urls.maskUrl, googleMapsApiKey);
       console.log(mask);
       
       const colors = binaryPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'No roof',
           max: 'Roof',
         },
         render: (showRoofOnly) => {
          console.log(showRoofOnly);
          
          return [
            renderPalette({
              data: mask,
              mask: showRoofOnly ? mask : undefined,
              colors: colors,
            }),
          ]
         },
       };
     },
     dsm: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.dsmUrl, googleMapsApiKey),
       ]);
       const sortedValues = Array.from(data.rasters[0]).sort((x, y) => x - y);
       const minValue = sortedValues[0];
       const maxValue = sortedValues.slice(-1)[0];
       const colors = rainbowPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: `${minValue.toFixed(1)} m`,
           max: `${maxValue.toFixed(1)} m`,
         },
         render: (showRoofOnly) => [
           renderPalette({
             data: data,
             mask: showRoofOnly ? mask : undefined,
             colors: colors,
             min: sortedValues[0],
             max: sortedValues.slice(-1)[0],
           }),
         ],
       };
     },
     rgb: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.rgbUrl, googleMapsApiKey),
       ]);
       return {
         id: layerId,
         bounds: mask.bounds,
         render: (showRoofOnly) => [renderRGB(data, showRoofOnly ? mask : undefined)],
       };
     },
     annualFlux: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.annualFluxUrl, googleMapsApiKey),
       ]);
       const colors = ironPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'Shady',
           max: 'Sunny',
         },
         render: (showRoofOnly) => [
           renderPalette({
             data: data,
             mask: showRoofOnly ? mask : undefined,
             colors: colors,
             min: 0,
             max: 1800,
           }),
         ],
       };
     },
     monthlyFlux: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.monthlyFluxUrl, googleMapsApiKey),
       ]);
       const colors = ironPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'Shady',
           max: 'Sunny',
         },
         render: (showRoofOnly) =>
           [...Array(12).keys()].map((month) =>
             renderPalette({
               data: data,
               mask: showRoofOnly ? mask : undefined,
               colors: colors,
               min: 0,
               max: 200,
               index: month,
             }),
           ),
       };
     },
     hourlyShade: async () => {
       const [mask, ...months] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         ...urls.hourlyShadeUrls.map((url) => downloadGeoTIFF(url, googleMapsApiKey)),
       ]);
       const colors = sunlightPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'Shade',
           max: 'Sun',
         },
         render: (showRoofOnly, month, day) =>
           [...Array(24).keys()].map((hour) =>
             renderPalette({
               data: {
                 ...months[month],
                 rasters: months[month].rasters.map((values) =>
                   values.map((x) => x & (1 << (day - 1))),
                 ),
               },
               mask: showRoofOnly ? mask : undefined,
               colors: colors,
               min: 0,
               max: 1,
               index: hour,
             }),
           ),
       };
     },
   };
   try {
     return get[layerId]();
   } catch (e) {
     console.error(`Error getting layer: ${layerId}\n`, e);
     throw e;
   }
 }

通过GroundOverlay实际渲染

因为有 月日照、小时阴影,所以渲染图层可能会有多个, overlays 用的是数组。

  • 清除渲染
// 清除单个
overlay.setMap(null)

// 多个
overlays && overlays.map((overlay) => overlay.setMap(null));
  • 渲染函数
async function DrawerLayer(_layerId: LayerId | "none", res: DataLayersResponse) {
    if (_layerId == "none" || !res) {
        clearLayer();
        return;
    };

    let layerRes: any;
    // 当前在渲染的,就不需要重新在解析一次数据了
    if (layerInstance && layerInstance.id === _layerId) {
      layerRes = layerInstance;
    } else {
      layerRes = await getLayer(_layerId, res, apiKey);
      setLayerInstance(layerRes);
    }

    const bounds = layerRes.bounds;
    // 渲染前,将之前的图层清掉
    overlays && overlays.map((overlay) => overlay.setMap(null));

    // 重新获取图层信息,再次渲染
    // showRoof 控制是否需要遮罩层
    const overlaysPos = layerRes.render(showRoof, month, day)
        .map((canvas) => new google.maps.GroundOverlay(canvas.toDataURL(), bounds));
    if (!["monthlyFlux", "hourlyShade"].includes(layerRes.id)) {
      overlaysPos[0].setMap(map);
    } else {
        // 根据当前的月份 / 小时,找到对应的图层信息,并渲染
      if (layerInstance.id == "monthlyFlux") {
        overlays.map((overlay, i) => overlay.setMap(i == month ? map : null));
      } else if (layerInstance.id == "hourlyShade") {
        overlays.map((overlay, i) => overlay.setMap(i == hour ? map : null));
      }
    }
    
    // 存储各图层信息
    setOverlays(overlaysPos);
  }

Drawer 绘图框选

DrawingManager 类提供一个图形界面,以供用户在地图上绘制多边形、矩形、多段线、圆形和标记。

绘图库示例文档:developers.google.com/maps/docume…

详细参数文档:developers.google.com/maps/docume…

初始化

需要在 libraries 加载 drawing

const { isLoaded } = useLoadScript({
    googleMapsApiKey: apiKey,
    libraries: ["places", "geometry", "marker", "drawing"],
    language: "en",
    mapIds: ["DEMO_MAP_ID"],
});

添加 drawing 参数后,即可创建 DrawingManager 对象,

const drawingManager = new google.maps.drawing.DrawingManager({
    drawingControl: true,
})
drawingManager.setMap(mapInstance);
  • drawingControl 开启工具栏

这里目前的需求只需要绘制多边形即可支撑,便关闭 drawingControl,只配置 polygonOptions即可。

const [drawingInstance, setDrawingInstance] = useState<google.maps.drawing.DrawingManager | null>(null);

const drawingManager = new google.maps.drawing.DrawingManager({
  drawingMode: null,
  drawingControl: false,
  polygonOptions: {
    fillColor: '#4CAF50',
    fillOpacity: 0.3,
    strokeWeight: 2,
    strokeColor: '#4CAF50',
    clickable: true,
    editable: true,
    zIndex: 1000,
  },
});

drawingManager.setMap(mapInstance);
setDrawingManager(drawingManager);

绘画过程

  • 开始绘画

    • 保存绘画状态
    • 设置绘画模式
const [isDrawing, setIsDrawing] = useState(false);
const startDrawing = useCallback(() => {
    if (drawingInstance) {
      // 绘画状态
      setIsDrawing(true);
      // 绘制模式(形状)
      drawingInstance.setDrawingMode(google.maps.drawing.OverlayType.POLYGON);
    }
}, [drawingInstance]);
  • 结束绘画

    • 监听 drawingInstance 绘画工具的 多边形-绘制完成 事件
    • 回调函数返回绘制完成的区域路径信息;
    • 存储到 areas 区域列表中,停止绘画状态;
    • 给当前绘画的区域添加点击事件,可用于 正选/ 反选区域。
interface Area {
  id: string;
  polygon: google.maps.Polygon;
  isExcluded: boolean; // 可用区域 / 排除区域
}

// 存储已经绘制完成的多个区域的路径信息
const [areas, setAreas] = useState<Area[]>([]);

useEffect(() => {
    // 监听绘制完成事件
    const polygonCompleteListener = google.maps.event.addListener(
      drawingInstance, 
      'polygoncomplete', 
      (polygon: google.maps.Polygon) => {
         
        // 拿到绘制完成的多边型路径
        const newArea: Area = {
          id: Date.now().toString(),
          polygon: polygon,
          isExcluded: false,
        };
        setAreas(prev => [...prev, newArea]);
        // 修改绘画状态,停止绘画动作
        setIsDrawingEnabled(false);
        drawingInstance.setDrawingMode(null);
        
        
        // 给当前绘画完成的区域,添加点击事件切换区域类型
        const clickListener = polygon.addListener('click', () => {
          setAreas(prev => prev.map(area => {
            if (area.id === newArea.id) {
              const updatedArea = { ...area, isExcluded: !area.isExcluded };
              
              // 更新多边形样式
              polygon.setOptions({
                fillColor: updatedArea.isExcluded ? '#F44336' : '#4CAF50',
                strokeColor: updatedArea.isExcluded ? '#F44336' : '#4CAF50',
              });
              
              return updatedArea;
            }
            return area;
          }));
        });
      }
    );
    
    return () => {
      google.maps.event.removeListener(polygonCompleteListener);
      google.maps.event.removeListener(clickListener);
      drawingInstance.setMap(null);
    };
}, [mapInstance, drawingInstance])
  • 清除区域
const clearAll = useCallback(() => {
    areas.forEach(area => {
      area.polygon.setMap(null);
    });
    setAreas([]);
}, [areas]);

LatLng 转 Polygon

  • 通过经纬度,创建一个 new google.maps.Polygon 多边形
// 坐标点类型定义
export type Coordinate = { lat: number; lng: number } | google.maps.LatLng;

/**
 * 坐标点数组转Polygon多边形
 * 
 * @param coordinates - 坐标点数组,支持 { lat, lng } 对象或 google.maps.LatLng 实例
 * @param isExcluded - 是否为排除区域,默认为 false(允许区域)
 * @param map - Google Maps 实例,可选,用于设置多边形到地图上
 * @returns 创建的 Polygon 实例
 */
export const coordinatesToPolygon = (
  coordinates: Coordinate[],
  isExcluded: boolean = false,
  polygonOptions?: google.maps.PolygonOptions,
  map?: google.maps.Map
): google.maps.Polygon => {
  if (coordinates.length < 3) {
    throw new Error('至少需要3个坐标点才能形成多边形');
  }
  // 转换坐标格式
  const paths = coordinates.map(coord => {
    if (coord instanceof google.maps.LatLng) {
      return coord;
    }
    return new google.maps.LatLng(coord.lat, coord.lng);
  });
  // 创建多边形
  const polygon = new google.maps.Polygon({
    paths: paths,
    ...{
      fillColor: isExcluded ? '#F44336' : '#4CAF50',
      fillOpacity: 0.3,
      strokeWeight: 2,
      strokeColor: isExcluded ? '#F44336' : '#4CAF50',
      clickable: true,
      editable: true,
      zIndex: 1000,
      ...polygonOptions
    }
  });
  // 如果提供了地图实例,将多边形添加到地图上
  if (map) {
    polygon.setMap(map);
  }
  return polygon;
};

Polygon 转 GeoJson

  • 单个 Polygon 转 GeoJson
/**
 * 多边形转GeoJSON
 * 
 * @param polygon - Google Maps Polygon 实例
 * @param isExcluded - 是否为排除区域,默认为 false(允许区域)
 * @param properties - 额外的属性信息
 * @returns GeoJSON Feature 对象
 */
export const polygonToGeoJSON = (
  polygon: google.maps.Polygon,
  isExcluded: boolean = false,
  properties: Record<string, any> = {}
): GeoJSONFeature => {
  const path = polygon.getPath();
  // 确保坐标格式为 [经度, 纬度](标准 GeoJSON 格式)
  const coordinates = path.getArray().map(latLng => [latLng.lng(), latLng.lat()]);
  
  return {
    type: "Feature",
    geometry: {
      type: "Polygon",
      coordinates: [coordinates] // 注意:GeoJSON要求坐标数组的数组
    },
    properties: {
      isExcluded: isExcluded,
      areaType: isExcluded ? "excluded" : "allowed",
      ...properties
    }
  };
};
  • 多个 Polygon 转 GeoJson
/**
 * 多个多边形转GeoJSON(支持MultiPolygon)
 * 
 * @param polygons - Google Maps Polygon 实例数组
 * @param isExcluded - 是否为排除区域,默认为 false(允许区域)
 * @param properties - 额外的属性信息
 * @returns GeoJSON Feature 对象(如果只有一个多边形返回Polygon,多个返回MultiPolygon)
 */
export const polygonListToGeoJSON = (
  polygons: google.maps.Polygon[],
  isExcluded: boolean = false,
  properties: Record<string, any> = {}
): GeoJSONFeature => {
  if (polygons.length === 0) {
    throw new Error('至少需要一个多边形');
  }

  if (polygons.length === 1) {
    // 单个多边形返回 Polygon 类型
    return polygonToGeoJSON(polygons[0], isExcluded, properties);
  }

  // 多个多边形返回 MultiPolygon 类型
  // MultiPolygon 格式:[[[polygon1_coords]], [[polygon2_coords]], ...]
  const coordinates = polygons.map(polygon => {
    const path = polygon.getPath();
    const polygonCoords = path.getArray().map(latLng => [latLng.lng(), latLng.lat()]);
    return [polygonCoords];
  });

  return {
    type: "Feature",
    geometry: {
      type: "MultiPolygon",
      coordinates: coordinates
    },
    properties: {
      isExcluded: isExcluded,
      areaType: isExcluded ? "excluded" : "allowed",
      description: `${isExcluded ? "排除" : "允许"}安装太阳能面板的多边形区域`,
      ...properties
    }
  };
};

GeoJson 转 Polygon

  • 判断geoJSON类型, 支持 FeatureCollection 或单个 Feature ,拿到 geoJsonfeatures 数组;

  • feature.properties 添加自定义属性

  • 根据 geometry.type,根据 PolygonMultiPolygon 区分处理逻辑;

  • 拿到 coordinates点位数据,执行 coordinatesToPolygon 根据经纬度渲染区域函数;

    • GeoJSON 使用 [经度, 纬度] 格式
    • Google Maps 使用 [纬度, 经度] 格式
    • 需要转换,调转一下位置
  • 坐标转换函数

/**
 * 智能检测坐标格式并转换为 LatLng
 * @param coordinates - 坐标数组
 * @returns 转换后的 LatLng 数组
 */
const convertCoordinatesToLatLngs = (coordinates: number[][]): google.maps.LatLng[] => {
  if (coordinates.length < 3) {
    throw new Error(`坐标点不足: ${coordinates.length} 个点,至少需要3个点`);
  }

  // 智能检测坐标格式:如果第一个坐标的纬度在 -90 到 90 之间,且经度在 -180 到 180 之间
  // 则认为是 [纬度, 经度] 格式,否则认为是 [经度, 纬度] 格式
  const firstCoord = coordinates[0] as number[];
  const isLatLngFormat = firstCoord[0] >= -90 && firstCoord[0] <= 90 && 
                        firstCoord[1] >= -180 && firstCoord[1] <= 180;
  
  return coordinates.map(coord => {
    if (isLatLngFormat) {
      // [纬度, 经度] 格式
      return new google.maps.LatLng(coord[0], coord[1]);
    } else {
      // [经度, 纬度] 格式(标准 GeoJSON)
      return new google.maps.LatLng(coord[1], coord[0]);
    }
  });
};
  • json转polygon
/**
 * GeoJSON转多边形
 * @param geoJSON - GeoJSON Feature 或 FeatureCollection
 * @param map - Google Maps 实例,可选,用于设置多边形到地图上
 * @returns 创建的多边形数组和对应的属性信息
 */
export const geoJSONToPolygons = (
  geoJSON: GeoJSONFeature | GeoJSONCollection,
  map?: google.maps.Map
): { polygons: google.maps.Polygon[]; properties: Record<string, any>[] } => {
  const polygons: google.maps.Polygon[] = [];
  const properties: Record<string, any>[] = [];

  const features = geoJSON.type === "FeatureCollection" ? geoJSON.features : [geoJSON];

  features.forEach((feature) => {
    const isExcluded =
      feature.properties?.isExcluded ??
      feature.properties?.areaType === "excluded";
    const featureProperties = feature.properties || {};

    if (feature.geometry.type === "Polygon") {
      // 处理单个多边形
      const coordinates = feature.geometry.coordinates as number[][][];

      try {
        const latLngs = convertCoordinatesToLatLngs(coordinates[0]);
        const polygon = coordinatesToPolygon(
          latLngs,
          isExcluded,
          undefined,
          map
        );
        if (polygon) {
          polygons.push(polygon);
          properties.push(featureProperties);
        }
      } catch (error) {
        console.error(`创建多边形失败:`, error);
      }
    } else if (feature.geometry.type === "MultiPolygon") {
      // 处理多个多边形
      const multiPolygonCoords = feature.geometry.coordinates as number[][][][];
      multiPolygonCoords.forEach((polygonCoords, polygonIndex) => {
        try {
          const latLngs = convertCoordinatesToLatLngs(polygonCoords[0]);
          const polygon = coordinatesToPolygon(
            latLngs,
            isExcluded,
            undefined,
            map
          );
          if (polygon) {
            polygons.push(polygon);
            properties.push(featureProperties);
          }
        } catch (error) {
          console.error(`创建多边形失败:${polygonIndex}`, error);
        }
      });
    }
  });

  return { polygons, properties };
};

检查渲染的光伏板是否在框选区域内

  • 检查 坐标点 是否在 多边形内
/**
 * 检查点是否在多边形内
 * @param point 要检查的点
 * @param polygon 多边形顶点数组
 * @returns 是否在多边形内
 */
export function isPointInPolygon(
  point: { lat: number; lng: number },
  polygon: google.maps.LatLng[]
): boolean {
  if (polygon.length < 3) {
    return false;
  }

  const x = point.lng;
  const y = point.lat;
  let inside = false;

  // 使用射线法算法
  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
    const xi = polygon[i].lng();
    const yi = polygon[i].lat();
    const xj = polygon[j].lng();
    const yj = polygon[j].lat();

    // 检查点是否在边的同一侧
    if (((yi > y) !== (yj > y)) && 
        (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
      inside = !inside;
    }
  }

  return inside;
}



/**
 * 检查点是否在任意多边形内
 * @param point 要检查的点
 * @param polygons 多边形数组
 * @returns 是否在任意多边形内
 */
export function isPointInAnyPolygon(
  point: { lat: number; lng: number },
  polygons: google.maps.LatLng[][]
): boolean {
  return polygons.some(polygon => isPointInPolygon(point, polygon));
}
  • 过滤太阳能板铺设数据
/**
 * 过滤太阳能面板,排除在不可铺设区域内的面板
 * @param panels 原始面板数组
 * @param excludedAreas 排除区域数组
 * @param allowedAreas 允许区域数组(如果为空,则使用所有非排除区域)
 * @returns 过滤后的面板数组
 */
export function filterSolarPanels(
  panels: SolarPanel[],
  excludedAreas: google.maps.LatLng[][] = [],
  allowedAreas: google.maps.LatLng[][] = []
): SolarPanel[] {
  const filteredPanels = panels.filter(panel => {
    const panelPoint = { lat: panel.center.latitude, lng: panel.center.longitude };
    
    // 如果在排除区域内,则过滤掉
    if (isPointInAnyPolygon(panelPoint, excludedAreas)) {
      return false;
    }
    
    // 如果指定了允许区域,则只保留在允许区域内的面板
    if (allowedAreas.length > 0) {
      return isPointInAnyPolygon(panelPoint, allowedAreas);
    }
    
    // 如果没有指定允许区域,则保留所有非排除区域的面板
    return true;
  });
  
  return filteredPanels;
}

解决地图拖动, 中心点定位图标晃动问题

import { useEffect, useState } from 'react';

interface LocationMarkerProps {
  map: google.maps.Map;
}
export default function LocationMarker({ map }: LocationMarkerProps) {
  // 使用 OverlayView 的 LocationMarker,确保与地理坐标精确对应
  const [overlay, setOverlay] = useState<google.maps.OverlayView | null>(null);

  useEffect(() => {
    if (!map) return;

    class CenterMarkerOverlay extends google.maps.OverlayView {
      private div: HTMLDivElement | null = null;
      private map: google.maps.Map | null = null;
      private listeners: google.maps.MapsEventListener[] = [];

      constructor() {
        super();
        this.div = null;
        this.map = null;
        this.listeners = [];
      }

      onAdd() {
        this.div = CreateElement();

        // 获取地图的 panes(可渲染的窗格) 对象,将 div 添加到 overlayImage 中
        const panes = this.getPanes();
        if (panes) {
          (panes as any).overlayImage.appendChild(this.div);
        }

        // 保存地图引用
        this.map = this.getMap() as google.maps.Map;

        // 使用 requestAnimationFrame 优化更新频率
        let animationId: number | null = null;
        const updatePosition = () => {
          this.updatePosition();
          animationId = requestAnimationFrame(updatePosition);
        };

        // 监听地图事件,使用高频更新
        if (this.map) {
          this.listeners.push(
            this.map.addListener('drag', () => {
              if (animationId) cancelAnimationFrame(animationId);
              animationId = requestAnimationFrame(updatePosition);
            }),
            this.map.addListener('dragend', () => {
              if (animationId) cancelAnimationFrame(animationId);
              this.updatePosition();
            }),
            this.map.addListener('zoom_changed', () => {
              this.updatePosition();
            }),
            this.map.addListener('center_changed', () => {
              this.updatePosition();
            }),
          );
        }

        // 初始化位置
        this.updatePosition();
      }

      updatePosition() {
        if (!this.div || !this.map) return;

        // 返回与此 OverlayView 关联的 MapCanvasProjection 对象, 用于计算div的地理坐标,像素坐标
        const projection = this.getProjection();
        if (!projection) return;

        const center = this.map.getCenter();
        if (!center) return;

        // 计算存放可拖动地图的 DOM 元素中指定地理位置的像素坐标。
        const point = projection.fromLatLngToDivPixel(center);
        if (point) {
          /* Google Maps Marker 的默认定位点是图标的底部中心
          我们的 SVG 图标宽度是 30px,高度是 36.21px
          让图标的底部中心对准地图中心点,与标准 Marker 保持一致 */
          this.div.style.left = point.x - 30 / 2 + 'px'; // 图标宽度的一半
          this.div.style.top = point.y - 36.21 + 'px'; // 图标高度,让图标底部对准中心点
        }
      }

      draw() {
        this.updatePosition();
      }

      onRemove() {
        // 清理事件监听器
        this.listeners.forEach((listener) => {
          google.maps.event.removeListener(listener);
        });
        this.listeners = [];

        if (this.div && this.div.parentNode) {
          this.div.parentNode.removeChild(this.div);
        }
        this.div = null;
        this.map = null;
      }
    }

    const newOverlay = new CenterMarkerOverlay();
    newOverlay.setMap(map);
    setOverlay(newOverlay);

    return () => {
      if (newOverlay) {
        newOverlay.setMap(null);
      }
    };
  }, [map]);

  return null;
}

function CreateElement() {
  const div = document.createElement('div');
  div.style.position = 'absolute';
  div.style.pointerEvents = 'none';
  div.style.zIndex = '1000';
  div.style.width = '30px';
  div.style.height = '36.21px';
  div.style.display = 'flex';
  div.style.alignItems = 'center';
  div.style.justifyContent = 'center';
  // 创建 SVG 元素,使用与原始 LocationMarker 相同的尺寸
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.setAttribute('width', '30');
  svg.setAttribute('height', '36.21');
  svg.setAttribute('viewBox', '0 0 30 36.21');
  svg.setAttribute('fill', 'none');
  svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  svg.setAttribute('version', '1.1');

  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  path.setAttribute(
    'd',
    'M25.6067,25.6067C25.6067,25.6067,15,36.2132,15,36.2132C15,36.2132,4.3934,25.6067,4.3934,25.6067C-1.46447,19.7487,-1.46447,10.2513,4.3934,4.3934C10.2513,-1.46447,19.7487,-1.46447,25.6067,4.3934C31.4645,10.2513,31.4645,19.7487,25.6067,25.6067ZM15,18.3333C16.841,18.3333,18.3333,16.841,18.3333,15C18.3333,13.159,16.841,11.6667,15,11.6667C13.159,11.6667,11.6667,13.159,11.6667,15C11.6667,16.841,13.159,18.3333,15,18.3333Z',
  );
  path.setAttribute('fill', '#E75043');
  path.setAttribute('fillRule', 'evenodd');
  path.setAttribute('fillOpacity', '1');

  svg.appendChild(path);
  div.appendChild(svg);
  return div;
}

使用 Canvas 实现扫描效果:宽度计算、透明度控制与旋转

作者 excel
2025年11月17日 13:07

介绍

在前端开发中,<canvas> 元素提供了强大的图形绘制能力。我们可以使用 canvas 来进行各种图像处理操作,包括图像加载、旋转、缩放以及创建动态效果。在很多应用中,扫描效果是一种常见的视觉效果,比如条形码扫描、激光扫描、加载动画等。

本文将介绍如何通过 canvas 元素实现一个平滑的扫描效果,使得图像从顶部到底部逐渐显示,模拟扫描的过程。同时,还将提供一个AI生成提示(Prompt),供图像生成模型(如 DALL·E、MidJourney)参考,帮助快速生成所需的图像。

实现思路

1. 获取图片并绘制

首先,我们需要将图像绘制到 canvas 上。通过 drawImage 方法将图像绘制到 canvas 上。

2. 使用 ImageData 获取像素数据

getImageData() 方法允许我们获取 canvas 中特定区域的像素数据。ImageData 对象包含图像的 RGBA 值,这样我们就可以逐个像素地操作图像。

3. 计算透明度

根据扫描的进度,调整每个像素的透明度。我们将透明度根据 y 坐标的值逐渐改变,从顶部完全透明,到底部完全不透明。透明度的变化将通过调整 ImageData 中的 Alpha 通道值来实现。

4. 动画实现

使用 setIntervalrequestAnimationFrame 来动态更新扫描的进度,逐渐显示图像的不同部分,直到图像完全显示。

代码实现

1. 初始化和宽度计算部分

在这个部分,我们计算 canvas 的宽高,并通过 devicePixelRatio 适配高清屏幕。

const displayWidth = props.width;
const displayHeight = props.height;

// 设置 canvas 的 CSS 尺寸
canvas.style.width = displayWidth + "px";
canvas.style.height = displayHeight + "px";

// 设置 canvas 的像素尺寸
canvas.width = displayWidth * devicePixelRatio;
canvas.height = displayHeight * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio); // 为高清屏幕做适配

2. 透明度控制和扫描效果部分

在这部分中,我们获取图像数据,并根据扫描进度修改透明度,逐步显示图像。

// 获取图像数据
const imageData = ctx.getImageData(-imgSize / 2, -imgSize / 2, imgSize, imgSize);
const data = imageData.data;

// 根据扫描进度逐渐修改透明度
for (let y = 0; y < imgSize; y++) {
  for (let x = 0; x < imgSize; x++) {
    const index = (y * imgSize + x) * 4; // 每个像素有4个值(RGBA)

    const alpha = y <= scanProgress ? 1 : 0; // 计算透明度
    data[index + 3] = Math.round(alpha * 255); // 修改透明度值
  }
}

// 更新图像数据
ctx.putImageData(imageData, -imgSize / 2, -imgSize / 2);

3. 旋转和反旋转部分

这部分涉及图像的旋转或反旋转,我们通过 getParentRotateDeg() 获取父元素的旋转角度,并根据需要旋转图像。

// 获取旋转角度
function getParentRotateDeg(): number {
  const transform = parent.style.transform || getComputedStyle(parent).transform;
  if (!transform || transform === "none") return 0;

  const match = transform.match(/rotate(([-\d.]+)deg)/);
  if (match) return Number(match[1]);

  const matrixMatch = transform.match(/matrix(([-\d., ]+))/);
  if (matrixMatch) {
    const values = matrixMatch[1].split(",").map(Number);
    const a = values[0], b = values[1];
    const rad = Math.atan2(b, a);
    return rad * (180 / Math.PI);
  }

  return 0;
}

// 旋转图像
function draw(deg: number) {
  ctx.clearRect(0, 0, displayWidth, displayHeight); // 清除画布
  ctx.save();

  // 将 canvas 的旋转中心设置为中心
  ctx.translate(displayWidth / 2, displayHeight / 2);
  ctx.rotate((deg * Math.PI) / 180); // 旋转角度

  // 绘制图像
  ctx.drawImage(img, -imgSize / 2, -imgSize / 2, imgSize, imgSize);
  
  // 恢复状态
  ctx.restore();
}

示例地址

总结

通过对 canvas 的宽高计算、透明度控制以及旋转的处理,我们能够实现图像的逐步扫描效果。结合 AI 图像生成提示,我们可以创造出未来感十足的动态扫描效果图像,应用于各种设计场景中。这种技术不仅适用于条形码扫描、图像加载动画,还可以用于创意设计和动态显示效果。

颜色网站为啥都收费?自己做个要花多少钱?

2025年11月17日 13:03

你是小阿巴,一位没有对象的程序员。

这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。

结果一个大大的付费弹窗阻挡了你!

你心想:可恶,为啥颜色网站都要收费啊?

作为一名程序员,你怎能甘心?

于是你决定自己做一个,不就是上传视频、播放视频嘛?

这时,经常给大家分享 AI 和编程知识的 鱼皮 突然从你身后冒了出来:天真!你知道自己做一个要花多少钱么?

你吓了一跳:我又没做过这种网站,怎么知道要花多少?

难道,你做过?

鱼皮一本正经:哼,当然…… 没有。

不过我做过可以看视频的、技术栈完全类似的 编程学习网站,所以很清楚这类网站的成本。

你来了兴趣:哦?愿闻其详。

鱼皮笑了笑:那我就以 编程导航 项目为例,从网站开发、上线到运营的完整流程,给你算算做一个视频网站到底要花多少钱。还能教你怎么省钱哦~

你点了个赞,并递上了两个硬币:好啊,快说快说!


鱼皮特别感谢朋友们的支持,你们的鼓励是我持续创作的动力 🌹!

⚠️ 友情声明:以下成本是基于个人经验 + 专业云服务商价格的估算(不考虑折扣),仅供参考。

⭐️ 推荐观看本文对应视频版:bilibili.com/video/BV1nJ…

服务器

想让别人访问你的网站,首先你要有一台服务器。

你点点头:我知道,代码文件都要放到服务器上运行,用户通过浏览器访问网站,其实是在向服务器请求网页文件和数据。

那服务器怎么选呢?

鱼皮:服务器的配置要看你的网站规模。刚开始做个小型视频网站,可以用入门配置的轻量应用服务器 (比如 2 核 CPU、2G 内存、4M 带宽) ,一年几百块就够了。

等后续用户多了,服务器带宽跟不上了再升级。比如 4 核 CPU、16G 内存、14M 带宽,一年差不多几千块。

你:几百块?比我想的便宜啊。

鱼皮:没错,国内云服务现在竞争很激烈、动不动就搞优惠。

但是要注意,如果你想做 “那种网站”,就要考虑用海外服务器了(好处是不用备案)。

咳咳,我们不谈这个……

数据库

有了服务器,还得有数据库,用来存储网站的用户信息、视频信息、评论点赞这些数据。

你:这个简单,数据库不就是 MySQL、PostgreSQL 这些嘛,装在服务器上不就行了?

鱼皮:是可以的,但我更建议使用云数据库服务,比如阿里云 RDS 或者腾讯云的云数据库。

你:为啥?不是要多花钱吗?

鱼皮:因为云数据库更稳定,而且自带备份、容灾、监控这些功能,你自己搞的话,还要费时费力安装维护,万一数据丢了可就麻烦了。

你:确实,那得多少钱?

鱼皮:入门级的云数据库(比如 2 核 4G 内存、100GB 硬盘)包年大概 2000 元左右。后面用户多了、数据量大了,就要升级配置(比如 4 核 16G),那一年就要 1 万多了。不过那个时候你已经赚麻了……

Redis

鱼皮:对了,我还建议你加个 Redis 缓存。

你挠了挠头:Redis?之前看过你的 讲解视频。这个是必须的吗?

鱼皮:刚开始可以没有,但如果你想让网站数据能更快加载,强烈建议用。

你想啊,视频网站用户一进来都要查看视频列表、热门推荐这些,如果用 Redis 把热点数据缓存起来,响应速度能快好几倍,还能帮数据库分摊查询压力。

你:确实,网站更快用户更爽,也更愿意付费。那 Redis 要多少钱?

鱼皮:Redis 比数据库便宜一些。入门级的 Redis 服务一年大概 1000 元左右。

你松了口气:也还行吧,看来做个视频网站也花不了多少钱啊!

对象存储

鱼皮:别急,接下来才是重点!

我问问你,视频文件保存在哪儿?

你不假思索:当然是存在服务器的硬盘上!

鱼皮哈哈大笑:别开玩笑了,一个高清视频动不动就几百 MB 甚至几个 G,你那点儿服务器硬盘能存几个视频?

而且服务器带宽有限,如果同时有很多用户看视频,服务器根本撑不住!

你:那咋办啊!

鱼皮:更好的做法是用 对象存储,比如阿里云 OSS、腾讯云 COS。

对象存储是专门用来存海量文件的云服务,它容量几乎无限、可以弹性扩展,而且访问速度快、稳定性高,很适合存储图片和音视频这些大文件。

你:贵吗?

鱼皮:存储本身不贵,100GB 一年也就几十块钱。但 真正贵的是流量费用

用户每看一次视频,都要从对象存储下载数据,这就产生了流量。

如果一个 1 GB 的视频被完整播放 1000 次,那就是 1000 GB 的流量,大概 500 块钱。

你看那些视频网站,每天光 1 个视频可能就有 10 万人看过,价格可想而知。

你惊讶地说不出话来:阿巴阿巴……

视频转码

鱼皮接着说:这还不够!对于视频网站,你还要做 视频转码。因为用户上传的视频格式、分辨率、编码方式都不一样,你需要把它们统一转成适合网页播放的格式,还要生成不同清晰度的版本让用户选择(标清、高清、超清)。

你:啊,那不是要多存好几个不同清晰度的视频文件?

鱼皮:没错,而且转码本身也是要钱的!

一般按照清晰度和视频分钟数计费。如果你上传 1000 个小时的高清视频,光转码费就得几千块!

CDN 加速

你急了:怎么做个视频网站处处都要花钱啊!有没有便宜点的办法?

鱼皮笑道:可以用 CDN。

你:CDN是啥?听着就高级!

鱼皮:CDN 叫内容分发网络,简单说就是把你的视频缓存到全国各地的服务器节点上。用户看视频的时候,从最近的节点拿数据,不仅速度更快,而且流量费比对象存储便宜不少。

你眼睛一亮:这么好?那不是必用 CDN!

鱼皮:没错,一般建议对象存储配合 CDN 使用。

而且视频网站 一定要做好流量防刷和安全防护

现在有的平台自带了流量防盗刷功能:

此外,建议手动添加更多流量安全配置。

1)设置访问频率限制,防止短时间被盗刷大量流量

2)还要配置 CDN 的流量告警,超过阈值及时得到通知

3)还要启用 referer 防盗链,防止别人盗用你的视频链接,用你的流量做网站捞钱。

如果不做这些,可能分分钟给你刷破产了!

你:这我知道,之前看过很多你破产和被攻击的视频!

鱼皮:我 ***!

视频点播

你:为了给用户看个视频,我要先用对象存储保存文件、再通过云服务转码视频、再通过 CDN 给用户加速访问,感觉很麻烦啊!

鱼皮神秘一笑:嘿嘿,其实还有更简单的方案 —— 视频点播服务,这是快速实现视频网站的核心。

只需要通过官方提供的 SDK 代码包和示例代码,就能快速完成视频上传、转码、多清晰度切换、加密保护等功能。

此外,还提供了 CDN 内容加速和各端的视频播放器。

你双眼放光:这么厉害,如果我自己从零开发这些功能,至少得好几个月啊!

鱼皮:没错,视频点播服务相当于帮你做了整合,能大幅提高开发效率。

但是它的费用也包含了存储费、转码费和流量费,价格跟前面提到的方案不相上下。

你叹了口气:唉,主要还是流量费太贵了啊……

网站上线还要准备啥?

鱼皮:讲完了开发视频网站需要的技术,接下来说说网站上线还需要的其他东西。

你:啊?还有啥?

鱼皮:首先,你得有个 域名 给用户访问吧?总不能让人家记你的 IP 地址吧?

不过别担心,普通域名一年也就几十块钱(比如我的 codefather.cn 才 38 / 年)。

当然,如果是稀缺的好域名就比较贵了,几百几千万的都有!

你:别说了,俺随便买个便宜的就行……

鱼皮:买了域名还得配 SSL 证书,因为现在做网站都得用 HTTPS 加密传输,不然浏览器会提示 “不安全”,用户看了就跑了。

刚开始可以直接用 Let's Encrypt 提供的免费证书,但只有 3 个月有效期,到期要手动续期,比较麻烦。

想省心的话可以买付费证书,便宜的一年几百块。

你:了解,那我就先用免费的,看来上线也花不了几个钱。

鱼皮:哎,可不能这么说,网站正式上线运营后,花钱的地方可多着呢!尤其是安全防护。

安全防护

做视频网站要面对两大安全威胁。第一个是 内容安全,你总不能让用户随便上传违规视频吧?万一上传了不该传的内容,网站直接就被封了。

你紧张起来:对啊,我人工审核也看不过来啊…… 怎么办?

鱼皮:可以用内容审核服务。视频审核包含画面和声音两部分,比文字审核更贵,审核 1000 小时视频,大概几千块。

你:还有第二个威胁呢?

鱼皮:第二个是最最最难应对的 网络攻击。做视频网站,尤其是有付费内容的,特别容易被攻击。DDoS 流量攻击想把你冲垮、SQL 注入想偷你数据、XSS 攻击想搞你用户、爬虫想盗你视频……

你:这么坏的吗?那我咋防啊!

鱼皮:常用的是 Web 应用防火墙(WAF)和 DDoS 防护服务。Web 防火墙能防 SQL 注入、XSS 攻击这些应用层攻击,而 DDoS 防护能抵御大规模流量冲击。

但是这些商业级服务都挺贵的,可能一年就是几万几十万……

你惊呼:我为了防止被攻击,还要搭这么多钱?!

鱼皮笑了:好消息是,有些云服务商会提供一点点免费的 DDoS 基础防护,还有相对便宜的轻量版 DDoS 防护包。

我的建议是,刚开始就先用免费的,加上代码里做好防 SQL 注入、XSS 这些安全措施,其实够用了。等网站真做起来、有收入了,再花钱买商业级的防护服务就好。

你点了点头:是呀,如果没收入,被攻击就被攻击吧,哼!

鱼皮微笑道:你这心态也不错哈哈。除了刚才说的这些,随着你网站的成熟,还可能会用到很多第三方服务,比如短信验证码、邮件推送、 等等,这些也都是成本。

总成本

讲到这里,你应该已经了解了视频网站的整个技术架构和成本。

最后再总结一下,如果一个人做个小型的视频网站,一年到底要花多少钱?

你看着这个表,倒吸一口凉气:视频网站的成本真高啊……

鱼皮:没错,这还只是保守估计。如果你的网站真火了,每天几万人看视频,一年光流量费就得有几十万吧。

而且刚才说的都只是网站本身的成本,如果你一个人做累了,要组个团队开发呢?

按照一线城市的成本算算,前端开发 + 后端开发 + 测试工程师 + 运维工程师,再加上五险一金,差不多每月要接近 10 万了。

你瞪大眼睛:那一年就是一百万?

鱼皮:没错,人力成本才是最贵的。

你:好了你别说了,我不做了,我不做了!我现在终于理解为什么那些网站都要收费了……

鱼皮:不过说实话,虽然成本不低,但那些网站收费真的太贵了,其实成本远没那么高,更多的是利用人性赚取暴利!

所以比起花钱看那些乱七八糟的网站,把钱和时间投资在学习上,才是最有价值的。

你点了点头:这次一定!再看一期你的教程,我就睡觉啦~

更多

💻 编程学习交流:编程导航 📃 简历快速制作:老鱼简历 ✏️ 面试刷题神器:面试鸭

前端,不止于 AI。12 月 20 日,FEDAY 2025,长沙见!

作者 裕波
2025年11月17日 12:53

十年,一段旅程。2015 年首届 FEDAY 在广州举办,2025 年 FEDAY 迎来它的第十个年头。十年间,我们走过北京、广州、杭州、成都、厦门,每一次相聚,都留下了属于前端开发者的热度与记忆。

FEDAY 是一场前端人的年度团聚,也是大家一起换个城市、换个风景、换种心情的旅途。当代码、城市与朋友交织在一起,我们就知道这就是 FEDAY 的意义。

从去年开始,我们将 FEDAY 的主题定为「前端,不止于 AI」。AI 不只是话题,它正在重塑我们的工具、工作方式与思考边界。而 FEDAY 的使命,就是在这场巨变中,继续为前端人提供方向、灵感与连接。

2025 年 12 月 20 日,FEDAY 2025 将在长沙举办。今年计划给将带来 10 个演讲主题,邀请来自行业内的大牛,与大家一起探讨 AI 时代的前端开发。

大会网站:fequan.com/2025 

今年我们取消了 VIP 门票,改为会后自愿 AA 聚餐,大家围坐一桌,畅聊技术、理想与生活。没有距离,没有门槛,只有开发者之间最真诚的共鸣。

十年 FEDAY,一路前行。我们相信:前端,不止于 AI。

12 月 20 日,长沙见。让我们继续,一起创造新的十年。

❌
❌