普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月12日掘金 前端

从零实现富文本编辑器#10-React视图层适配器的模式扩展

作者 WindRunnerMax
2026年1月12日 11:11

在编辑器最开始的架构设计上,我们就以MVC模式为基础,分别实现模型层、核心层、视图层的分层结构。在先前我们讨论的主要是模型层以及核心层的设计,即数据模型以及编辑器的核心交互逻辑,在这里我们以React为例,讨论其作为视图层的模式扩展设计。

从零实现富文本编辑器系列文章

概述

多数编辑器实现了本身的视图层,而重新设计视图层需要面临渲染问题,诸如处理复杂的DOM更新、差量更新的成本,在业务上也无法复用组件生态,且存在新的学习成本。因此我们需要能够复用现有的视图层框架,例如React/Vue/Angular等,这就需要在最底层的架构上就实现可扩展视图层的核心模式。

复用现有的视图层框架也就意味着,在核心层设计上不感知任何类型的视图实现,针对诸如选区的实现也仅需要关注最基础的DOM操作。而这也就导致我们需要针对每种类型的框架都实现对应的视图层适配器,即使其本身并不会特别复杂,但也会是需要一定工作量的。

虽然独立设计视图层可以解决视图层适配成本问题,但相应的会增加维护成本以及包本身体积,因此在我们的编辑器设计上,我们还是选择复用现有的视图层框架。然而,即使复用视图层框架,适配富文本编辑器也并非是一件简单的事情,需要关注的点包括但不限于以下几部分:

  • 视图层初始状态渲染: 生命周期同步、状态管理、渲染模式、DOM映射状态等。
  • 内容编辑的增量更新: 不可变对象、增量渲染、Key值维护等。
  • 渲染事件与节点检查: 脏DOM检查、选区更新、渲染Hook等。
  • 编辑节点的组件预设: 零宽字符、Embed节点、Void节点等。
  • 非编辑节点内容渲染: 占位节点、只读模式、插件模式、外部节点挂载等。

此外,基于React实现视图层适配器,相当于重新深入学习React的渲染机制。例如使用memo来避免不必要的重渲染、使用useLayoutEffect来控制DOM渲染更新时机、严格控制父子组件事件流以及副作用执行顺序等、处理脏DOM的受控更新等。

除了这些与React相关的实现,还有一些样式有关的问题需要注意。例如在HTML默认连续的空白字符,包括空格、制表符和换行符等,在渲染时会被合并为一个空格,这样就会导致输入的多个空格在渲染时只显示一个空格。

为了解决这个问题,有些时候我们可以直接使用HTML实体 来表示这些字符来避免渲染合并,然而我们其实可以用更简单的方式来处理,即使用CSS来控制空白符的渲染。下面的几个样式分别控制了不同的渲染行为:

  • whiteSpace: "pre-wrap": 表示在渲染时保留换行符以及连续的空白字符,但是会对长单词进行换行。
  • wordBreak: "break-word": 可以防止长单词或URL溢出容器,会在单词内部分行,对中英文混合内容特别有用。
  • overflowWrap: "break-word": 同样是处理溢出文本的换行,但自适应宽度时不考虑长单词折断,属于当前标准写法。
<div
  style={{
    whiteSpace: "pre-wrap",
    wordBreak: "break-word",
    overflowWrap: "break-word",
  }}
></div>

这其中word-break是早期WebKit浏览器的实现,给word-break添加了一个非标准的属性值break-word。这里存在问题是,CSS在布局计算中,有一个环节叫Intrinsic Size即固有尺寸计算,此时如果给容器设置width: min-content时,容器就坍塌成了一个字母的宽度。

<style>
  .wrapper { background: #eee; padding: 10px; width: min-content; border: 1px solid #999; }
  .standard { overflow-wrap: break-word; }
  .legacy { word-break: break-word; }
</style>
<h3>overflow-wrap: break-word</h3>
<div class="wrapper standard">
  Supercalifragilistic
</div>
<h3>word-break: break-word</h3>
<div class="wrapper legacy">
  Supercalifragilistic
</div>

生命周期同步

从最开始的编辑器设计中,我们就已经将核心层和视图层分离,并且为了更灵活地调度编辑器,我们将编辑器实例化的时机交予用户来控制。这种情况下若是暴露出Editorref/useImperativeHandle接口实现,就可以不必要设置null的状态,通常情况下的调度方式类似于下面的实现:

const Editor = () => {
  const editor = useMemo(() => new Editor(), []);
  return <Editable editor={editor} />;
}

此外,我们需要在SSR渲染或者生成SSG静态页面时,编辑器的渲染能够正常工作。那么通常来说,我们需要独立控制编辑器对于DOM操作的时机,实例化编辑器时不会进行任何DOM操作,而只有在DOM状态准备好后,才会进行DOM操作。那么最常见的时机,就是组件渲染的副作用Hook

const Editable: React.FC = (props) => {
  const ref = useRef<HTMLDivElement>(null);
  useLayoutEffect(() => {
    const el = ref.current;
    el && editor.mount(el);
  }, [editor]);
  return <div ref={ref}></div>;
}

然而在这种情况下,编辑器实例化的生命周期和Editable组件的生命周期必须要同步,这样才能保证编辑器的状态和视图层的状态一致。例如下面的例子中,Editable组件的生命周期与Editor实例的生命周期不一致,会导致视图所有的插件卸载。

const Editor = () => {
  const editor = useMemo(() => new Editor(), []);
  const [key, setKey] = useState(0);
  return (
    <Fragment>
      <button onClick={() => setKey((k) => k + 1)}>切换</button>
      {key % 2 ? <div><Editable editor={editor} /></div> : <Editable editor={editor} />}
    </Fragment>
  );
}

上面的例子中,若是Editable组件层级一致其实是没问题的,即使存在条件分支React也会直接复用,但层级不一致的话会导致Editable组件级别的卸载和重新挂载。当然本质上就是组件生命周期导致的问题,因此可以推断出若是同一层级的组件,设置为不同的key值也会触发同样的效果。

const Editor = () => {
  const editor = useMemo(() => new Editor(), []);
  const [key, setKey] = useState(0);
  return (
    <Fragment>
      <button onClick={() => setKey((k) => k + 1)}>切换</button>
      <Editable key={key} editor={editor} />
    </Fragment>
  );
}

这里的核心原因实际上是,Editable组件的useEffect钩子会在组件卸载时触发清理函数,而编辑器实例化的时候是在最外层执行的。那么编辑器实例化和卸载的时机是无法对齐的,卸载后所有的编辑器功能都会销毁,只剩下纯文本的内容DOM结构。

useLayoutEffect(() => {
  const el = ref.current;
  el && editor.mount(el);
  return () => {
    editor.destroy();
  };
}, [editor]);

此外,实例化独立的编辑器后,同样也不能使用多个Editable组件来实现编辑器的功能,也就是说每个编辑器实例都必须要对应一个Editable组件,多个编辑器组件的调度会导致整个编辑器状态混乱。

const Editor = () => {
  const editor = useMemo(() => new Editor(), []);
  return (
    <Fragment>
      <Editable editor={editor} />
      <Editable editor={editor} />
    </Fragment>
  );
}

因此为了避免类似的问题,我们是应该以最开始的实现方式一致,也就是说整个编辑器组件都应该是合并为独立组件的。那么类似于上述出现问题的组件应该有如下的形式,将实例化的编辑器和Editable组件合并为独立组件,这样就能够将生命周期完全对齐而非交给用户侧实现。

const Editor = () => {
  const editor = useMemo(() => new Editor(), []);
  return <Editable editor={editor} />;
};

const App = () => {
  const [key, setKey] = useState(0);
  return (
    <Fragment>
      <button onClick={() => setKey((k) => k + 1)}>切换</button>
      {key % 2 ? <div><Editor /></div> : <Editor />}
    </Fragment>
  );
}

实际上我们完全可以将实例化编辑器这个行为封装到Editable组件中的,但是为了更灵活地调度编辑器,将实例化放置于外层更合适。此外,从上述的Effect中实际上可以看出,与onMount实例挂载对齐的生命周期应该是Unmount,因此这里更应该调度编辑器卸载DOM的事件。

然而若是将相关的事件粒度拆得更细,即在插件中同样需要定义为onMountonUnmount的生命周期,这样就能更好地控制相关处理时机问题。然而这种情况下,对于用户来说就需要有更复杂的插件逻辑,与DOM相关的事件绑定、卸载等都需要用户明确在哪个生命周期调用。

即使分离了更细粒度的生命周期,编辑器的卸载时机仍然是需要主动调用的,不主动调用则会存在可能的内存泄漏问题。在插件的实现中用户是可能直接向DOM绑定事件的,这些事件在编辑器卸载时是需要主动解绑的,将相关的约定都学习一边还是存在一些心智负担。

实际上如果能实现更细粒度的生命周期,对于整个编辑器的实现是更高效的,毕竟若是避免实例化编辑器则可以减少不必要的状态变更和事件绑定。因此这里还是折中实现,若是用户需要避免编辑器的卸载事件,可以通过preventDestroy参数来实现,用户在编辑器实例化生命周期结束主动卸载。

export const Editable: React.FC<{
  /**
   * 避免编辑器主动销毁
   * - 谨慎使用, 生命周期结束必须销毁编辑器
   * - 注意保持值不可变, 否则会导致编辑器多次挂载
   */
  preventDestroy?: boolean;
}> = props => {
  const { preventDestroy } = props;
  const { editor } = useEditorStatic();

  useLayoutEffect(() => {
    const el = ref.current;
    el && editor.mount(el);
    return () => {
      editor.unmount();
      !preventDestroy && editor.destroy();
    };
  }, [editor, preventDestroy]);
}

状态管理

对于状态管理我们需要从头开始,在这里我们先来实现全量更新模式,但是要在架构设计上留好增量的更新模式。那么思考核心层与视图层中的通信与状态管理,使用Context/Redux/Mobx是否可以避免自行维护各个状态对象,也可以达到局部刷新而不是刷新整个页面的效果。

深入思考一下似乎并不可行,以Context管理状态为例,即使有immer.js似乎也做不到局部刷新,因为整个delta的数据结构不能够达到非常完整的与props对应的效果。诚然我们可以根据op & attributes作为props再在组件内部做数据转换,但是这样似乎并不能避免维护一个状态对象。

由此最基本的是应该要维护一个LineState对象,每个op可能与前一个或者后一个有状态关联,以及行属性需要处理,这样一个基础的LineState对象是必不可少的。再加上是要做插件化的,那么给予react组件的props应该都实际上可以隐藏在插件里边处理。

如果我们使用immer的话,似乎只需要保证插件给予的参数是不变的即可,但是同样的每一个LineState都会重新调用一遍插件化的render方法。这样确实造成了一些浪费,即使能够保证数据不可变即不会再发生re-render,但是如果在插件中解构了这个对象或者做了一些处理,那么又会触发更新。

那么既然LineState对象不可避免,如果再在这上边抽象出一层BlockState来管理LineState。再通过ContentChange事件作为bridge,以及这种一层管理一层的方式,精确地更新每一行,减少性能损耗,甚至于精确的得知究竟是哪几个op更新了,做到完全精准更新也不是不可能。

由此回到最初实现State模块更新文档内容时,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。

delta.eachLine((line, attributes, index) => {
  const lineState = new LineState(line, attributes, this);
  lineState.index = index;
  lineState.start = offset;
  lineState.key = Key.getId(lineState);
  offset = offset + lineState.length;
  this.lines[index] = lineState;
});

这种方式简单直接,全量更新状态能够保证在React的状态更新,然而这种方式的问题在于性能,当文档内容非常大的时候,全量计算将会导致大量的状态重建。并且其本身的改变也会导致Reactdiff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。

不过,上述的监听OnContentChange事件来更新视图的方式,是完全没有问题的,这也是连接编辑器核心层和视图层的重要部分。React的视图更新需要setState来触发状态变更,那么从核心层的OnContentChange事件中触发更新,就可以将状态的更新应用到视图层。

const BlockView: FC = props => {
  const [lines, setLines] = useState(() => state.getLines());

  const onContentChange = useMemoFn(() => {
    setLines(state.getLines());
  });
}

这里其实还有个有趣的事情,假设我们在核心层中同步地多次触发了状态更新,则会导致多次视图层更新。这是个比较常见的事情,当一个事件作用到状态模型时,可能调用了若干指令,产生了多个Op,若每个Op的应用都进行一次视图同步,代价会十分高昂。

不过在React中,这件事的表现并没有那么糟糕,因为React本身会合并起来异步更新视图,但我们仍然可以避免多次无效的更新,可以减少React设置状态这部分的开销。具体的逻辑是,同步更新状态时,通过一个状态变量守卫住React状态更新,直到异步操作时才更新视图层。

在下面的这段代码中,可以举个例子同步等待刷新的队列为||||||||,每一个|都代表着一次状态更新。进入更新步骤即首个|后, 异步队列行为等待, 同步的队列由于!flushing全部被守卫。主线程执行完毕后, 异步队列开始执行, 此时拿到的是最新数据, 以此批量重新渲染。

/**
 * 数据同步变更, 异步批量绘制变更
 */
const onContentChange = useMemoFn(() => {
  if (flushing.current) return void 0;
  flushing.current = true;
  Promise.resolve().then(() => {
    flushing.current = false;
    setLines(state.getLines());
  });
});

回到更新这件事本身,即使全部重建了LineState以及LeafState,也需要尽可能找到其原始的LineState以便于复用其key值,避免整个行的re-mount。当然即使复用了key值,因为重建了State实例,React也会继续后边的re-render流程。

这样的全量更新自然是存在性能浪费的,特别是我们的数据模型都是基于原子化Op的,不实现增量更新本身也并不合理。因此我们需要在核心层实现增量更新的逻辑,例如Immutable的状态序列对象、Key值的复用等,视图层也需要借助memo等来避免无效渲染。

渲染模式

我们希望实现的是视图层分离的通信架构,相当于所有的渲染都采用React,类似于Slate的架构设计。而Facebook在推出的Draft富文本引擎中,是使用纯React来渲染的,然后当前Draft已经不再维护,转而推出了Lexical

后来我发现Lexical虽然是Facebook推出的,但是却没用React进行渲染,从DOM节点上就可以看出来是没有Fiber的,因此可以确定普通的节点并不是React渲染的。诚然使用React可能存在性能问题,而且由于非受控模式下可能会造成crash,但是能够直接复用视图层还是有价值的。

LexicalREADME中可以看到是可以支持React的,那么这里的支持实际上仅有DecoratorNode可以用React来渲染,例如在Playground中加入ExcaliDraw画板的组件的话,就可以发现svg外的DOM节点是React渲染的,可以发现React组件是额外挂载上去的。

在下面的例子中,可以看到p标签的属性中是lexical注入的相关属性,而子级的span标签则是lexical装饰器节点,用于渲染React组件,这部分都并非是React渲染的。而明显的,button标签则明显是React渲染的,因为其有Fiber相关属性,这也验证了分离渲染模式。

<!-- 
  __lexicalKey_gqfof: "4"
  __lexicalLineBreak: br
  __lexicalTextContent: "\n\n\n\n" 
-->
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
  <!-- 
    __lexicalKey_gqfof: "5"
    _reactListening2aunuiokal: true
  -->
  <span class="editor-image" data-lexical-decorator="true" contenteditable="false">
    <!-- 
      __reactFiber$zski0k5fvkf: { ... }
      __reactProps$zski0k5fvkf: { ... }
    -->
    <button class="excalidraw-button "><!-- ... --></button>
  </span>
</p>

也就是说,仅有Void/Embed类型的节点才会被React渲染,其他的内容都是普通的DOM结构。这怎么说呢,就有种文艺复兴的感觉,如果使用Quill的时候需要将React结合的话,通常就需要使用ReactDOM.render的方式来挂载React节点。

Lexical还有一点是,需要协调的函数都需要用$符号开头,这也有点PHP的文艺复兴。Facebook实现的框架就比较喜欢规定一些约定性的内容,例如ReactHooks函数都需要以use开头,这本身也算是一种心智负担。

那么有趣的事,在Lexical中我是没有看到使用ReactDOM.render的方法,所以我就难以理解这里是如何将React节点渲染到DOM上的。于是在useDecorators中找到了Lexical实际上是以createPortal的方法来渲染的。

// https://react-lab.skyone.host/
const Context = React.createContext(1);
const Customer = () => <span>{React.useContext(Context)}</span>;
const App = () => {
  const ref1 = React.useRef<HTMLDivElement>(null);
  const ref2 = React.useRef<HTMLDivElement>(null);
  const [decorated, setDecorated] = React.useState<React.ReactPortal | null>(null);
    
  React.useEffect(() => {
    const div1 = ref1.current!;
    setDecorated(ReactDOM.createPortal(<Customer />, div1));
    const div2 = ref2.current!;
    ReactDOM.render(<Customer />, div2);
  }, []);
    
  return (
    <Context.Provider value={2}>
      {decorated}
      <div ref={ref1}></div>
      <div ref={ref2}></div>
      <Customer></Customer>
    </Context.Provider>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

使用这种方式实际与ReactDOM.render效果基本一致,但是createPortal是可以自由使用Context的,且在React树渲染的位置是用户挂载的位置。实际上讨论这部分的主要原因是,我们在视图层的渲染并非需要严格使用框架来渲染,分离渲染模式也是能够兼容性能和生态的处理方式。

DOM 映射状态

在实现MVC架构时,理论上控制器层以及视图层都是独立的,控制器不会感知视图层的状态。但是在我们具体实现过程中,视图层的DOM是需要被控制器层处理的,例如事件绑定、选区控制、剪贴板操作等等,那么如何让控制器层能够操作相关的DOM就是个需要处理的问题。

理论上而言,我们在DOM上的设计是比较严格的,即data-block节点属块级节点,而data-node节点属行级节点,data-leaf节点则属行内节点。block节点下只能包含node节点,而node节点下只能包含leaf节点。

<div contenteditable style="outline: none" data-block>
  <div data-node><span data-leaf><span>123</span></span></div>
  <div data-node>
    <span data-leaf><span contenteditable="false">321</span></span>
  </div>
  <div data-node><span data-leaf><span>123</span></span></div>
</div>

那么在这种情况下,我们是可以在控制器层通过遍历DOM节点来获取相关的状态的,或者诸如querySelectorAllcreateTreeWalker等方法来获取相关的节点。但是这样明显是会存在诸多无效的遍历操作,因此我们需要考虑是否有更高效的方式来获取相关的节点。

React中我们可以通过ref来获取相关的节点,那么如何将DOM节点对象映射到相关编辑器对象上。我们此时存在多个状态对象,因此可以将相关的对象完整一一映射到对应的主级DOM结构上,而且Js中我们可以使用WeakMap来维护弱引用关系。

export class Model {
  /** DOM TO STATE */
  protected DOM_MODEL: WeakMap<HTMLElement, BlockState | LineState | LeafState>;
  /** STATE TO DOM */
  protected MODEL_DOM: WeakMap<BlockState | LineState | LeafState, HTMLElement>;

  /**
   * 映射 DOM - LeafState
   * @param node
   * @param state
   */
  public setLeafModel(node: HTMLSpanElement, state: LeafState) {
    this.DOM_MODEL.set(node, state);
    this.MODEL_DOM.set(state, node);
  }
}

React组件的ref回调函数中,我们需要通过setLeafModel方法来将DOM节点映射到LeafState上。在React中相关执行时机为ref -> layout effect -> effect,且需要保证引用不变, 否则会导致回调在re-render时被多次调用null/span状态。

export const Leaf: FC<LeafProps> = props => {
  /**
   * 处理 ref 回调
   */
  const onRef = useMemoFn((dom: HTMLSpanElement | null) => {
    dom && editor.model.setLeafModel(dom, lineState);
  });

  return (
    <span ref={onRef} {...{ [LEAF_KEY]: true }}>
      {props.children}
    </span>
  );
};

总结

在先前我们主要讨论了模型层以及核心层的设计,即数据模型以及编辑器的核心交互逻辑,也包括了部分DOM相关处理的基础实现。还重点讲述了选区的状态同步、输入的状态同步,并且处理了相关实现在浏览器中的兼容问题。

在当前部分,我们主要讨论了视图层的适配器设计,主要是全量的视图初始化渲染,包括生命周期同步、状态管理、渲染模式、DOM映射状态等。接下来我们需要处理变更的增量更新,这属于性能方面的优化,我们需要考虑如何最小化DOM以及Op操作,以及在React中实现增量渲染的方式。

每日一题

参考

前端向架构突围系列 - 框架设计(四):依赖倒置原则(DIP)

2026年1月12日 11:08

写在前面

在前端圈子里,我们常说“这个组件太耦合了”或者“这段逻辑改不动”。大多数人习惯性地把锅甩给业务复杂或者前人留下的“屎山”。但如果我们冷静下来复盘,你会发现绝大多数的维护噩梦都指向同一个设计缺陷:高层逻辑被低层细节给绑架了。

今天我们要聊的依赖倒置原则(Dependency Inversion Principle, DIP) ,就是专门用来破解这种“死局”的架构利器。


image.png

一、 场景:一次痛苦的“技术升级”

想象一下,你负责一个复杂的 B 端系统。半年前,为了快速上线,你直接在业务 Hooks 里引入了 Axios,并散落到了项目的各个角落:

// useUser.ts - 典型的“自上而下”依赖
import axios from 'axios'; 

export const useUser = (id: string) => {
  const fetchUser = async () => {
    // 业务逻辑直接依赖了具体的实现:axios
    const res = await axios.get(`/api/user/${id}`);
    return res.data;
  };
  return { fetchUser };
};

噩梦开始了: 由于公司架构调整,所有请求必须从 Axios 切换到公司自研的 RPC SDK,或者需要统一接入一套极其复杂的签名加密逻辑。

你看着全局搜索出来的 200 多个 axios 引用,陷入了沉思。不仅要改代码,还要面对全量回归测试的风险。这时候你才会意识到:你的业务逻辑,已经和具体的网络库“殉情”了。


二、 什么是依赖倒置?(别背定义,看本质)

传统的开发思维是自顶向下的:页面依赖组件,组件依赖工具类。这就像在盖房子时,把电线直接浇筑在混凝土里,想换根线就得拆墙。

依赖倒置原则核心就两句话:

  1. 高层模块不应依赖低层模块,两者都应依赖抽象。
  2. 抽象不应依赖细节,细节应依赖抽象。

通俗点说:谁拥有接口(Interface),谁就是老大。

在架构突围中,我们要把“控制权”翻转过来。高层业务不应该问:“我该怎么去调用 Axios?”而是应该傲娇地声明:“我需要一个能发请求的东西,至于你是用 Axios 还是 Fetch,我不在乎。”

image.png


三、 代码案例:从“死耦合”到“神解耦”

让我们用 DIP 的思维重构上面的例子。

1. 定义抽象(Interface)

首先,在业务层定义我们要的“形状”,这叫建立契约

// domain/http.ts - 这是我们的“主权声明”
export interface IHttpClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, data: any): Promise<T>;
}

2. 业务层依赖抽象

现在的业务 Hook 不再关心具体的库。

// useUser.ts
import { IHttpClient } from '../domain/http';

export const useUser = (id: string, client: IHttpClient) => {
  const fetchUser = async () => {
    // 业务只对接口负责
    return await client.get(`/api/user/${id}`);
  };
  return { fetchUser };
};

3. 底层实现细节

具体的库(Axios/Fetch)只是契约的执行者。

// infra/AxiosClient.ts
import axios from 'axios';
import { IHttpClient } from '../domain/http';

export class AxiosClient implements IHttpClient {
  async get<T>(url: string): Promise<T> {
    const res = await axios.get(url);
    return res.data;
  }
  // ...实现其他方法
}

4. 依赖注入(DI)

在应用的最顶层(入口处),我们将具体的实现“注入”进去。

// App.tsx
const httpClient = new AxiosClient(); // 后续换成 RPCClient 只需要改这一行
const { fetchUser } = useUser('123', httpClient);

四、 深度思考:前端架构里的 DIP 怎么玩?

如果你觉得上面的代码只是多写了几行 Interface,那就小看 DIP 了。在复杂的前端工程中,DIP 是实现以下架构的关键:

1. 跨端架构的统一

如果你在做一套同时支持 Web 和 小程序的方案。业务逻辑应该是同一套,通过 DIP,你可以为 Web 注入 WebAdapter,为小程序注入 MiniProgramAdapter

想象一下,你的团队要开发一个电商应用的“用户模块”,包含登录、获取用户信息、修改收货地址等功能。这个模块需要同时跑在 Web 端(用 React/Vue)和 微信小程序端

业务逻辑(Business Logic)本身其实是一样的:

“用户点击保存 -> 校验表单 -> 发起网络请求保存数据 -> 更新本地状态 -> 提示成功”

但是,底层的技术实现(Infrastructure Layer)却说着完全不同的“方言”:

功能点 Web 端 "方言" 小程序端 "方言"
网络请求 fetchaxios wx.request
本地存储 localStorage.setItem wx.setStorage
路由跳转 history.push / router.push wx.navigateTo
交互反馈 Antd Message / ElementUI Notification wx.showToast
错误示范:传统的“自上而下”强耦合

如果不适用 DIP,为了复用代码,很多团队会写出这种充斥着环境判断的“面条代码”:

// user.service.ts (糟糕的设计)
import axios from 'axios';

// 假设通过某种方式注入了环境变量 IS_MINI_PROGRAM
export const getUserProfile = async (id: string) => {
  // 业务逻辑里夹杂着环境判断
  if (IS_MINI_PROGRAM) {
    // 小程序方言
    
  } else if (IS_WEB) {
    // Web 方言
    
  } else if ...
};

后果: 你的业务逻辑层被迫知道了太多它不该知道的“底层细节”。每增加一个端(比如又要支持阿里小程序、字节小程序),这里就要加一个 else if,代码迅速腐化,难以维护。

正确示范:DIP 主导的跨端统一架构

用 DIP 的思路,我们要反客为主。业务层不再去迁就各个端的方言,而是制定一套“官方语言”(Interface),要求各个端配备“翻译官”(Adapter)来适配这套语言。

// --- Core Business Layer (核心业务层,与平台无关) ---

// domain/interfaces/http.interface.ts
// 定义网络请求的契约
export interface IHttpClient {
  get<T>(url: string, params?: any): Promise<T>;
  post<T>(url: string, data?: any): Promise<T>;
}

// domain/interfaces/storage.interface.ts
// 定义本地存储的契约
export interface IStorage {
  setItem(key: string, value: string): Promise<void> | void;
  getItem(key: string): Promise<string | null> | string | null;
}
编写纯净的业务逻辑(依赖抽象)

现在的业务 Service 代码非常干净,它只依赖上面的接口。它不知道自己运行在哪里,它只知道自己有一个能发请求的对象(http)和一个能存东西的对象(storage)。

// --- Core Business Layer ---

// services/userService.ts
import { IHttpClient } from '../domain/interfaces/http.interface';
import { IStorage } from '../domain/interfaces/storage.interface';

export class UserService {
  // 通过构造函数注入依赖 (DI)
  constructor(
    private http: IHttpClient,
    private storage: IStorage
  ) {}

  async login(username: string) {
    // 1. 调用 HTTP 接口
    const user = await this.http.post('/api/login', { username });
    // 2. 调用 Storage 接口
    await this.storage.setItem('user_token', user.token);
    return user;
  }
}
各端派遣“翻译官”(实现适配器)

现在轮到基础设施层(Infra Layer)干活了。我们需要为 Web 端和小程序端分别实现上述接口。这就是所谓的 Adapter(适配器)模式

Web 端适配器:
// --- Infra Layer (Web) ---

// infra/web/AxiosHttpClient.ts
import axios from 'axios';
import { IHttpClient } from '../../domain/interfaces/http.interface';

// 这就是 Web 端的翻译官,把标准语言翻译成 Axios 方言
export class AxiosHttpClient implements IHttpClient {
  async get<T>(url: string, params?: any): Promise<T> {
    const res = await axios.get(url, { params });
    return res.data;
  }
  // ... implement post
}

// infra/web/LocalStorageAdapter.ts
import { IStorage } from '../../domain/interfaces/storage.interface';

export class LocalStorageAdapter implements IStorage {
  setItem(key: string, value: string) {
    localStorage.setItem(key, value);
  }
  // ... implement getItem
}
小程序端适配器:
// --- Infra Layer (Mini Program) ---

// infra/mp/WechatHttpClient.ts
import { IHttpClient } from '../../domain/interfaces/http.interface';

// 这就是小程序端的翻译官,把标准语言翻译成 wx.request 方言
export class WechatHttpClient implements IHttpClient {
  async get<T>(url: string, params?: any): Promise<T> {
    // 将 callback 风格封装成 Promise 风格以符合接口要求
    return new Promise((resolve, reject) => {
      wx.request({
        url: `https://api.myapp.com${url}`, // 小程序需要完整 URL
        data: params,
        method: 'GET',
        success: (res) => resolve(res.data as T),
        fail: reject
      });
    });
  }
  // ... implement post
}
// 类似地实现 WechatStorageAdapter...
在入口处组装(依赖注入)

这是最后一步见证奇迹的时刻。在不同端的入口文件里,我们将对应的“翻译官”注入到业务逻辑中。

Web 端入口 (main.web.ts / App.tsx):
import { UserService } from './services/userService';
import { AxiosHttpClient } from './infra/web/AxiosHttpClient';
import { LocalStorageAdapter } from './infra/web/LocalStorageAdapter';

// 组装 Web 版的 User Service
const webUserService = new UserService(
  new AxiosHttpClient(),
  new LocalStorageAdapter()
);

// 现在 webUserService 可以直接在 React/Vue 组件中使用了
小程序端入口 (app.ts / main.mp.ts):
import { UserService } from './services/userService';
import { WechatHttpClient } from './infra/mp/WechatHttpClient';
import { WechatStorageAdapter } from './infra/mp/WechatStorageAdapter';

// 组装小程序版的 User Service
const mpUserService = new UserService(
  new WechatHttpClient(),
  new WechatStorageAdapter()
);

// mpUserService 可以在小程序的 Page 或 Component 中使用了
总结

通过 DIP,我们将跨端架构分成了清晰的三层:

  1. 核心业务层(稳定) :定义 Interface,编写业务逻辑。这一层代码在多端是完全共用的,一行都不用改。
  2. 接口契约层(抽象) :即 IHttpClient, IStorage 等 Interface 定义。
  3. 基础设施层(易变) :各个端的具体 Adapter 实现(WebAdapter, MiniProgramAdapter)。

2. 制定官方语言(定义抽象接口)

业务层声明它需要什么能力,而不关心这能力怎么实现。这些 Interface 定义在核心业务域中。

3. 无感知的 Mock 与测试

写单元测试最痛苦的是 Mock 全局库。如果你遵循了 DIP,你只需要给业务逻辑注入一个 MockClient,连 jest.mock('axios') 这种黑盒操作都不用了。

4. 插件化架构

像 VS Code 或大型低代码平台,其核心框架并不依赖具体的插件。它定义了一套规范(抽象),所有的插件必须实现这些规范,这正是 DIP 的高级应用。


五、 结语:突围的核心是“心智负担”的转移

很多前端同学抗拒 DIP,觉得“我就写个业务,有必要搞这么复杂吗?”

确实,对于三天就扔的小程序,DIP 属于过度设计。但如果你在构建一个长期迭代的工程,DIP 的本质是在隔离变化。它把最不稳定的部分(第三方库、API 协议、浏览器差异)挡在了抽象层之外。

架构突围,不是为了炫技,而是为了在下一次需求变动、技术迁移时,你能气定神闲地改一行代码,而不是通宵改两百个文件。

互动环节: 你在项目中遇到过“因为换个库导致全线崩溃”的经历吗?或者你觉得在开发中,Context API 是否已经足够支撑起 DIP 的职责?欢迎在评论区博弈。

【节点】[Channel-Swizzle节点]原理解析与实际应用

作者 SmalBox
2026年1月12日 11:01

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

Swizzle节点的核心概念深度解析

在Unity通用渲染管线(URP)的Shader Graph可视化着色器编辑器中,Swizzle节点承担着矢量分量重排的关键功能。这种称为"重排"的操作模式,在计算机图形学中具有深厚的理论基础,是高效处理图形数据的重要技术手段。

矢量作为基础数学概念,能够描述方向和大小的双重属性。在游戏和应用开发中,矢量广泛应用于描述基本属性,包括角色位置、运动速度或物体间距离。理解矢量算术对于图形编程、物理模拟和动画制作至关重要,而Swizzle节点正是这一知识在Shader Graph中的具体实现。

重排操作的基本数学原理

重排操作的本质

重排操作的本质是对矢量分量的重新排列与组合。在着色器编程中,矢量通常包含多个分量,如位置坐标(x,y,z)、颜色值(r,g,b,a)或纹理坐标(u,v)等。通过Swizzle节点,开发者可以灵活地操作这些分量关系:

  • 分量提取:从高维矢量中提取特定维度的子集
  • 顺序调整:改变分量排列顺序以适应特定算法
  • 数据转换:在不同数据表示格式间进行转换

重排掩码语法规则详解

双系统字符表示体系

  • 坐标系统:使用x、y、z、w表示矢量的四个分量
  • 颜色系统:使用r、g、b、a表示颜色的四个通道

这两种表示系统在功能上完全等效,但在语义上有所区别。坐标系统更适合处理位置、法线等几何数据,而颜色系统更直观地处理颜色相关信息。

掩码维度映射规则

单字符掩码

  • 输出标量值
  • "x" - 输出输入矢量的x分量
  • "r" - 输出输入矢量的红色通道
  • 适用场景:提取单个数值分量

双字符掩码

  • 输出二维矢量
  • "xy" - 输出包含x和y分量的Vector2
  • "gb" - 输出包含绿色和蓝色通道的Vector2
  • 适用场景:纹理坐标处理、二维向量运算

三字符掩码

  • 输出三维矢量
  • "xyz" - 输出包含x、y、z分量的Vector3
  • "bgr" - 输出包含蓝、绿、红通道的Vector3
  • 适用场景:位置处理、法线计算、RGB颜色操作

四字符掩码

  • 输出四维矢量
  • "xyzw" - 输出完整的四维矢量
  • "abgr" - 输出包含透明度及颜色通道的Vector4

掩码有效性验证机制

系统会自动检测掩码的有效性,确保:

  • 所有字符都在输入矢量的维度范围内
  • 字符长度在1-4之间
  • 不包含非法字符

端口系统技术特性分析

输入端口特性

  • 类型:动态矢量(Dynamic Vector)
  • 维度支持:Vector2、Vector3、Vector4
  • 数据流:接收上游节点的输出数据

输入端口的设计体现了Shader Graph的类型推断机制,能够自动适应不同维度的输入数据,提供极大的灵活性。

输出端口机制

  • 维度决定:完全由掩码长度控制
  • 类型安全:确保下游节点接收正确维度的数据
  • 性能优化:直接映射到HLSL的高效代码

掩码控制系统全解析

掩码字符可用性规则

Vector2类型输入

  • 有效字符:x、y、r、g
  • 示例掩码:"yx"、"rg"、"xx"、"yy"
  • 无效字符:z、w、b、a

Vector3类型输入

  • 有效字符:x、y、z、r、g、b
  • 示例掩码:"zyx"、"bgr"、"xyz"、"xzy"
  • 无效字符:w、a

Vector4类型输入

  • 有效字符:x、y、z、w、r、g、b、a
  • 示例掩码:"wzyx"、"abgr"、"xyzw"、"rgba"

高级掩码应用技巧

分量重复技术

  • "xxx":创建所有分量相同的三维矢量
  • "rrr":灰度值扩展为RGB矢量
  • "yyyy":单一分量填充的四维矢量

部分重排策略

  • "xyzx":保留部分原始分量顺序
  • "rgbr":颜色通道的创造性组合

Swizzle节点的实际应用场景

数据格式转换应用

颜色空间转换

  • RGB转BGR:"bgr"掩码
  • 添加Alpha通道:"rgba"掩码(需输入为Vector3)
  • 移除Alpha通道:"rgb"掩码

坐标系调整

  • 左手系转右手系:"x-zy"配合乘法节点
  • UV坐标翻转:"yx"实现90度旋转

分量操作技术

分量提取策略

从四维位置矢量中提取三维坐标:

  • 使用"xyz"掩码获取位置
  • 使用"w"掩码单独提取透明度

分量组合技巧

将多个低维矢量组合为高维矢量:

  • 先使用Combine节点,再配合Swizzle调整顺序

数学运算优化应用

矩阵运算准备

  • 调整矢量分量顺序以匹配矩阵乘法要求
  • 准备点积和叉积的输入数据

光照计算优化

  • 重排法线分量以适应光照模型
  • 调整视线方向矢量的分量排列顺序

代码生成机制深度解析

基础代码模式

// 输入:Vector4 In // 掩码:"wzyx" float4 _Swizzle_Out = In.wzyx;

不同类型输入的代码示例

Vector3输入处理

// 输入:Vector3 In // 掩码:"zyx" float3 _Swizzle_Out = In.zyx;

Vector2输入处理

// 输入:Vector2 In // 掩码:"yx" float2 _Swizzle_Out = In.yx;

性能优化策略

计算效率考虑

  • 避免在片段着色器循环内使用复杂重排
  • 利用预计算减少运行时重排操作
  • 结合其他数学节点优化整体性能

内存访问优化

  • 合理安排分量访问顺序
  • 减少不必要的中间变量
  • 优化寄存器使用

错误处理与调试指南

常见错误类型

无效掩码错误

  • 原因:使用了超出输入维度的字符
  • 解决方法:检查输入矢量维度,调整掩码字符

维度不匹配警告

  • 原因:输入输出维度不兼容
  • 解决方法:调整掩码长度或使用类型转换节点

调试技术

  • 使用预览窗口实时查看输出结果
  • 逐步测试简单掩码以隔离问题
  • 利用帧调试器分析运行时行为

高级应用开发

动态重排模拟

虽然Swizzle节点本身不支持动态掩码,但可以通过以下方法模拟:

条件分支方案

  • 使用Branch节点根据条件选择不同Swizzle
  • 多个Swizzle节点并行处理
  • 使用Lerp节点平滑过渡

多节点协作架构

处理链设计

Swizzle节点 + Multiply节点 + Add节点 实现复杂数学变换

反馈循环系统

Swizzle节点配合Custom Function节点 创建自适应的着色效果

跨平台兼容性分析

图形API支持情况

  • HLSL:完全支持重排语法
  • GLSL:支持类似的重排操作
  • Metal:提供等效的功能实现

平台特定优化

  • 针对不同GPU架构调整重排策略
  • 考虑移动平台的性能限制
  • 优化着色器变体管理

实际开发案例研究

案例一:高级颜色处理

需求描述

开发一个支持多种颜色空间转换的着色器

技术实现

  • 使用Swizzle节点实现RGB↔BGR转换
  • 配合其他节点处理Gamma校正
  • 实现HDR颜色映射

案例二:复杂几何变换

需求描述

实现基于法线的动态变形效果

解决方案

  • Swizzle节点调整法线分量顺序
  • 结合噪声纹理创建自然效果
  • 优化性能保证实时渲染

最佳实践总结

编码规范

  • 使用语义明确的掩码字符
  • 保持重排逻辑的清晰性
  • 文档化复杂的重排操作

性能指南

  • 优先使用简单的重排模式
  • 避免不必要的分量复制
  • 合理利用着色器变体

维护建议

  • 定期审查重排逻辑
  • 测试不同硬件的兼容性
  • 建立效果验证机制

未来发展方向

技术趋势预测

  • 对更高维度矢量的支持
  • 动态掩码功能的实现
  • 更智能的类型推断机制

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

数字孪生项目效率翻倍!AI技术实测与场景验证实录​

作者 Mapmost
2026年1月12日 10:52

引言

AIGC技术正以天为单位刷新认知。从ChatGPT、Midjourney到Sora,生成式AI浪潮一浪高过一浪,数字孪生行业也不例外——如今的技术论坛,不提AIGC仿佛就落伍了。

当前,AIGC在数字孪生领域的应用正逐步深入,但由于数字孪生对精度、数据和可靠性要求高,这与AIGC固有的"概率生成"特性之间存在矛盾。因此,核心问题并非技术是否适用,而在于如何找到AIGC能落地的价值环节。

本文以Mapmost智慧收费站项目为例,探讨AIGC在真实场景中可实现的应用点:

结合AIGC流程的Mapmost智慧收费站应用

01 建模AI辅助:设备建模效率提升

初期尝试文本生成模型时,纯文本输入如开盲盒,同一设备多次生成结果差异大返工成本高。转为图像输入(尤其是多视角照片)后,模型一致性结构比例明显改善。部分AIGC三维模型生成工具已具备组件拆分功能,但实际使用下来,这类工具对角色模型的理解比设备类模型更成熟。

混元3DStudio组件拆分

车辆、摄像头、道闸等原本需大半天手工建模的设备,现几分钟即可生成可用模型。

使用HYPER3D生成的摄像头模型

使用腾讯混元3D生成的车模型

Mapmost智慧收费站项目中,这一点帮我们省了大功夫。项目需要大量不同类型的车辆模型来模拟收费流程,如果纯手工做,光是车型变化就要折腾好几天。用AI生成后,几分钟就能出一辆结构完整的车,样式、颜色还能根据场景需求调整

智慧收费站项目中使用AI生成的汽车模型

02 纹理AI增强:一键优化材质清晰度

数字孪生中,纹理质量直接影响场景真实感,但高清纹理获取往往比建模更困难。AI图像增强工具通过超分辨率算法,可提升低清纹理的清晰度、锐利边缘和材质真实感,将“能用”的纹理变为“好用”的纹理。

Mapmost智慧收费站项目中,实景三维模型手工精模需要衔接顺畅,但两者的纹理都来源于现场拍摄的照片,清晰度参差不齐。手工模型的材质细节不足,质感层级与实景模型对不上,放在同一场景中视觉差异明显。

使用AI增强纹理清晰度

用AI工具增强后,纹理的边缘细节和材质质感都被拉到了同一水平线。两种模型的视觉差异被有效抹平,融合起来的场景不再"各说各话",整体观感更统一,客户现场演示时的沉浸感也提升了好几个档次。

Mapmost智慧收费站融合手工精模与倾斜数据的效果

03 视频AI生成:静态截图秒变动效演示

项目汇报阶段,**“讲明白”“做出来”**同样关键。视频生成AI能根据初始画面和目标画面,自动生成5–10秒的过渡动画,在系统未完全就绪时,快速制作演示素材,使汇报从“凭想象”变为“看得见”

在Mapmost项目前期汇报中,利用该功能快速生成收费站鸟瞰漫游视频,直观展示收费站布局与重点区域。

项目前期使用即梦AI生成的汇报素材

04 Mapmost未来工作流全面升级

未来,智能技术将在数字孪生领域释放更多可能,不止于建模、纹理和视频生成,更能深入理解场景、精准匹配业务需求

对Mapmost来说,这些能力将被无缝融入我们的工作流,让项目实施更快、成本更低,把更多精力聚焦在真正的业务创新与价值创造上——让智慧城市和数字孪生的落地,变得更简单、更高效。

立即体验,开始三维开发之旅!

👉 点击访问官网免费试用:Mapmost官网

前端成功转鸿蒙开发者真实案例,教大家如何开发鸿蒙APP--ArkTS 卡片刷新机制

作者 陈_杨
2026年1月12日 05:35

大家好,我是陈杨,一名有着8 年前端开发经验、6 年技术写作积淀的鸿蒙开发者,也是鸿蒙生态里的一名极客。

曾因前端行业的危机感居安思危,果断放弃饱和的 iOS、安卓赛道,在鸿蒙 API9 发布时,凭着前端技术底子,三天吃透 ArkTS 框架,快速上手鸿蒙开发。三年深耕,我不仅打造了鸿蒙开源图表组件库「莓创图表」,闯进过创新赛、极客挑战赛总决赛,更带着团队实实在在做出了成果 —— 目前已成功上架11 款鸿蒙应用,涵盖工具、效率、创意等多个品类,包括JLPT、REFLEX PRO、国潮纸刻、Wss 直连、ZenithDocs Pro、圣诞相册、CSS 特效等,靠这些自研产品赚到了转型后的第一桶金。

从前端转型到鸿蒙掘金,靠的不是运气,而是选对赛道的眼光和快速落地的执行力。今天这篇文章,就接着上一篇的内容,和大家聊聊 [ArkTS 卡片刷新机制],这篇很重要,认真听。

在 ArkTS 卡片开发中,“内容刷新” 是核心功能之一 —— 无论是实时数据更新(如天气、新闻)、图片替换,还是基于状态的内容切换,都依赖高效的刷新机制。本文基于 HarmonyOS 官方文档,系统梳理卡片刷新的两种核心模式(主动刷新、被动刷新)、特殊场景实现(图片刷新、状态关联刷新)及关键约束,结合完整代码示例,帮助开发者精准落地各类刷新需求。

一、刷新机制核心概述

ArkTS 卡片的刷新能力由系统框架提供,核心依赖两套接口和配置体系:

  • 主动刷新:由卡片提供方(应用)或使用方(如桌面)主动触发,适用于 “按需更新” 场景(如用户点击刷新按钮、应用数据变化);
  • 被动刷新:由系统根据预设规则自动触发,适用于 “周期性更新” 场景(如每天固定时间刷新、每隔 30 分钟更新);
  • 数据传递:刷新时的数据通过formBindingData封装,卡片页面通过@LocalStorageProp接收(数据自动转为 string 类型),确保数据同步的一致性。

关键接口说明:

接口名 调用方 核心作用 约束
updateForm 卡片提供方 主动刷新自身卡片内容 仅能刷新当前应用的卡片,无法操作其他应用卡片
requestForm 卡片使用方(系统应用) 主动请求刷新已添加的卡片 仅能刷新当前宿主中的卡片
setFormNextRefreshTime 卡片提供方 设置下次刷新时间 最短刷新间隔 5 分钟

二、主动刷新:按需触发的精准更新

主动刷新是开发者最常使用的模式,核心是通过updateForm接口手动触发,可搭配用户交互、应用数据变化等场景使用。

2.1 核心实现:提供方主动刷新

卡片提供方(应用)通过formProvider.updateForm接口触发刷新,需传入目标卡片的formId(唯一标识)和更新后的数据。通常与onFormEvent(用户交互触发)、onUpdateForm(生命周期回调)搭配使用。

完整代码示例:用户点击按钮触发刷新

// 1. 卡片页面(WidgetCard.ets):提供刷新按钮,通过postCardAction传递事件
let storage = new LocalStorage();
@Entry(storage)
@Component
struct RefreshButtonCard {
  // 接收FormExtensionAbility传递的formId和数据
  @LocalStorageProp('formId') formId: string = '';
  @LocalStorageProp('currentData') currentData: string = '初始数据';

  build() {
    Column({ space: 20 })
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .padding(15) {

      Text(`当前内容:${this.currentData}`)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)

      // 点击按钮触发刷新事件
      Button("手动刷新")
        .width("80%")
        .height(50)
        .backgroundColor("#5A5FFF")
        .fontColor("#FFFFFF")
        .onClick(() => {
          // 发送message事件给FormExtensionAbility,携带formId
          postCardAction(this, {
            action: "message",
            params: {
              formId: this.formId,
              refreshType: "manual"
            }
          });
        })
    }
  }
}

// 2. FormExtensionAbility(EntryFormAbility.ets):接收事件并执行刷新
import { 
  formBindingData, 
  FormExtensionAbility, 
  formProvider, 
  formInfo 
} from '@kit.FormKit';
import { Want, BusinessError } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG: string = 'EntryFormAbility';
const DOMAIN_NUMBER: number = 0xFF00;

export default class EntryFormAbility extends FormExtensionAbility {
  // 卡片创建时,将formId存入LocalStorage(供页面使用)
  onAddForm(want: Want): formBindingData.FormBindingData {
    hilog.info(DOMAIN_NUMBER, TAG, '[onAddForm] 卡片创建');
    // 获取卡片唯一ID
    const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string;
    // 初始化数据(包含formId)
    const initData = {
      formId: formId,
      currentData: '初始数据'
    };
    return formBindingData.createFormBindingData(initData);
  }

  // 接收卡片页面的message事件,执行刷新
  async onFormEvent(formId: string, message: string): Promise<void> {
    const params = JSON.parse(message);
    hilog.info(DOMAIN_NUMBER, TAG, `[onFormEvent] 触发手动刷新,formId: ${formId}`);

    // 模拟获取新数据(实际场景可替换为接口请求、数据库查询等)
    const newData = {
      currentData: `刷新于 ${new Date().toLocaleTimeString()}`
    };

    // 封装刷新数据并调用updateForm
    const formInfo = formBindingData.createFormBindingData(newData);
    try {
      await formProvider.updateForm(formId, formInfo);
      hilog.info(DOMAIN_NUMBER, TAG, `[onFormEvent] 刷新成功`);
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(DOMAIN_NUMBER, TAG, `[onFormEvent] 刷新失败:code=${err.code}, msg=${err.message}`);
    }
  }
}

2.2 关键注意点

  • formId是核心标识:每个卡片实例的formId唯一,需通过want.parameters[formInfo.FormParam.IDENTITY_KEY]获取(卡片创建时);
  • 异常处理必须:updateForm可能因网络异常、权限不足失败,需通过try/catch捕获BusinessError
  • 数据格式限制:传递的数据需为 JSON 可序列化类型(字符串、数字、对象),复杂类型需手动转换。

三、被动刷新:系统驱动的自动更新

被动刷新无需手动触发,由系统根据配置自动执行,核心分为 “定时刷新”“定点刷新”“下次刷新” 三类,适用于周期性数据更新场景。

3.1 定时刷新:固定间隔自动更新

定时刷新通过form_config.jsonupdateDuration字段配置,单位为 “30 分钟”,需配合updateEnabled: true启用。

配置示例(form_config.json)

{
  "forms": [
    {
      "name": "WeatherWidget",
      "src": "./ets/widget/pages/WeatherCard.ets",
      "uiSyntax": "arkts",
      "updateEnabled": true, // 启用周期性刷新
      "updateDuration": 2, // 刷新周期:2 * 30分钟 = 1小时
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"],
      "colorMode": "auto",
      "isDefault": true
    }
  ]
}

代码实现(EntryFormAbility.ets)

系统触发定时刷新时,会回调onUpdateForm方法,需在此方法中实现数据更新逻辑:

export default class EntryFormAbility extends FormExtensionAbility {
  // 定时刷新触发时执行
  async onUpdateForm(formId: string): void {
    hilog.info(DOMAIN_NUMBER, TAG, `[onUpdateForm] 定时刷新触发,formId: ${formId}`);

    // 模拟请求天气数据(实际场景替换为真实接口)
    const newWeatherData = {
      city: "北京",
      temperature: "25℃",
      updateTime: new Date().toLocaleTimeString()
    };

    const formInfo = formBindingData.createFormBindingData(newWeatherData);
    try {
      await formProvider.updateForm(formId, formInfo);
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(DOMAIN_NUMBER, TAG, `定时刷新失败:${err.message}`);
    }
  }
}

定时刷新核心约束

  • 刷新周期规则:updateDuration为自然数,0 表示不生效;API 11 及以上版本,若应用市场配置了刷新周期,取 “配置值” 和 “应用配置值” 的较大者(如应用配置 1 小时,市场配置 2 小时,则按 2 小时刷新);
  • 配额限制:每张卡片每天最多定时刷新 50 次(包含updateDurationsetFormNextRefreshTime两种方式),0 点重置;
  • 可见性影响:卡片不可见时,系统仅记录刷新动作,待卡片可见后统一刷新布局。

3.2 定点刷新:指定时间自动更新

定点刷新支持 “单时间点” 和 “多时间点” 配置,通过form_config.jsonscheduledUpdateTime(单时间点)或multiScheduledUpdateTime(多时间点)字段设置,需关闭定时刷新(updateDuration: 0`)。

配置示例(多时间点刷新)

{
  "forms": [
    {
      "name": "NewsWidget",
      "src": "./ets/widget/pages/NewsCard.ets",
      "uiSyntax": "arkts",
      "updateEnabled": true,
      "updateDuration": 0, // 关闭定时刷新,优先定点刷新
      "scheduledUpdateTime": "10:30", // 单时间点(兼容旧版本)
      "multiScheduledUpdateTime": "08:00,12:00,18:00", // 多时间点(最多24个)
      "defaultDimension": "2*4",
      "supportDimensions": ["2*4"],
      "isDefault": true
    }
  ]
}

关键说明

  • 优先级:多时间点配置(multiScheduledUpdateTime)优先级高于单时间点(scheduledUpdateTime),两者同时配置时仅多时间点生效;
  • 时间格式:采用 24 小时制,精确到分钟(如08:0016:30),多时间点用英文逗号分隔;
  • 触发逻辑:系统在指定时间点回调onUpdateForm方法,数据更新逻辑与定时刷新一致。

3.3 下次刷新:自定义延迟更新

通过formProvider.setFormNextRefreshTime接口设置下次刷新时间,适用于 “延迟更新” 场景(如用户操作后 5 分钟刷新),最短间隔为 5 分钟。

代码示例

export default class EntryFormAbility extends FormExtensionAbility {
  onFormEvent(formId: string, message: string): void {
    const params = JSON.parse(message);
    if (params.action === "setNextRefresh") {
      // 设置5分钟后刷新(参数单位:分钟)
      const delayMinutes = 5;
      formProvider.setFormNextRefreshTime(formId, delayMinutes, (err: BusinessError) => {
        if (err) {
          hilog.error(DOMAIN_NUMBER, TAG, `设置下次刷新失败:${err.message}`);
          return;
        }
        hilog.info(DOMAIN_NUMBER, TAG, `已设置5分钟后刷新`);
      });
    }
  }

  // 下次刷新时间到后,触发onUpdateForm
  async onUpdateForm(formId: string): void {
    hilog.info(DOMAIN_NUMBER, TAG, `[onUpdateForm] 下次刷新触发`);
    // 执行数据更新逻辑...
  }
}

四、特殊场景刷新:图片与状态关联

除基础文本数据刷新外,卡片常见的复杂场景包括 “图片刷新”(本地 / 网络图片)和 “状态关联刷新”(根据卡片配置刷新不同内容),需针对性处理。

4.1 图片刷新:本地与网络图片更新

卡片展示图片需通过formImages字段传递文件描述符(fd),页面通过memory://fileName协议加载,支持本地图片和网络图片两种场景。

4.1.1 本地图片刷新(卡片创建时加载)

// EntryFormAbility.ets:onAddForm中加载本地图片
import { fileIo } from '@kit.CoreFileKit';
import { Want, BusinessError } from '@kit.AbilityKit';

export default class EntryFormAbility extends FormExtensionAbility {
  onAddForm(want: Want): formBindingData.FormBindingData {
    // 获取应用临时目录(存放本地图片)
    const tempDir = this.context.getApplicationContext().tempDir;
    const imgMap: Record<string, number> = {};

    try {
      // 打开本地图片(假设tempDir下有head.png)
      const file = fileIo.openSync(`${tempDir}/head.png`);
      imgMap['avatar'] = file.fd; // fd为文件描述符,作为图片标识
    } catch (e) {
      const err = e as BusinessError;
      hilog.error(DOMAIN_NUMBER, TAG, `打开本地图片失败:${err.message}`);
    }

    // 封装图片数据(formImages为固定字段,不可改名)
    class FormData {
      text: string = "我的头像";
      imgName: string = "avatar"; // 与formImages的key一致
      formImages: Record<string, number> = imgMap; // 存储fd
    }

    return formBindingData.createFormBindingData(new FormData());
  }
}

// 卡片页面(WidgetCard.ets):加载本地图片
let storage = new LocalStorage();
@Entry(storage)
@Component
struct LocalImageCard {
  @LocalStorageProp('text') text: string = "加载中...";
  @LocalStorageProp('imgName') imgName: string = "";

  build() {
    Column()
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center) {

      // 通过memory://协议加载图片(imgName对应formImages的key)
      Image(`memory://${this.imgName}`)
        .width(100)
        .height(100)
        .borderRadius(50)
        .objectFit(ImageFit.Cover)

      Text(this.text)
        .margin({ top: 10 })
        .fontSize(14)
    }
  }
}

4.1.2 网络图片刷新(用户点击触发)

网络图片需先下载到本地临时目录,再通过 fd 传递,需申请ohos.permission.INTERNET权限。

// 1. 配置权限(module.json5)
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "用于下载网络图片",
        "usedScene": { "abilities": ["EntryFormAbility"], "when": "always" }
      }
    ]
  }
}

// 2. EntryFormAbility.ets:onFormEvent中下载并刷新图片
import { http } from '@kit.NetworkKit';

export default class EntryFormAbility extends FormExtensionAbility {
  async onFormEvent(formId: string, message: string): Promise<void> {
    // 先更新状态为“刷新中”
    let loadingData = { text: "刷新中..." };
    await formProvider.updateForm(formId, formBindingData.createFormBindingData(loadingData));

    // 网络图片地址(替换为真实链接)
    const imgUrl = "https://example.com/new-avatar.jpg";
    const tempDir = this.context.getApplicationContext().tempDir;
    const fileName = `img_${Date.now()}`; // 文件名唯一(确保图片刷新)
    const tempFile = `${tempDir}/${fileName}`;
    const imgMap: Record<string, number> = {};

    try {
      // 1. 下载网络图片
      const httpRequest = http.createHttp();
      const response = await httpRequest.request(imgUrl);
      if (response.responseCode !== http.ResponseCode.OK) {
        throw new Error(`下载失败:${response.responseCode}`);
      }

      // 2. 写入临时文件
      const imgFile = fileIo.openSync(tempFile, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
      await fileIo.write(imgFile.fd, response.result as ArrayBuffer);
      imgMap[fileName] = imgFile.fd;

      // 3. 刷新卡片图片
      const formData = {
        text: "刷新成功",
        imgName: fileName,
        formImages: imgMap
      };
      await formProvider.updateForm(formId, formBindingData.createFormBindingData(formData));

      // 4. 关闭文件(需在刷新后执行)
      fileIo.closeSync(imgFile);
      httpRequest.destroy();
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(DOMAIN_NUMBER, TAG, `网络图片刷新失败:${err.message}`);
      // 刷新失败状态
      const failData = { text: "刷新失败" };
      await formProvider.updateForm(formId, formBindingData.createFormBindingData(failData));
    }
  }
}

图片刷新关键约束

  • 图片大小限制:展示的图片需控制在 2MB 以内,避免占用过多内存;
  • 文件名唯一性:每次刷新需使用不同的imgName(如加时间戳),否则卡片不会感知图片变化;
  • 下载超时限制:FormExtensionAbility后台仅存活 5 秒,需确保网络图片快速下载完成。

4.2 状态关联刷新:根据卡片配置更新内容

同一卡片可能存在多种状态(如两张天气卡片分别显示北京、上海天气),需通过持久化存储记录卡片状态,刷新时根据状态返回对应内容。

完整实现示例

// 1. form_config.json:配置定时刷新(每30分钟)
{
  "forms": [
    {
      "name": "CityWeatherWidget",
      "src": "./ets/widget/pages/WeatherCard.ets",
      "uiSyntax": "arkts",
      "updateEnabled": true,
      "updateDuration": 1, // 30分钟刷新一次
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"],
      "isDefault": true
    }
  ]
}

// 2. 卡片页面(WeatherCard.ets):选择城市状态
let storage = new LocalStorage();
@Entry(storage)
@Component
struct CityWeatherCard {
  @LocalStorageProp('beijingWeather') beijingWeather: string = "待刷新";
  @LocalStorageProp('shanghaiWeather') shanghaiWeather: string = "待刷新";
  @State selectBeijing: boolean = false;
  @State selectShanghai: boolean = false;

  build() {
    Column({ space: 15 })
      .width('100%')
      .height('100%')
      .padding(15) {

      // 城市选择复选框
      Row({ space: 8 }) {
        Checkbox({ name: 'bj', group: 'cityGroup' })
          .onChange((value) => {
            this.selectBeijing = value;
            // 通知FormExtensionAbility更新状态
            this.notifyStateChange();
          });
        Text("北京")
          .fontSize(14);
      }

      Row({ space: 8 }) {
        Checkbox({ name: 'sh', group: 'cityGroup' })
          .onChange((value) => {
            this.selectShanghai = value;
            this.notifyStateChange();
          });
        Text("上海")
          .fontSize(14);
      }

      // 天气展示
      Text(`北京天气:${this.beijingWeather}`)
        .fontSize(14)
        .margin({ top: 10 });
      Text(`上海天气:${this.shanghaiWeather}`)
        .fontSize(14);
    }
  }

  // 发送状态变更事件
  private notifyStateChange() {
    postCardAction(this, {
      action: "message",
      params: {
        selectBeijing: JSON.stringify(this.selectBeijing),
        selectShanghai: JSON.stringify(this.selectShanghai)
      }
    });
  }
}

// 3. EntryFormAbility.ets:持久化状态并刷新对应内容
import { preferences } from '@kit.ArkData';

export default class EntryFormAbility extends FormExtensionAbility {
  // 卡片创建时初始化状态(存入preferences数据库)
  async onAddForm(want: Want): formBindingData.FormBindingData {
    const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string;
    const isTempCard = want.parameters?.[formInfo.FormParam.TEMPORARY_KEY] as boolean;

    // 仅常态卡片持久化状态
    if (!isTempCard) {
      const store = await preferences.getPreferences(this.context, 'cityWeatherStore');
      await store.put(`bj_${formId}`, 'false'); // 北京状态:未选中
      await store.put(`sh_${formId}`, 'false'); // 上海状态:未选中
      await store.flush();
    }

    return formBindingData.createFormBindingData({});
  }

  // 接收状态变更事件,更新数据库
  async onFormEvent(formId: string, message: string): Promise<void> {
    const params = JSON.parse(message);
    const store = await preferences.getPreferences(this.context, 'cityWeatherStore');

    if (params.selectBeijing !== undefined) {
      await store.put(`bj_${formId}`, params.selectBeijing);
    }
    if (params.selectShanghai !== undefined) {
      await store.put(`sh_${formId}`, params.selectShanghai);
    }
    await store.flush();
  }

  // 定时刷新时,根据状态返回对应天气数据
  async onUpdateForm(formId: string): void {
    const store = await preferences.getPreferences(this.context, 'cityWeatherStore');
    const selectBeijing = await store.get(`bj_${formId}`, 'false');
    const selectShanghai = await store.get(`sh_${formId}`, 'false');

    const updateData: Record<string, string> = {};
    // 模拟获取天气数据
    if (selectBeijing === 'true') {
      updateData.beijingWeather = `25℃ 晴(${new Date().toLocaleTimeString()})`;
    }
    if (selectShanghai === 'true') {
      updateData.shanghaiWeather = `28℃ 多云(${new Date().toLocaleTimeString()})`;
    }

    await formProvider.updateForm(formId, formBindingData.createFormBindingData(updateData));
  }

  // 卡片删除时,清理数据库数据
  async onRemoveForm(formId: string): void {
    const store = await preferences.getPreferences(this.context, 'cityWeatherStore');
    await store.delete(`bj_${formId}`);
    await store.delete(`sh_${formId}`);
  }
}

五、刷新机制核心约束与最佳实践

5.1 关键约束(避坑重点)

  1. 刷新次数限制:定时刷新每天最多 50 次,超出后当天无法触发;
  2. 进程存活限制FormExtensionAbility后台仅存活 5 秒(触发回调后),耗时操作(如下载大文件)需拉起主应用处理;
  3. 权限限制:网络图片刷新需申请INTERNET权限,本地文件操作需确保路径合法;
  4. 资源限制:图片大小≤2MB,自定义字体总大小≤20MB,避免内存溢出。

5.2 最佳实践

  1. 选择合适的刷新方式
  • 实时交互场景(如点击刷新)→ 主动刷新;
  • 周期性数据(如天气、新闻)→ 被动刷新(定时 / 定点);
  1. 优化刷新性能
  • 减少刷新频率,非必要不使用高频定时刷新;
  • 刷新数据仅传递变更部分,避免全量更新;
  1. 异常处理兜底
  • 网络请求失败时,保留上次有效数据;
  • 图片加载失败时,显示默认占位图;
  1. 清理冗余数据:卡片删除时,同步清理数据库、临时文件等,避免存储冗余。

总结

ArkTS 卡片的刷新机制围绕 “主动按需” 和 “被动自动” 两大核心,覆盖了文本、图片、状态关联等各类场景。开发时需重点关注formId唯一性、数据传递格式、系统约束(次数、内存、进程存活),并根据业务场景选择合适的刷新方式。通过本文的代码示例和约束说明,可快速落地稳定、高效的卡片刷新功能,提升用户体验。

前端成功转鸿蒙开发者真实案例,教大家如何开发鸿蒙APP--ArkTS 卡片开发完全指南

作者 陈_杨
2026年1月12日 05:25

经验分享

哈喽,大家好!我是陈杨,可能有部分朋友对我有点印象,但更多新朋友还不认识我,今天正好借这个机会,和大家好好认识一下,顺便聊聊我从前端工程师成功转型鸿蒙开发的那些事儿。

算下来,我已经有八年前端开发经验,同时坚持写技术文章也有六年了,这些经历在我的博客里都能查到。曾经我以为,只要把前端技术学深学透,就能稳稳守住自己的 “饭碗”。但这两年,这种想法被越来越强烈的危机感打破 —— 总担心自己哪天就跟不上前端的迭代节奏,而且手里没有能独立运营的项目,被行业淘汰似乎只是时间问题。

也正因这份居安思危的心态,三年前我下定决心转型,当时的目标很明确:开发属于自己的产品,做好宣传、搭建用户社群,最终实现更自主的职业价值。

转型之初,我也踩过不少技术选型的坑。iOS 开发、安卓开发、各类跨端框架,我都逐一了解过,但最终都放弃了,原因有三点:

  1. 学习成本太高:眼看就要到三十岁,时间和精力都耗不起,没法投入大量成本去啃全新的技术栈;
  2. 生态市场饱和:iOS 和安卓的市场格局早已定型,新人想做出点成绩太难,很容易打击积极性;
  3. 兴趣驱动不足:说起来也奇怪,我对安卓和 iOS 开发就是提不起兴趣,哈哈哈。

排除了这些方向,我也考虑过继续深耕 Windows 或网页开发,但实在是提不起劲。就在我纠结之际,鸿蒙 API9 的发布,彻底点亮了我的转型之路。说实话,在 API9 之前,鸿蒙用的还是 Java 语法,我当时瞅都不瞅 —— 毕竟在我看来,那和安卓没什么两样(这话可能有点直接,大家轻喷)。

直到 ArkTS 框架的出现,我一眼就来了精神!这语法简直和 VUE/React+TS 如出一辙,对我这个老前端来说,这不就是信手拈来的事儿吗?我甚至没有进行系统性学习,就对着官方文档啃了三天,直接跑通了我的第一个鸿蒙 APP。那一刻,我真的觉得天时地利人和全凑齐了:

  1. 学习门槛低:鸿蒙新框架和前端技术无缝衔接,几乎没花多少时间成本就上手了;
  2. 市场机会大:当时鸿蒙生态才刚刚起步,整个市场都是蓝海,对我们这种转型开发者来说,处处都是机遇。

从 2023 年正式入局鸿蒙开发,到现在已经快三年了。这三年里,我实实在在做了五件事,也拿到了一些成果:

  1. 封装开源组件库:打造了鸿蒙生态下的开源图表组件库 ——莓创图表,这也是我在鸿蒙社区的第一个开源作品,借此结识了一大批志同道合的鸿蒙开发者;
  2. 参赛验证实力:通过参加各类鸿蒙开发赛事检验学习成果,不仅拿到了不少奖项,还一路闯进了创新赛和极客挑战赛的总决赛;
  3. 写文巩固影响力:坚持输出鸿蒙技术文章,既能巩固自己的知识体系,也能扩大个人影响力,反过来又倒逼自己不断学习、持续进步;
  4. 深耕开发者社区:积极参与社区活动,主动分享技术、解答问题,为鸿蒙生态添砖加瓦,也有幸成为了一名鸿蒙极客
  5. 开发上架多款 APP:去年一年,我和团队陆续开发了多款鸿蒙应用,目前已经成功上架十一款,也靠着这些产品赚到了转型后的第一桶金。感兴趣的朋友可以去体验一下,比如 JLPTREFLEX PRO国潮纸刻Wss 直连ZenithDocs Pro圣诞相册CSS 特效这些

以上就是我转型鸿蒙开发的一些经验。这三年的经历,让我找回了刚毕业时那种全身心投入做一件事的冲劲。今年我还有不少重要规划,接下来的日子里,也会持续在平台分享鸿蒙相关的技术干货,不管是想转型鸿蒙的朋友,还是刚入门的萌新,都可以来找我交流,咱们一起学习、一起进步!

当然,今天的重点还是技术分享。其他的话题大家感兴趣的话可以私聊我,话不多说,咱们正式进入主题 —— 今天我会以我们团队的主力 APP 指令魔方 为例,给大家拆解鸿蒙的各类特性 API,讲讲这些 API 到底该怎么用、使用过程中会踩哪些坑,还有哪些可以快速落地的实战案例。

马上开始!

鸿蒙卡片的开发-全流程开发

鸿蒙卡片是指令魔方中最重要的一个环节,他可以实现用户在桌面就可以操作某个指令,来快速实现某个功能。大大提升了我们使用手机的效率,而且还可以美化桌面。同时ArkTS卡片也是HarmonyOS生态中轻量级的交互组件,能在宿主应用(如桌面)直接展示核心信息并支持轻量化交互,无需启动完整应用。

这次我们基于 HarmonyOS 官方文档,从概念、创建、配置、生命周期到进程模型,带大家完整掌握 ArkTS 卡片开发,包含实操代码和关键原理图示。

一、ArkTS卡片核心概述

1.1 核心亮点

ArkTS卡片相比传统JS卡片,在开发效率和能力上有显著提升:

  • 统一开发范式:卡片与应用页面共享ArkTS UI布局逻辑,比如应用中用的Column、Row布局可直接复用到卡片,无需单独适配,大幅减少重复开发。
  • 能力增强:新增三大关键能力——支持属性动画/显式动画(让交互更流畅)、开放Canvas画布(自定义绘制复杂图形)、允许运行逻辑代码(业务逻辑可在卡片内闭环,比如本地数据处理)。

1.2 实现原理

ArkTS卡片的运行依赖4个核心角色,交互流程如图所示:

  • 卡片使用方:显示卡片的宿主应用(目前仅系统应用,如桌面),控制卡片展示位置。
  • 卡片提供方:开发卡片的应用,负责卡片的UI布局、数据内容和点击事件逻辑。
  • 卡片管理服务:系统级常驻服务,提供formProvider接口,管理卡片生命周期(如创建、刷新、删除)和周期性刷新(定时/定点)。
  • 卡片渲染服务:管理所有卡片的渲染实例,每个宿主卡片组件对应一个渲染实例;实例运行在ArkTS虚拟机中,不同应用的渲染实例隔离(避免资源冲突),同一应用的实例共享globalThis对象。

关键区别:JS卡片不支持内置逻辑代码,而ArkTS卡片通过“渲染服务+虚拟机隔离”,既支持逻辑运行,又不影响宿主应用稳定性。

1.3 卡片类型对比

ArkTS卡片分动态和静态两种,实际开发需根据业务场景选择,核心差异如下:

卡片类型 支持能力 适用场景 优缺点
静态卡片 仅UI组件+布局,仅支持FormLink组件跳转 展示固定信息(如天气实况、日期) 内存开销小,功耗低;但无交互能力,频繁刷新会导致资源反复创建销毁
动态卡片 UI组件+布局+通用事件(点击、动画)+自定义动效 需交互/刷新场景(如音乐卡片切歌、新闻卡片刷新内容) 功能丰富,支持复杂交互;但内存开销比静态卡片大

动态卡片的事件交互依赖postCardAction接口,支持3种事件:

  • router:跳转至应用内UIAbility(非系统应用仅支持跳自己的页面);
  • call:拉起UIAbility到后台(如音乐卡片后台播放);
  • message:触发FormExtensionAbility的onFormEvent回调,更新卡片内容(如点击按钮刷新数据)。

1.4 约束与限制

开发时需注意以下限制,避免卡片异常:

  • 开发层面:仅支持ArkUI声明式范式,不支持跨平台开发;不支持Native语言(如C/C++)和加载Native so库。
  • 资源层面:仅支持导入“标注支持卡片”的模块(接口文档会标“卡片能力”),仅支持HAR静态共享包,不支持HSP动态共享包。
  • 调试层面:不支持极速预览、断点调试和Hot Reload热重载,开发时需通过日志(如hilog)排查问题。
  • 运行层面:不支持setTimeOut;若宿主应用支持左右滑动(如桌面分页),卡片内避免用左右滑动组件(防止手势冲突)。

二、创建ArkTS卡片

2.1 两种工程入口

在DevEco Studio(API 10及以上Stage模型)中,有两种创建卡片的前置工程:

  1. 新建Application工程:创建后右键工程根目录 → 选择“New” → “Service Widget”;
  2. 新建Atomic Service(元服务)工程:同上,后续步骤一致。

注意:不同DevEco Studio版本界面可能有差异,以实际界面为准。

2.2 新建卡片步骤

  1. 右键工程 → “New” → “Service Widget”,选择“动态卡片”或“静态卡片”(后续可通过配置修改);
  2. 选择卡片模板(如“2x2宫格”“4x4宫格”),开发语言选“ArkTS”,输入卡片名称(建议按业务命名,如“MusicWidget”);
  3. 点击“Finish”后,工程会自动生成3个核心文件:
  • EntryFormAbility.ets:卡片生命周期管理文件(处理创建、刷新、删除等逻辑);
  • WidgetCard.ets:卡片UI页面文件(定义布局和交互控件);
  • form_config.json:卡片配置文件(定义尺寸、刷新策略、卡片类型等)。

2.3 卡片类型修改

若创建时选错类型,可通过form_config.jsonisDynamic字段修改:

  • isDynamic: true 或字段置空 → 动态卡片;
  • isDynamic: false → 静态卡片。

三、卡片配置文件详解

ArkTS卡片需配置两个核心文件:module.json5(注册FormExtensionAbility)和form_config.json(卡片具体参数)。

3.1 module.json5配置(注册FormExtensionAbility)

FormExtensionAbility是卡片的“生命周期载体”,需在module.json5extensionAbilities标签中注册,示例代码如下:

{
  "module": {
    "package": "com.example.arktswidget",
    "name": ".MyModule",
    "mainAbility": "com.example.arktswidget.EntryAbility",
    // 卡片相关配置
    "extensionAbilities": [
      {
        "name": "EntryFormAbility", // 生命周期类名,需与EntryFormAbility.ets一致
        "srcEntry": "./ets/entryformability/EntryFormAbility.ets", // 生命周期文件路径
        "label": "$string:EntryFormAbility_label", // 卡片标签(多语言支持)
        "description": "$string:EntryFormAbility_desc", // 卡片描述
        "type": "form", // 类型固定为form
        "metadata": [
          {
            "name": "ohos.extension.form", // 固定键名
            "resource": "$profile:form_config" // 指向form_config.json配置文件
          }
        ]
      }
    ]
  }
}

3.2 form_config.json配置(卡片参数)

该文件位于resources/base/profile/目录,定义卡片的尺寸、刷新策略、主题等核心参数,完整示例如下(含关键注释):

{
  "forms": [
    {
      "name": "MusicWidget", // 卡片名称(最大127字节)
      "displayName": "$string:widget_display_name", // 卡片显示名(多语言,1-30字节)
      "description": "$string:widget_desc", // 描述(可选,最大255字节)
      "src": "./ets/widget/pages/WidgetCard.ets", // ArkTS卡片页面路径(需带.ets后缀)
      "uiSyntax": "arkts", // 类型:arkts(ArkTS卡片)/hml(JS卡片),默认hml
      // 窗口配置(可选,控制UI缩放)
      "window": {
        "designWidth": 720, // 设计基准宽度(默认720px)
        "autoDesignWidth": true // 自动计算基准宽度(true时忽略designWidth)
      },
      "colorMode": "auto", // 主题:auto(跟随系统)/dark/light,默认auto
      "isDefault": true, // 是否为默认卡片(每个UIAbility仅1个默认卡片)
      "updateEnabled": true, // 是否支持周期性刷新(true=支持)
      "scheduledUpdateTime": "10:30", // 定点刷新时间(24小时制,如10:30,可选)
      "updateDuration": 1, // 定时刷新周期(单位30分钟,1=30分钟,0=不生效,优先级高于定点)
      "defaultDimension": "2*2", // 默认尺寸(需在supportDimensions中)
      "supportDimensions": ["2*2", "4*4"], // 支持的尺寸(1*1圆形/1*2/2*2/2*4/4*4/6*4)
      "formConfigAbility": "ability://com.example.arktswidget.EntryAbility", // 配置跳转链接(URI格式,可选)
      "dataProxyEnabled": false, // 是否支持代理刷新(true时定时刷新失效,可选)
      "isDynamic": true, // 是否为动态卡片(仅ArkTS卡片生效,默认true)
      "fontScaleFollowSystem": true, // 字体是否跟随系统缩放(默认true)
      "supportShapes": "rect", // 卡片形状:rect(方形)/circle(圆形),默认rect
      "metadata": [] // 自定义元信息(可选)
    }
  ]
}

关键标签说明:

  • isDynamic:仅ArkTS卡片生效,决定卡片类型(动态/静态);
  • updateEnabled:开启后支持“定时刷新”(updateDuration)或“定点刷新”(scheduledUpdateTime),两者同时配置时定时优先;
  • supportDimensions:需根据宿主应用支持的尺寸选择(如桌面通常支持22_、24_)。

四、卡片生命周期管理

ArkTS卡片的生命周期由FormExtensionAbility接口控制,需在EntryFormAbility.ets中实现核心方法,处理卡片的创建、刷新、删除等逻辑。

4.1 完整生命周期代码

// 导入必要的工具包
import { 
  formBindingData, 
  FormExtensionAbility, 
  formInfo, 
  formProvider 
} from '@kit.FormKit';
import { Configuration, Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

// 日志配置(方便调试)
const TAG: string = 'EntryFormAbility';
const DOMAIN_NUMBER: number = 0xFF00; // 日志域(自定义)

// 实现FormExtensionAbility
export default class EntryFormAbility extends FormExtensionAbility {
  /**
   * 1. 卡片创建时触发(宿主应用添加卡片)
   * @param want 包含卡片参数(如卡片名称、尺寸)
   * @return 卡片初始数据(FormBindingData)
   */
  onAddForm(want: Want): formBindingData.FormBindingData {
    hilog.info(DOMAIN_NUMBER, TAG, '[onAddForm] 卡片开始创建');
    // 获取卡片名称(从want参数中提取)
    const formName = want.parameters?.[formInfo.FormParam.NAME_KEY] as string;
    hilog.info(DOMAIN_NUMBER, TAG, `当前创建的卡片:${formName}`);
    
    // 构造卡片初始数据(键名需与WidgetCard.ets中的UI绑定一致)
    const initData = {
      'title': '音乐卡片',
      'currentSong': '晴天',
      'singer': '周杰伦'
    };
    // 返回绑定数据(UI会自动渲染这些数据)
    return formBindingData.createFormBindingData(initData);
  }

  /**
   * 2. 临时卡片转常态卡片时触发(目前手机端暂未使用临时卡片)
   * @param formId 卡片唯一ID
   */
  onCastToNormalForm(formId: string): void {
    hilog.info(DOMAIN_NUMBER, TAG, `[onCastToNormalForm] 临时卡片转常态:${formId}`);
  }

  /**
   * 3. 卡片刷新时触发(定时/定点刷新、宿主主动刷新)
   * @param formId 卡片唯一ID
   */
  onUpdateForm(formId: string): void {
    hilog.info(DOMAIN_NUMBER, TAG, `[onUpdateForm] 卡片开始刷新:${formId}`);
    
    // 构造刷新后的数据(如更新当前播放歌曲)
    const updateData = {
      'currentSong': '七里香',
      'singer': '周杰伦'
    };
    const formData = formBindingData.createFormBindingData(updateData);
    
    // 调用接口更新卡片(需处理异常)
    formProvider.updateForm(formId, formData)
      .catch((error: BusinessError) => {
        hilog.error(DOMAIN_NUMBER, TAG, `刷新失败:${JSON.stringify(error)}`);
      });
  }

  /**
   * 4. 卡片可见性变化时触发(仅系统应用生效)
   * @param newStatus 可见性状态(键:formId,值:可见性)
   */
  onChangeFormVisibility(newStatus: Record<string, number>): void {
    hilog.info(DOMAIN_NUMBER, TAG, `[onChangeFormVisibility] 可见性变化:${JSON.stringify(newStatus)}`);
  }

  /**
   * 5. 卡片触发事件时触发(如动态卡片点击按钮)
   * @param formId 卡片唯一ID
   * @param message 事件消息(自定义格式)
   */
  onFormEvent(formId: string, message: string): void {
    hilog.info(DOMAIN_NUMBER, TAG, `[onFormEvent] 卡片触发事件:${formId},消息:${message}`);
    // 示例:收到“切歌”消息后,刷新卡片数据
    if (message === 'next_song') {
      this.onUpdateForm(formId);
    }
  }

  /**
   * 6. 卡片删除时触发(宿主应用移除卡片)
   * @param formId 卡片唯一ID
   */
  onRemoveForm(formId: string): void {
    hilog.info(DOMAIN_NUMBER, TAG, `[onRemoveForm] 卡片开始删除:${formId}`);
    // 清理卡片相关数据(如本地缓存的播放记录)
  }

  /**
   * 7. 系统配置更新时触发(如字体大小、主题变化)
   * @param config 新的系统配置
   */
  onConfigurationUpdate(config: Configuration) {
    hilog.info(DOMAIN_NUMBER, TAG, `[onConfigurationUpdate] 系统配置变化:${JSON.stringify(config)}`);
  }

  /**
   * 8. 查询卡片状态时触发(默认返回就绪状态)
   * @param want 包含卡片参数
   * @return 卡片状态(READY/UNREADY/INVALID)
   */
  onAcquireFormState(want: Want) {
    hilog.info(DOMAIN_NUMBER, TAG, `[onAcquireFormState] 查询卡片状态`);
    return formInfo.FormState.READY; // 默认为就绪状态
  }
}

4.2 生命周期关键注意点

  • FormExtensionAbility进程不常驻:生命周期方法执行完成后,进程会保留10秒;若10秒内无新操作,进程自动退出。若需处理耗时任务(如网络请求),建议拉起应用主进程处理,完成后调用formProvider.updateForm刷新卡片。
  • formId是关键标识:每个卡片实例有唯一formId,刷新、删除、事件处理都需通过formId定位。
  • 数据绑定FormBindingData中的键名需与WidgetCard.ets的UI组件绑定一致(如Text(${this.title})对应数据中的title键)。

五、ArkTS卡片进程模型

ArkTS卡片的运行依赖4个独立进程,各进程职责和隔离机制如图所示:

进程名称 核心职责 关键特性
卡片使用方进程 宿主应用进程(如桌面),展示卡片UI 仅系统应用可作为使用方
卡片渲染服务进程 统一渲染所有卡片的UI,管理渲染实例 所有应用的卡片共享该进程,但通过ArkTS虚拟机隔离(不同应用的实例无资源冲突)
卡片管理服务进程 系统级SA服务,管理卡片生命周期(创建/刷新/删除)、调度渲染服务和提供方 常驻系统,是卡片运行的“中枢”
卡片提供方进程 包含应用主进程(UIAbility)和FormExtensionAbility进程 两个进程内存隔离(避免互相影响),但共享同一文件沙箱(可共享本地文件)

进程交互逻辑

  1. 用户在桌面添加卡片 → 卡片使用方进程向管理服务进程发起创建请求;
  2. 管理服务进程通知提供方进程执行onAddForm,生成初始数据;
  3. 管理服务进程调度渲染服务进程,加载卡片UI代码(WidgetCard.ets)并渲染;
  4. 渲染完成后,渲染服务进程将UI数据发送给使用方进程,最终展示在桌面。

六、开发注意事项

  1. UI组件限制:仅支持ArkUI声明式的部分组件(如Column、Row、Text、Button),不支持List、Grid等复杂滚动组件(易引发性能问题);
  2. 资源控制:动态卡片的内存开销较大,避免在卡片中加载大图片或复杂动画;
  3. 手势冲突:若宿主应用支持左右滑动(如桌面分页),卡片内不要用SwipeGesture等左右滑动手势;
  4. 调试技巧:因不支持断点调试,建议通过hilog打印关键流程日志(如创建、刷新、事件触发),在DevEco Studio的“Log”面板查看;
  5. 兼容性form_config.json中的supportDimensions需选择主流尺寸(如2_2、4_4),避免使用小众尺寸(如6*4)导致部分设备不支持。

通过以上内容,我们已覆盖ArkTS卡片开发的全流程。实际开发中,建议先根据业务场景确定卡片类型(动态/静态),再按“创建工程→配置文件→实现生命周期→调试”的步骤推进,遇到问题可以在社区官网提问题,也可以私聊我,我一般看到都会回信息的,有必须要的情况,我也可以创建一些交流群,大家一起互相沟通

TanStack Router 实战: 如何设置基础认证和受保护路由

2026年1月12日 10:17

本指南涵盖了在 TanStack Router 应用程序中实现基础认证模式和保护路由的方法。

快速开始

通过创建一个上下文感知(context-aware)的路由器,实现认证状态管理,并使用 beforeLoad 进行路由保护来设置身份验证。本指南侧重于使用 React Context 进行核心认证设置。


创建认证上下文 (Authentication Context)

创建 src/auth.tsx

import React, { createContext, useContext, useState, useEffect } from 'react'

interface User {
  id: string
  username: string
  email: string
}

interface AuthState {
  isAuthenticated: boolean
  user: User | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = createContext<AuthState | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  // 在应用加载时恢复认证状态
  useEffect(() => {
    const token = localStorage.getItem('auth-token')
    if (token) {
      // 使用你的 API 验证 token
      fetch('/api/validate-token', {
        headers: { Authorization: `Bearer ${token}` },
      })
        .then((response) => response.json())
        .then((userData) => {
          if (userData.valid) {
            setUser(userData.user)
            setIsAuthenticated(true)
          } else {
            localStorage.removeItem('auth-token')
          }
        })
        .catch(() => {
          localStorage.removeItem('auth-token')
        })
        .finally(() => {
          setIsLoading(false)
        })
    } else {
      setIsLoading(false)
    }
  }, [])

  // 在检查认证时显示加载状态
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        Loading...
      </div>
    )
  }

  const login = async (username: string, password: string) => {
    // 替换为你的认证逻辑
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
    })

    if (response.ok) {
      const userData = await response.json()
      setUser(userData)
      setIsAuthenticated(true)
      // 存储 token 以便持久化
      localStorage.setItem('auth-token', userData.token)
    } else {
      throw new Error('Authentication failed')
    }
  }

  const logout = () => {
    setUser(null)
    setIsAuthenticated(false)
    localStorage.removeItem('auth-token')
  }

  return (
    <AuthContext.Provider value={{ isAuthenticated, user, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

配置路由器上下文 (Router Context)

1. 设置路由器上下文

更新 src/routes/__root.tsx

import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'

interface AuthState {
  isAuthenticated: boolean
  user: { id: string; username: string; email: string } | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

interface MyRouterContext {
  auth: AuthState
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: () => (
    <div>
      <Outlet />
      <TanStackRouterDevtools />
    </div>
  ),
})

2. 配置路由器

更新 src/router.tsx

import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export const router = createRouter({
  routeTree,
  context: {
    // auth 将从 App 组件向下传递
    auth: undefined!,
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

3. 连接应用与认证

更新 src/App.tsx

import { RouterProvider } from '@tanstack/react-router'
import { AuthProvider, useAuth } from './auth'
import { router } from './router'

function InnerApp() {
  const auth = useAuth()
  return <RouterProvider router={router} context={{ auth }} />
}

function App() {
  return (
    <AuthProvider>
      <InnerApp />
    </AuthProvider>
  )
}

export default App

创建受保护路由

1. 创建认证布局路由

创建 src/routes/_authenticated.tsx

import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          // 保存当前位置,以便登录后重定向
          redirect: location.href,
        },
      })
    }
  },
  component: () => <Outlet />,
})

2. 创建登录路由

创建 src/routes/login.tsx

import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'

export const Route = createFileRoute('/login')({
  validateSearch: (search) => ({
    redirect: (search.redirect as string) || '/',
  }),
  beforeLoad: ({ context, search }) => {
    // 如果已认证,则进行重定向
    if (context.auth.isAuthenticated) {
      throw redirect({ to: search.redirect })
    }
  },
  component: LoginComponent,
})

function LoginComponent() {
  const { auth } = Route.useRouteContext()
  const { redirect } = Route.useSearch()
  const navigate = Route.useNavigate()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsLoading(true)
    setError('')

    try {
      await auth.login(username, password)
      // 使用路由器导航跳转到重定向 URL
      navigate({ to: redirect })
    } catch (err) {
      setError('Invalid username or password')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center">
      <form
        onSubmit={handleSubmit}
        className="max-w-md w-full space-y-4 p-6 border rounded-lg"
      >
        <h1 className="text-2xl font-bold text-center">Sign In</h1>

        {error && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
            {error}
          </div>
        )}

        <div>
          <label htmlFor="username" className="block text-sm font-medium mb-1">
            Username
          </label>
          <input
            id="username"
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            required
          />
        </div>

        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-1">
            Password
          </label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            required
          />
        </div>

        <button
          type="submit"
          disabled={isLoading}
          className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {isLoading ? 'Signing in...' : 'Sign In'}
        </button>
      </form>
    </div>
  )
}

3. 创建受保护的仪表盘 (Dashboard)

创建 src/routes/_authenticated/dashboard.tsx

import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/dashboard')({
  component: DashboardComponent,
})

function DashboardComponent() {
  const { auth } = Route.useRouteContext()

  return (
    <div className="p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">Dashboard</h1>
        <button
          onClick={auth.logout}
          className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
        >
          Sign Out
        </button>
      </div>

      <div className="bg-white p-6 rounded-lg shadow">
        <h2 className="text-xl font-semibold mb-2">Welcome back!</h2>
        <p className="text-gray-600">
          Hello, <strong>{auth.user?.username}</strong>! You are successfully
          authenticated.
        </p>
        <p className="text-sm text-gray-500 mt-2">Email: {auth.user?.email}</p>
      </div>
    </div>
  )
}

添加认证持久化

更新你的 AuthProvider 以在页面刷新时恢复认证状态:

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  // 在应用加载时恢复认证状态
  useEffect(() => {
    const token = localStorage.getItem('auth-token')
    if (token) {
      // 使用你的 API 验证 token
      fetch('/api/validate-token', {
        headers: { Authorization: `Bearer ${token}` },
      })
        .then((response) => response.json())
        .then((userData) => {
          if (userData.valid) {
            setUser(userData.user)
            setIsAuthenticated(true)
          } else {
            localStorage.removeItem('auth-token')
          }
        })
        .catch(() => {
          localStorage.removeItem('auth-token')
        })
        .finally(() => {
          setIsLoading(false)
        })
    } else {
      setIsLoading(false)
    }
  }, [])

  // 在检查认证时显示加载状态
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        Loading...
      </div>
    )
  }

  // ... provider 的其余逻辑
}

生产环境检查清单

在部署认证功能之前,请确保你已经:

  • 使用适当的认证中间件保护了 API 端点
  • 在生产环境中设置了 HTTPS(安全 Cookie 所需)
  • 为 API 端点配置了环境变量
  • 实现了适当的 token 验证和刷新机制
  • 为基于表单的认证添加了 CSRF 保护
  • 测试了认证流程(登录、登出、持久化)
  • 为网络故障添加了适当的错误处理
  • 为认证操作实现了加载状态

常见问题

认证上下文不可用

问题: 出现 useAuth must be used within an AuthProvider 错误。

解决方案: 确保 AuthProvider 包裹了整个应用,且 RouterProvider 位于其内部。

页面刷新后用户登出

问题: 页面刷新时认证状态重置。

解决方案: 如上文持久化部分所示,添加 token 持久化逻辑。

受保护路由在重定向闪烁

问题: 受保护的内容在重定向到登录页面之前短暂显示。

解决方案: 使用 beforeLoad 而不是组件级别的认证检查:

export const Route = createFileRoute('/_authenticated/dashboard')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login' })
    }
  },
  component: DashboardComponent,
})

uniapp 异型无缝轮播图

作者 幸福小宝
2026年1月12日 10:16

上截图

image.png

支持 web ios android

上代码

<template>
<view class="joy-swiper" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd"
@touchcancel="handleTouchEnd">
<!-- 实际数据+填充数据实现无缝循环 -->
<view class="swiper-warap" :style="{ 
        transform: `translate3d(${offsetX}px, 0, 0)`, 
        transition: transitionStyle 
      }">
<view v-for="(item, index) in local_list" :key="index" :id="`item-${index}`" class="swiper-item"
:class="{active: currentIndex == index}" @click.stop="itemClick(item)">
<image class="image" :style="{
            transition: transitionWidth,
            backgroundColor: item.filePath
          }" :src="item.filePath" mode="aspectFill" />
</view>
</view>
</view>
</template>

<script>
export default {
props: {
list: {
type: Array,
default: () => {
return []
}
},
autoplay: {
type: Boolean,
default: false
},
duration: {
type: Number,
default: 3000
}
},
watch: {
list: {
immediate: true,
handler(list) {
this.leng = list.length
if (1 < this.leng) {
// 复制数组 数组1 数组2 数组3
this.local_list = [...list, ...list, ...list]
this.currentIndex = list.length
clearTimeout(this.timeout2)
this.timeout2 = setTimeout(() => {
this.getItemDom().then((res) => {
this.itemWidth = res.width
this.offsetX = -this.currentIndex * this.itemWidth
clearTimeout(this.timeout3)
this.timeout3 = setTimeout(() => {
this.transitionStyle = "transform 0.2s ease-out"
this.transitionWidth = "all ease 0.2s"
clearTimeout(this.timeout2)
clearTimeout(this.timeout3)
}, 50)
})
}, 0)
} else {
this.local_list = list
this.currentIndex = 0
this.offsetX = 0
}
}
},

autoplay: {
immediate: true,
handler(val) {
this.local_autoplay = val
}
},
local_autoplay: {
handler(val) {
if (val) {
this.autoplayHandler()
} else {
this.interval && clearInterval(this.interval)
}
},
immediate: true,
}
},
data() {
return {
itemWidth: 0, // 单项宽度
isDragging: false, // 防止断触
startX: 0,
startY: 0,
distance: 0,
miniDistance: 25, // 最小距离
offsetX: 0,
damping: 0.38, // 阻尼系数
transitionStyle: "none",
transitionWidth: "all ease 0.2s",
leng: 0, // 原始数组length
currentIndex: 0, // 当前选中项索引
local_list: [], // 新的数组数据
local_autoplay: false,
interval: null,
timeout1: null,
timeout2: null,
timeout3: null,
};
},
methods: {
handleTouchStart(e) {
this.distance = 0;
this.local_autoplay = false;
if (this.leng == 1) return;
this.startX = e.touches[0].pageX;
this.startY = e.touches[0].pageY;
this.isDragging = true;
// 拖拽时禁用过渡
this.transitionStyle = "none";
this.transitionWidth = "none";
},
handleTouchMove(e) {
this.local_autoplay = false;
if (this.leng == 1) return;
if (!this.isDragging) return;
// 阻止事件冒泡,上调允许上下滚动的阈值
if (Math.abs(e.touches[0].pageY - this.startY) < 50) {
e.stopPropagation()
}
// 手姿移动的距离
this.distance = e.touches[0].pageX - this.startX;
// 盒子实际移动的距离 = 手势距离 * 阻尼系数
const domDistance = this.distance * this.damping
// X轴方向位移距离,判断允许左右滚动的阈值
if (this.miniDistance < Math.abs(this.distance)) {
this.offsetX = -this.currentIndex * this.itemWidth + domDistance;
}
},
handleTouchEnd() {
this.local_autoplay = this.autoplay;
if (this.leng == 1) return;
if (Math.abs(this.distance) <= this.miniDistance) return;
this.changeHandler()
},
changeHandler(eventType) {
// 开启过渡
this.transitionStyle = "transform 0.2s cubic-bezier(0.2, 0.7, 0.3, 1)";
this.transitionWidth = "all ease 0.2s";
if (eventType === 'autoplayHandler') {
this.currentIndex++;
} else {
// 计算是否超过一个item的宽度,超过则移动一个item宽度的距离
const delta = Math.round(this.distance * this.damping / this.itemWidth);
if (1 <= Math.abs(delta)) {
// 根据 distance 正负判断滑动的方向
if (0 < this.distance) {
this.currentIndex--;
} else {
this.currentIndex++;
}
}
}
// X轴方向位移距离
this.offsetX = -this.currentIndex * (this.itemWidth)
// 过渡动画结束时重置索引,实现无缝滑动效果
this.timeout1 && clearTimeout(this.timeout1)
this.timeout1 = setTimeout(() => {
// 修改数据时禁用过渡动画以实现视觉欺骗,否则盒子和元素会出现跳动
this.transitionStyle = "none";
this.transitionWidth = "none";
// 向右滑到 0 时,截取数组3放在最前面
if (this.currentIndex === 0) {
const temp = this.local_list.splice(this.leng * 2, this.leng)
this.local_list = [...temp, ...this.local_list]
}
// 向右滑到 this.list.length * 2 时,截取数组1放在最后面
if (this.currentIndex === this.leng * 2) {
const temp = this.local_list.splice(0, this.leng)
this.local_list = [...this.local_list, ...temp]
}
// 重置索引为 this.list.length
if (this.currentIndex === 0 || this.currentIndex === this.leng * 2) {
this.currentIndex = this.leng
this.offsetX = -this.currentIndex * this.itemWidth
}
// 恢复
this.isDragging = false;
}, 220)
},
autoplayHandler() {
this.interval && clearInterval(this.interval)
this.interval = setInterval(() => {
this.changeHandler('autoplayHandler')
}, this.duration);
},
getItemDom() {
return new Promise((resolve, reject) => {
let selectorQuery = uni.createSelectorQuery().in(this);
// #ifdef MP-ALIPAY
selectorQuery = uni.createSelectorQuery();
// #endif
selectorQuery
.select("#item-1")
.boundingClientRect()
.exec((res) => {
resolve(res[0])
})
})
},
itemClick(item) {
this.$emit('click', JSON.parse(JSON.stringify(item)))
}
},
destroyed() {
clearTimeout(this.timeout1)
clearTimeout(this.timeout2)
clearTimeout(this.timeout3)
clearInterval(this.interval)
},
};
</script>

<style lang="scss">
.joy-swiper {
padding-top: 100px;
width: 100vw;
overflow: hidden;
position: relative;

.swiper-warap {
display: flex;
flex-wrap: nowrap;
padding: 0 4px;

.swiper-item {
display: flex;
position: relative;
flex-shrink: 0;
padding: 0 4px;

.image {
display: block;
width: 73px;
height: 150px;
border-radius: 5px;
}

&.active .image {
width: calc(100vw - 175px);
border-radius: 5px;
}
}
}
}
</style>

使用姿势

<template>
<view>
<joy-swiper :list="swiper" @click="clickItem" />
</view>
</template>

<script>
    export default {
        data() {
            return {
                // 建议数组长度在3个以上
                // 假数据是用背景色代替图片路径,引入插件后在插件内删除image的backgroundColor属性即可
                swiper: [
                    {
                        filePath: '#815c94'
                    },
                    {
                        filePath: '#2E5A6F'
                    },
                    {
                        filePath: '#ed5126'
                    },
                    {
                        filePath: '#B6D7A8'
                    },
                    {
                        filePath: '#2A52BE'
                    },
                    {
                        filePath: '#96c24e'
                    },
                ]
            }
        },
        methods: {
            clickItem(item) {
                console.log(item)
            }
        }
    }
</script>

<style>

</style>

前端存储与离线应用实战:Cookie、LocalStorage、PWA 及 Service Worker 核心知识点

2026年1月12日 10:02

1. 前言

该文章围绕浏览器存储及相关技术展开,核心涵盖Cookie、LocalStorage、SessionStorage、IndexedDB 四种浏览器存储方式(各有存储大小、使用场景等差异),同时介绍了 PWA(渐进式 Web 应用) 的特性与相关工具,以及 Service Worker 的作用、运行机制和调试方式,最终通过案例分析与实战帮助学习者掌握各类技术的概念、使用及选择逻辑。

2.思维导图(mindmap)

image.png

3.浏览器存储方式详情(核心对比)

存储方式 核心定位 存储大小 关键特性 典型用途
Cookie 维持 HTTP 无状态的客户端状态存储 约 4KB 1. 生成方式:HTTP 响应头 set-cookie、JS 的 document.cookie;2. 关联对应域名(存在 CDN 流量损耗);3. 支持 httponly 属性;4. 可设置 expire 过期时间 辨别用户、记录客户基础信息
LocalStorage HTML5 专用浏览器本地存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 持久化存储(除非主动清除) 浏览器本地缓存方案
SessionStorage 会话级浏览器存储 约 5M 1. 仅客户端使用,不与服务端通信;2. 接口封装更友好;3. 会话结束后数据清除 临时维护表单信息
IndexedDB 客户端大容量结构化数据存储 -(无明确限制,支持大量数据) 1. 低级 API,支持索引;2. 高性能数据搜索;3. 弥补 Web Storage 大容量存储短板 为应用创建离线版本

三、PWA(Progressive Web Apps)相关

  1. 定义:并非单一技术,而是通过一系列 Web 新特性 + 优秀 UI 交互设计,渐进式增强 Web App 用户体验的新模型

  2. 核心特性:

    • 可靠:无网络环境下可提供基本页面访问,避免 “未连接到互联网” 提示

    • 快速:针对网页渲染和网络数据访问做了专项优化

    • 融入:可添加到手机桌面,支持全屏显示、推送等原生应用类似特性

  3. 相关工具:lighthouse(下载地址:lavas.baidu.com/doc-assets/…

四、Service Worker 相关

  1. 定义:独立于当前网页,在浏览器后台运行的脚本,为无页面 / 无用户交互场景的特性提供支持
  2. 核心能力:
    • 首要特性:拦截和处理网络请求,编程式管理缓存响应
    • 未来特性:推送消息、背景同步、地理围栏定位(geofencing)
  3. 生命周期:Installing(安装中)→ Activated(激活)→ Idle(闲置)/ Terminated(终止),过程中可能出现 Error(错误)
  4. 调试地址:
    • chrome://serviceworker-internals/
    • chrome://inspect/#service-workers

4. 关键问题

问题 1:Cookie 与 LocalStorage 作为浏览器存储方式,核心差异体现在哪些方面?

答案:两者核心差异集中在 4 点:1. 存储大小:Cookie 约 4KB,LocalStorage 约 5M;2. 通信特性:Cookie 会随 HTTP 请求发送至服务端(关联域名导致 CDN 流量损耗),LocalStorage 仅在客户端使用,不与服务端通信;3. 核心定位:Cookie 侧重维持 HTTP 无状态的客户端状态,LocalStorage 是 HTML5 设计的专用本地缓存方案;4. 附加特性:Cookie 支持 expire 过期时间和 httponly 属性,LocalStorage 无过期时间(需主动清除)且无 httponly 相关设置。

问题 2:PWA 能提供 “可靠、快速、融入” 的用户体验,其背后依赖的关键技术支撑是什么?

答案:PWA 的核心体验依赖两大关键技术:1. Service Worker:通过后台运行的脚本拦截网络请求、管理缓存响应,实现无网络环境下的基本页面访问(支撑 “可靠” 特性),同时优化网络数据访问效率(辅助 “快速” 特性);2. IndexedDB:提供客户端大容量结构化数据存储能力,为 PWA 离线版本提供数据支撑(强化 “可靠” 特性);此外,Web 新特性与优化的 UI 交互设计共同保障了 “快速” 和 “融入”(如桌面添加、全屏显示)特性的实现。

问题 3:在实际开发中,如何根据需求选择合适的浏览器存储方式?

答案:需结合存储数据量、使用场景、是否与服务端交互等需求判断:1. 若需存储少量用户标识、会话状态(需随请求发送至服务端),选择 Cookie(约 4KB,支持过期时间);2. 若需在客户端持久化存储中等容量数据(不与服务端交互),如本地缓存配置、用户偏好,选择 LocalStorage(约 5M);3. 若需临时存储会话期间的表单数据、页面临时状态(会话结束后无需保留),选择 SessionStorage(约 5M);4. 若需存储大量结构化数据(如离线应用的本地数据库),支撑应用离线使用,选择 IndexedDB(无明确容量限制,支持索引和高性能搜索)。

终极指南:在 Flutter 中通过 sign_in_with_apple 实现 Apple 登录

作者 JarvanMo
2026年1月12日 09:56

Apple 登录已成为移动应用中必不可少的身份验证选项,尤其是在你的 App 已经提供了 Google 或 Facebook 等社交登录的情况下。Apple 甚至规定,如果 App 提供了第三方登录,就必须为 iOS 用户同时提供 Apple 登录选项。

在这篇博文中,我们将全方位拆解这一功能——从环境配置代码实现,再到 UI 设计后端校验以及最佳实践

🔥 什么是 Apple 登录? Apple 登录允许用户通过其 Apple ID 安全且私密地登录你的应用。它支持选择性分享邮箱,或者使用 Apple 的“隐藏邮件地址”转发服务。借助 Flutter 插件,这一功能可以完美适配 iOS、macOS、Android 以及 Web 端

📦 为什么要使用 sign_in_with_apple 插件? pub.dev 上的 sign_in_with_apple 是官方社区维护的 Flutter 桥接插件。它是目前集成 Apple 登录的主流方案,支持:

  • ✔ 唤起 Apple 原生身份验证界面
  • ✔ 请求用户信息(如全名和邮箱)
  • ✔ 获取身份令牌 (Identity Tokens) 和授权码 (Authorization Codes)
  • ✔ 跨平台支持(通过 sign_in_with_apple_web 等扩展支持 iOS、macOS、Android 和 Web)

🛠️ 准备工作 在开始之前,请确保你已满足以下条件:

  • ✔ 拥有一个已付费的 Apple Developer 计划账号
  • ✔ 已在 Apple Developer Portal 注册了应用的 Bundle ID
  • ✔ 使用 iOS 13+ 的真机或模拟器进行测试
  • ✔ 已在 Xcode 中配置了“Sign In with Apple”功能权限 (Capability)

👉 1. Apple Developer 后台配置

✅ 第一步:注册 App ID

  1. 登录后台,进入 Certificates, Identifiers & Profiles
  2. 选择 Identifiers → 点击 + 号。
  3. 填写应用描述(Description)和 Bundle ID
  4. 在下方列表勾选 Sign in with Apple
  5. 保存变更。

📌 作用: 这一步是让你的应用获得调用 Apple 身份验证服务的权限。

✅ 第二步:创建 Service ID(可选) 如果你计划支持 Web 端或 Android 端的重定向登录流程,则需要配置 Service ID

  1. 前往 IdentifiersService IDs
  2. 注册一个具有唯一名称的服务。
  3. 为其勾选 Sign in with Apple
  4. 配置用于 Web 验证的 重定向 URI (Redirect URIs)

📌 作用: 这主要用于 Web 端或基于 OAuth 的重定向验证流程。


👉 2. 添加 Flutter 依赖 打开你的 pubspec.yaml 文件,添加以下配置:

dependencies:
  flutter:
    sdk: flutter
  sign_in_with_apple: ^7.0.1

运行:

flutter pub get

✔ 这一步将安装 Apple 登录插件及其相关的依赖项。

👉 3. iOS 平台配置 (Xcode)

  1. 在 Xcode 中打开项目: 路径为 ios/Runner.xcworkspace

  2. 配置 Signing & Capabilities(签名与功能):

    • 点击左上角的 + Capability 按钮。
    • 搜索 Sign in with Apple 并双击添加。
  3. 核对配置,确保你的 App Target 包含:

    • 正确的 Bundle ID
    • 已添加的 Sign in with Apple 功能权限。
  4. (可选)启用 Keychain Sharing(钥匙串共享):

    • 开启此项有助于在用户卸载重装应用后,依然能保持登录状态或找回凭据。

👉 4. 在 Flutter 中添加登录按钮

import 'package:sign_in_with_apple/sign_in_with_apple.dart';

接下来,在你的界面里加入这个按钮:

SignInWithAppleButton(
  onPressed: () async {
    final credential = await SignInWithApple.getAppleIDCredential(
      scopes: [
        AppleIDAuthorizationScopes.email,
        AppleIDAuthorizationScopes.fullName,
      ],
    );

print("Authorization Code: ${credential.authorizationCode}");
    print("User Email: ${credential.email}");
  },
),

🔍 这段代码的作用:

  • ✔ 唤起 Apple 原生登录弹窗

  • ✔ 返回包含以下信息的凭证 (Credential):

    • authorizationCode (授权码)
    • identityToken (身份令牌)
    • emailfullName(可选)
  • ✔ 将这些数据发送至后端进行身份验证。


👉 5. 深入理解返回的凭证 (Credentials) 当用户完成登录时:

  • authorizationCode:一个短效代码,服务器用它向 Apple 换取访问令牌(Access Tokens)。
  • identityToken:一个包含用户信息(邮箱、姓名)的 JWT (JSON Web Token)
  • email & fullName仅在用户第一次登录时显示

📌 敲黑板: Apple 只会返回一次邮箱和姓名,所以请务必在第一次获取时就将其安全地存储到你的数据库中!


👉 6. 安全加固(后端校验) 拿到凭证后,为了确保安全,你需要:

  1. authorizationCode 发送到你的后端。
  2. 后端向 Apple 服务器请求交换访问令牌。
  3. 验证 identityToken 的签名和声明(Claims)。
  4. 在你的系统中创建或验证用户会话。

📌 作用: 这能确保只有合法的 Apple ID 才能进入系统,有效防止令牌伪造或重放攻击。

注意: 如果你使用的是 Firebase Authentication,Firebase 会帮你处理这整套复杂的校验流程,你无需自己手动实现后端验证逻辑。


👉 7. Android 与 Web 端支持 该插件通过额外配置也可支持 Android 和 Web:

  • ✔ Android:需要在 AndroidManifest.xml 中配置重定向 Activity。
  • ✔ Web:需在 Apple Developer 后台配置 Service ID 及其回调 URL。

📌 常见“坑点”提醒

  • 🚫 并非每次登录都能拿到邮箱 出于隐私保护,Apple 仅在首次登录时发送邮箱。之后,你只能拿到用户的唯一 ID (user ID),参看stackoverflow
  • 🚫 必须使用付费 Apple 账号 免费版 Apple ID 无法注册 Apple 登录所需的各项服务。

✨ 完整代码示例

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

class AppleSignInDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: SignInWithAppleButton(
          onPressed: () async {
            try {
              final credential = await SignInWithApple.getAppleIDCredential(
                scopes: [
                  AppleIDAuthorizationScopes.email,
                  AppleIDAuthorizationScopes.fullName,
                ],
              );
              // Send these credentials to the backend
              // and create your user session
              print(credential);
            } catch (e) {
              print("Error: $e");
            }
          },
        ),
      ),
    );
  }
}

📌 最佳实践

  • ✅ 务必在服务器端验证 Token:永远不要只在客户端做判断。
  • ✅ 安全存储用户信息:妥善保存初次获取到的邮箱和姓名。
  • ✅ 做好容错处理:当 Apple 返回的用户信息有限时(非首次登录),确保 App 逻辑依然稳健。
  • ✅ 使用真机测试:只有真机才能模拟最真实的授权行为和交互反馈。

📌 结语 通过使用 sign_in_with_apple 插件,你可以为 Flutter 用户提供快速且隐私优先的身份验证体验。虽然前端 UI 的集成非常简单,但严谨的后端 Token 校验才是保障系统安全的重中之重。

无论你的项目是基于 Firebase、自定义后端还是其他认证服务器,本指南都为你提供了全方位的参考,助你信心满满地在 Flutter 应用中上线 Apple 登录功能。

视频播放弱网提示实现

2026年1月12日 09:55

作者:陈盛靖

一、背景

业务群里面经常反馈,视频播放卡顿,视频播放总是停留在某一时刻就播放不了了。后面经过排查,发现这是因为弱网导致的。然而,用户数量众多,隔三差五总有人在群里反馈,有时问题一时半会好不了,用户就会怀疑不是网络,而是我们的系统问题。因此,我们希望能在弱网的时候展示提示,这样用户体验会更友好,同时也能减少一定的客诉。

二、现状分析

我们使用的播放器是chimee(www.chimee.org/index.html)。遗憾的是,chimee并没有视频播放卡顿自动展示loading的功能,不过我们可以通过其插件能力,来编写一个自定义video-loading的插件。

三、方案设计

使用NetworkInformation

常见的方法就是我们通过设定一个标准,然后检测用户设备的网络速度,在到达一定阈值时展示弱网提示。这里需要确定一个重要的点:什么情况下才算弱网?

我们的应用是h5,这里我们可以使用window对象中的NetworkInformation(developer.mozilla.org/zh-CN/docs/…),我们可以通过浏览器的debug工具,打印window.naviagtor.connection,这个对象内部就存储着网络信息:

其中各个属性含义如下表所示:

属性 含义
downlink 返回以兆比特每秒为单位的有效带宽估计,四舍五入到最接近的 25 千比特每秒的倍数。
downlinkMax 返回底层连接技术的最大下行速度,以兆比特每秒(Mbps)为单位。
effectiveType 返回连接的有效类型(意思是“slow-2g”、“2g”、“3g”或“4g”中的一个)。此值是使用最近观察到的往返时间和下行链路值的组合来确定的。
rtt 返回当前连接的有效往返时间估计,四舍五入到最接近的 25 毫秒的倍数。
saveData 如果用户在用户代理上设置了减少数据使用的选项,则返回 true。
type 返回设备用于网络通信的连接类型。它会是以下值之一:
bluetooth
cellular
ethernet
none
wifi
wimax
other
unknown
onchange 接口的 change 事件在网络连接信息发生变化时被触发,并且该事件由 NetworkInformation(developer.mozilla.org/zh-CN/docs/…) 对象接收。

其中,我们可以通过effectiveType判断当前网络的大体情况,并且可以拿到一个预估的网络带宽(downlink)。我们可以通过监听onchange事件,在网络变差的时候,展示对应的弱网提示。

这个方案的优点是:

  • 浏览器环境原生支持
  • 实现相对简单

但缺点却十分明显:

  • 网络状态变化非实时

effectiveType的变化可能是分钟级别的,对于短暂的网络波动,状态没办法做更精细的把控

  • 存在兼容性问题

对于不同一些主流浏览器不支持,例如Firefox、Safari等

  • 不同设备间存在差异

不同的设备和浏览器,由于其差异,在不同的网络情况下,视频的播放情况是不一样的,如果我们固定一个标准,可能会导致在不同设备下,同一个网络速度,有人明明正常播放视频,但是却提示网络异常,这样用户会感到疑惑。

那有没有更好的方法呢?

监听Video元素事件

chimee底层也是在html video上进行的二次封装,我们可以在插件的生命周期中,拿到对应的video元素节点。而在video标签中,存在这样两个事件:waiting和canplay。

其事件描述如下图所示:

当视频播放卡顿时,会触发waiting事件;而当视频播放恢复正常时,会触发canplay事件。只要监听这两个事件,我们就可以实现对应的功能了。

四、功能拓展

我们知道,现在大多数网站的视频在提示弱网的时候,都会展示当前设备的网络速度是多少。因此我们也希望在展示对应的信息。那么怎么实现网络速度的检测呢?

一个简单的方法是,我们可以通过获取一张固定大小的图片资源(不一定是图片,也可以是别的类型的资源),并统计请求该资源的请求速度,从而计算当前网络的带宽是多少。当然,图片大小要尽可能小一点,一是为了节省用户流量,二是为了避免在网络不好的情况下,图片请求太慢导致一直计算不出来。

具体代码如下:

funtion calculateSpeed() {
  // 图片大小772Byte
  const fileSize = 772;
  // 拼接时间戳,避免缓存
  const imgUrl = `https://xxx.png?timestamp=${new Date().getTime()}`;

  return new Promise((resolve, reject) => {
    let start = 0;
    let end = 1000;
    let img = document.createElement('img');
    start = new Date().getTime();
    img.onload = function (e) {
        end = new Date().getTime();
        // 计算出来的单位为 B/s
        const speed = fileSize / (end > start ? end - start : 1000) * 1000;
        resolve(speed);
    }
    img.src = imgUrl;
  }).catch(err => { throw err });
}
function translateUnit(speed) {
  if(speed === 0) return '0.00 B/s';
  if(speed > 1024 * 1024) return `${(speed / 1024 / 1024).toFixed(2)} MB/s`;
  if(speed > 1024) return `${(speed / 1024).toFixed(2)} KB/s`;
  else return `${speed.toFixed(2)} B/s`;
}

我们可以通过setInterval来轮询调用该函数,从而实时展示当前网络情况。系统流程图如下:

画板

五、总结

我们可以通过Chrome浏览器开发者工具中的Network中的网络配置来模拟弱网情况 具体效果如下:

成功实现视频弱网提示,完结撒花🎉🎉🎉🎉🎉🎉。

情迷服务器驱动 UI:我在 Flutter 开发中的爱与哀愁

作者 JarvanMo
2026年1月12日 09:47

嘿,Flutter 开发者!如果你曾经为了把一个按钮往左挪 20 像素,就不得不苦等三天的 App Store 审核,那你一定听过 Server-Driven UI (SDUI) 的“迷魂曲”。我曾在大型项目中落地过 SDUI,曾为此在凌晨三点崩溃大哭,但也最终学会了爱上它——前提是,你得知道怎么驯服这头怪兽。

这是我的实战心得:它的好,它的丑,以及到底什么时候该用(或绝对别碰)它。

魔法时刻——SDUI 让你像开了挂

  • 瞬间上线: 喝杯早咖啡的功夫,你就换掉了整个首页——不用重新打包,不用发布,更不用等苹果审核。
  • 究极 A/B 测试: 明天上线 15 个不同版本的下单流程,看看哪个转化率最高。这才是真正的 A/B 测试终极形态。
  • 千人千面: 给免费用户、高级用户、巴西用户或是上周弃购的用户展示完全不同的 UI。
  • 运营自由: 让市场部在 12 月 24 日晚上 11:59 发布圣诞活动,而你完全不用起床。
  • 迭代提速: 产品经理和设计师的迭代速度能快 10 倍,因为改个文案这种小事再也不用麻烦移动端同学了。
  • 包体积瘦身: 安装包里“写死”的页面少了,APK 也就苗条了。
  • 一份逻辑,三端共用: 一份 JSON 就能驱动 Flutter App、Web 端,甚至未来的 React Native 版本。
  • 灰度发布易如反掌: 觉得新模块不稳?先只发给 1% 的用户看看情况。
  • 离线友好: 配合好缓存,App 在断网时依然能完美运行——直接展示最后一次加载成功的界面就行。

这种感觉就像在作弊。而有时候……它确实就是在走捷径。


阴暗面——没人预先告诉你的坑

  • 一字之错,全线崩溃: JSON 里写错一个 Key,你就亲手给 200 万用户发了个白屏。而且还是在周日凌晨三点。
  • 调试噩梦: Flutter Inspector?基本没戏。热重载?帮不上忙。你只能对着原始 JSON 疯狂分析,然后在线祈祷。
  • 动画终结者: 你花了两个星期精心调优的自定义动画?祝你能用 JSON 把它描述出来。
  • 性能税: 每个页面都要跑 300-600 KB 的解析逻辑。
  • 层级深渊: 过深嵌套的 JSON 会让首帧卡得像回到了 2018 年。
  • 后端“越权”: 既然逻辑在后端,出事了锅就在后端。恭喜你,你们团队又多了一个互相甩锅的机会。
  • 审美崩坏: 后端同学可能完全不懂 Material 或 Cupertino 规范,分分钟搞出个四不像。
  • 安全漏洞: 如果校验不严,那个“跳转 URL”的动作可能会把用户带进钓鱼网站。
  • 复杂表单: 带自定义校验逻辑的复杂表单,做起来简直是自我折磨。

相信我,这些坑我都踩过。


什么时候用 SDUI 最爽?(闭眼入)

  • 动态页面: 首页、信息流、仪表盘,这类每周都要变的页面。
  • 运营活动: 营销横幅、节日活动、限时秒杀。
  • 内容驱动: 新闻、教育、社交动态、CMS 驱动的产品。
  • 高频实验: 任何需要疯狂做 A/B 测试的地方。
  • 个性化流程: 银行或电商 App 中,不同用户群体需要稍微不同的操作流。
  • 精锐部队: 后端实力极强且监控体系完善的大型团队。

什么时候用 SDUI 会让你怀疑人生?(快跑!)

  • 核心流程: 登录、注册、支付、结账——任何坏了就会丢钱、丢用户的页面。
  • 炫酷交互: 带有复杂动画、Custom Painter、Shader 或 Lottie 神作的页面。
  • 重性能体验: 相机、视频编辑器、游戏、地图、AR。
  • 小团队/初创: 还没有配齐专业的后端和运维力量。
  • 万年不变: App 半年才改一次 UI。
  • 缺乏基建: 团队没有完善的监控、告警和自动回滚机制。

听我一句劝:快跑,跑得越远越好。


如何正确落地 SDUI?(我的“保命”清单)

这是我希望第一天就能拿到的避坑指南:

  1. 拒绝“全量动效”: 永远不要 100% SDUI。目标是 70-90% 原生 Flutter + 10-30% 动态,把复杂的逻辑留在原生。
  2. 核心逻辑写死: 登录、支付、设置、底部导航这些核心链路永远硬编码。
  3. 建立组件库: 打造一套神圣不可侵犯的组件库(15-25 个 Widget)。后端只能通过这些组件来“搭积木”,绝无例外。
  4. Schema 版本化: 给每一版 JSON 加上版本号(比如 home_v17)。
  5. 永不裸奔: 为每一个动态页面准备一个原生兜底页面(Static Fallback)。
  6. 严苛校验: 像防贼一样校验输入的 JSON,只允许白名单内的操作。
  7. 疯狂缓存: 用 Hive 或 Isar 搞好缓存。用户看当前页时,就预取下一页。
  8. 鹰眼监控: 盯着解析错误、渲染时长和崩溃率,一点都不能松懈。
  9. 自动回滚: 设定好阈值。如果发完 JSON 崩溃率翻倍,瞬间撤回。
  10. 从小开始: 先试着动态化一个营销横幅。成功了,庆祝一下,再慢慢扩张。

按这些规矩办,SDUI 就是你最强的武器。如果不按规矩来,一个迭代就能让你老十岁。


最终裁决——你该用它吗?

  • 大厂/大 App,UI 变动频繁,后端强悍冲吧,拥抱它(记得带上上面的规则)。
  • 中型 App,有一些营销需求推荐,混合方案最香。
  • 小团队/个人开发者等等再说,等你有精力搞监控和基建了再来。
  • 核心流/重度动画老老实实写 Flutter,睡个安稳觉。

SDUI 不是魔法,它是一把电锯:双手抓稳并心存敬畏时,它威力无穷;但要是交给一个没见过电锯的人,后果不堪设想。

用得好,你就是全街发版最快的仔;用得烂,你就是那个圣诞节被 Call 起来修 Bug 的倒霉蛋。

言尽于此,好自为之。

祝你构建愉快!

Vben Admin管理系统集成qiankun微服务(二)

作者 go_caipu
2026年1月11日 23:05

继上篇

上篇Vben Admin管理系统集成qiankun微服务(一)遗留的三个问题:

  1. 子应用鉴权使用主应用鉴权,如果系统鉴权过期要跳转到登录页面。
  2. 主应用和子应用保持主题风格一致,主应用调整子应用同步调整。
  3. 支持多个应用动态加载。

下面分步完成以上相关内容

1. 主应用和子应用主题同步

主应用

主应用和子应用的数据传递主要使用props实现,上篇文章已经实现了部分没有详细解释,本篇补充以上内容。 通过props.userInfo和props.token 传递登录信息和授权信息,

vue-vben-admin/apps/web-antd/src/qiankun/config.ts

/**  本地应用测试微服务架构 */
export default {
  subApps: [
    {
      name: 'basic', // 子应用名称,跟package.json一致
      // entry: import.meta.env.VITE_API_BASE_URL, // 子应用入口,本地环境下指定端口
      entry: 'http://localhost:5667', // 子应用入口,本地前端环境下指定端口'http://localhost:5174',发布可以调整为主系统:/app/workflow-app/= /app/插件名称/
      container: '#sub-container', // 挂载子应用的dom
      activeRule: '/app/basic', // 路由匹配规则
      props: {
        userInfo: [],
        token: '',
      }, // 主应用与子应用通信传值
      sandbox: {
        strictStyleIsolation: true, // 启用严格样式隔离
      },
    },
  ],
};

vue-vben-admin/apps/web-antd/src/qiankun/index.ts文件,实现代码主要是在beforeLoad函数

// 参考项目:https://github.com/wstee/qiankun-web
import { useAccessStore, useUserStore } from '@vben/stores';

import { registerMicroApps } from 'qiankun';

import config from './config';

const { subApps } = config;

export async function registerApps() {
  try {
    // 如果子应用是不定的,可以这里定义接口从后台获取赋值给subApps,动态添加

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
         
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          const container = document.querySelector(app.container);
          if (container) container.innerHTML = '';
        },
      ],
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

子应用调整

修改代码读取主应用传递的参数,调整mount函数 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

   async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);
      // 监听主应用的主题事件
      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    }

如果操作子应用时登录信息失效了呢,要让应用跳转到登录,可以修改setupAccessGuard函数,按照如下修改直接跳转到系统登录页。

caipu-vben-admin/apps/app-antd-child/src/router/guard.ts

 // 没有访问权限,跳转登录页面
      if (to.fullPath !== LOGIN_PATH) {
        // return {
        //   path: LOGIN_PATH,
        //   // 如不需要,直接删除 query
        //   query:
        //     to.fullPath === preferences.app.defaultHomePath
        //       ? {}
        //       : { redirect: encodeURIComponent(to.fullPath) },
        //   // 携带当前跳转的页面,登录后重新跳转该页面
        //   replace: true,
        // };
        window.location = 'http://localhost:5666/#/login';
      }

这样就实现主应用和子应用的信息同步了。

2. 主应用与子应用主题同步

vben主题相关配置是在'@vben/preferences'包中,要调整的动态配置主要是在preferences.theme当中,所以实现主题同步只要把配置信息同步到子应用即可。

未通过props传递原因是加载子应用之后再调整偏好设置和主题 子应用不生效,所以考虑只能通另外一种方式实现,最终选择 window.dispatchEvent事件监听的方式实现。

image.png

主应用调整

调整 vue-vben-admin/apps/web-antd/src/layouts/basic.vue

# 引用包
import { preferences } from '@vben/preferences';

# 合适位置增加主题监听
watch(
  () => ({
    theme: preferences.theme,
  }),
  async ({ theme }) => {
    alert('handler qiankun-theme  start', theme);
    // 子应用会监听这个事件并更新响应式对象
    window.dispatchEvent(
      new CustomEvent('qiankun-theme-update', {
        detail: preferences,
      }),
    );
  },
  {
    immediate: true,
  },
);

子应用调整

如果细心的话,在上述子应用调整的main.ts,mount函数要已有说明,主要是增加事件监听qiankun-theme-update 和监听处理事件handleThemeUpdate,完整代码如下 caipu-vben-admin/apps/app-antd-child/web-demo/src/main.ts

import { initPreferences, updatePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { unmountGlobalLoading } from '@vben/utils';

import {
  qiankunWindow,
  renderWithQiankun,
} from 'vite-plugin-qiankun/dist/helper';

import { bootstrap } from './bootstrap';
import { overridesPreferences } from './preferences';

let app: any = null;
/**
 * 应用初始化完成之后再进行页面加载渲染
 */
async function initApplication(container: any = null) {
  // name用于指定项目唯一标识
  // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
  const env = import.meta.env.PROD ? 'prod' : 'dev';
  const appVersion = import.meta.env.VITE_APP_VERSION;
  const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;

  // app偏好设置初始化
  await initPreferences({
    namespace,
    overrides: overridesPreferences,
  });
  // 启动应用并挂载
  // vue应用主要逻辑及视图
  app = await bootstrap(namespace, container);
  // 移除并销毁loading
  unmountGlobalLoading();
}

const initQianKun = async () => {
  renderWithQiankun({
    async mount(props: any) {
      const { container, token, userInfo } = props;
      await initApplication(container);
      const useStore = useUserStore();
      const accessStore = useAccessStore();
      console.log('[子应用]  mounting', props);
      console.log('[子应用]  token:', token);
      console.log('[子应用]  userInfo:', userInfo);

      useStore.setUserInfo(userInfo);
      accessStore.setAccessToken(token);

      window.addEventListener('qiankun-theme-update', handleThemeUpdate);
      // 移除并销毁loading
      unmountGlobalLoading();
    },
    bootstrap() {
      return new Promise((resolve, reject) => {
        // eslint-disable-next-line no-console
        console.log('[qiankun] app bootstrap');
        resolve();
      });
    },
    update(props: any) {
      // eslint-disable-next-line no-console
      console.log('[子应用]  update');
      const { container } = props;
      initApplication(container);
    },
    unmount(props) {
      // 移除事件监听
      if (handleThemeUpdate) {
        // eslint-disable-next-line no-console
        console.log('remove sub apps theme handle:', app.name);
        window.removeEventListener('qiankun-theme-update', handleThemeUpdate);
      }
      // eslint-disable-next-line no-console
      console.log('[子应用] unmount', props);
      app?.unmount();
      app = null;
    },
  });
};
// 判断是否为乾坤环境,否则会报错iqiankun]: Target container with #subAppContainerVue3 not existed while subAppVue3 mounting!
qiankunWindow.__POWERED_BY_QIANKUN__
  ? await initQianKun()
  : await initApplication();

const handleThemeUpdate = (event: any) => {
  const newTheme = event.detail;
  if (newTheme) {
    // 更新响应式对象,由于是响应式的,Vue 会自动更新视图
    console.log('子应用主题已更新(通过 props + 事件):', newTheme);
    updatePreferences(newTheme);
  }
};

3. 支持多个应用动态加载

子应用如果不是固定subApps,要从后台加载那如何实现呢,比如我的程序实现子应用动态插拔,后台安装子应用之后前台就要支持展示。 代码逻辑是:本地调试从config.ts获取固定配置,发布环境读取后台配置。主要看registerApps()。 核心代码是下面这段:

 if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

完整文件代码是:

import type { MicroApp } from '#/api/apps/model';

import { useAccessStore, useUserStore } from '@vben/stores';

// 参考项目:https://github.com/wstee/qiankun-web
import { registerMicroApps } from 'qiankun';

import { GetMicroApp } from '#/api/apps';

import config from './config';

let { subApps } = config;

export async function registerApps() {
  try {
    // 判断是否是发布环境,发布环境从后台获p取subApps
    if (import.meta.env.PROD) {
      const data = await GetMicroApp();
      // 将获取的子应用数据转换为qiankun需要的格式
      subApps = data.map((app: MicroApp) => ({
        name: app.name, // 子应用名称
        entry: app.entry, // 子应用入口地址
        container: '#sub-container', // 子应用挂载节点
        activeRule: app.activeRule, // 子应用激活规则
        props: {
          userInfo: [],
          token: '',
        }, // 主应用与子应用通信传值
        sandbox: {
          strictStyleIsolation: true, // 启用严格样式隔离
        },
      }));
    }

    registerMicroApps(subApps, {
      beforeLoad: [
        (app: any) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeLoad', app.name);
          const useStore = useUserStore();
          const accessStore = useAccessStore();
          app.props.token = accessStore.accessToken;
          app.props.userInfo = useStore.userInfo;
          // app.props.publicKey = import.meta.env.VITE_PUBLIC_KEY;
        },
      ],
      // 生命周期钩子
      loader: (loading: any) => {
        // 可以在这里处理加载状态
        // eslint-disable-next-line no-console
        console.log('子应用加载状态:', loading);
      },
      beforeMount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('[主应用] beforeMount', app.name);
          // const container = document.querySelector(app.container);
          // if (container) container.innerHTML = '';
          // 仅隐藏容器,不删除 DOM
          if (app.container.style) {
            app.container.style.display = 'none';
          }
        },
      ],
      beforeUnmount: (app) => {
        // 重新显示容器
        if (app.container.style) {
          app.container.style.display = 'none';
        }
      },
      afterUnmount: [
        (app) => {
          // eslint-disable-next-line no-console
          console.log('count: %s', app);
        },
      ],
    });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.log('count: %s', error);
  }
}

GetMicroApp()返回数据结构json结果如下,主要是data的内容:

{
    "code": 200,
    "data": [
        {
            "name": "caipu-site",
            "entry": "/app/caipu-site/",
            "activeRule": "/app/caipu-site"
        },
        {
            "name": "email",
            "entry": "/app/email/",
            "activeRule": "/app/email"
        },
        {
            "name": "ip2region",
            "entry": "/app/ip2region/",
            "activeRule": "/app/ip2region"
        },
        {
            "name": "testdata",
            "entry": "/app/testdata/",
            "activeRule": "/app/testdata"
        }
    ],
    "msg": "",
    "success": true,
    "timestamp": 1768140865000
}

最后

  1. 上文有小伙伴回复是否可以支持主应用多页签切换不同子应用的页面状态保持,抱歉多次尝试未在vben实现此功能,作为一名后端人员技术有限如您有实现方案,请不吝指教。

  2. 抽时间也会尝试下wujie微前端方案完善相关功能,基于以上浅显内容,欢迎大积极尝试和分享。 如你有更好的建议内容分享请给评论。

如有幸被转载请注明出处: go-caipu

程序员武学修炼手册(三):融会贯通——从写好代码到架构设计

2026年1月11日 21:01

"小有所成修的是'术',融会贯通修的是'道'。" —— 《程序员修炼心法》

前情回顾

在前两篇中,我们经历了:

  • 初学乍练:从 Hello World 到能跑就行
  • 小有所成:从能跑就行到知其所以然

当你开始思考"系统应该怎么设计"而不只是"代码应该怎么写"的时候,恭喜你,你已经踏入了融会贯通的大门——成为真正的一流高手。


第一章:一流高手的特征

1.1 什么是融会贯通?

融会贯通,是程序员从"写代码的人"到"设计系统的人"的蜕变。

就像武侠小说里,高手从"会使剑"到"懂剑意"的升华。张无忌学太极剑时,张三丰问他忘了多少,他说全忘了——这就是融会贯通的境界,招式已经烂熟于心,开始追求更高层次的武学境界。

融会贯通(一流高手)程序员的典型特征:

  • 能独立负责一个模块或子系统
  • 开始关注架构设计和技术选型
  • 能指导初级开发者
  • 在技术讨论中有自己的见解
  • 开始思考"为什么这样设计"而不只是"怎么实现"

1.2 小有所成 vs 融会贯通

┌─────────────────────────────────────────────────────────────┐
│              小有所成 vs 融会贯通                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   小有所成关注:                    融会贯通关注:           │
│   ├─ 这个函数怎么写?            ├─ 这个系统怎么设计?       │
│   ├─ 代码规范是什么?            ├─ 为什么选这个技术栈?     │
│   ├─ 怎么写单元测试?            ├─ 系统瓶颈在哪里?         │
│   ├─ 这个Bug怎么修?             ├─ 如何保证系统稳定性?     │
│   └─ 怎么让代码更清晰?          └─ 如何应对未来的扩展?     │
│                                                             │
│   小有所成的产出:                  融会贯通的产出:         │
│   ├─ 高质量的代码                ├─ 架构设计文档             │
│   ├─ 单元测试                    ├─ 技术方案评审             │
│   └─ 代码审查意见                └─ 团队技术指导             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

第二章:融会贯通的修炼内容

2.1 第一式:系统设计思维

// 融会贯通的思维方式

// 需求:设计一个用户系统

// 小有所成的思考
// "用户表怎么设计?API怎么写?"

// 融会贯通的思考
/*
 * 1. 需求分析
 *    - 预计用户量?10万?100万?1000万?
 *    - 读写比例?读多写少?还是写多读少?
 *    - 有哪些核心功能?注册、登录、信息修改?
 *    - 有哪些非功能需求?性能、安全、可用性?
 *
 * 2. 架构设计
 *    - 单体还是微服务?
 *    - 数据库选型?MySQL?PostgreSQL?MongoDB?
 *    - 缓存策略?Redis?本地缓存?
 *    - 认证方案?Session?JWT?OAuth?
 *
 * 3. 扩展性考虑
 *    - 未来可能的功能扩展?
 *    - 如何支持水平扩展?
 *    - 数据迁移方案?
 *
 * 4. 风险评估
 *    - 单点故障?
 *    - 数据一致性?
 *    - 安全风险?
 */

2.2 第二式:架构模式

// 融会贯通必修:常见架构模式

// ===== 模式1:分层架构 =====
/*
 * ┌─────────────────────────────────────┐
 * │           表现层 (Controller)        │  处理HTTP请求
 * ├─────────────────────────────────────┤
 * │           业务层 (Service)           │  业务逻辑
 * ├─────────────────────────────────────┤
 * │           数据层 (Repository)        │  数据访问
 * ├─────────────────────────────────────┤
 * │           数据库 (Database)          │  数据存储
 * └─────────────────────────────────────┘
 */

// Controller层:只处理HTTP相关逻辑
class UserController {
  constructor(userService) {
    this.userService = userService
  }

  async createUser(req, res) {
    try {
      const user = await this.userService.createUser(req.body)
      res.status(201).json(user)
    } catch (error) {
      res.status(400).json({ error: error.message })
    }
  }
}

// Service层:业务逻辑
class UserService {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository
    this.emailService = emailService
  }

  async createUser(data) {
    // 业务校验
    await this.validateUserData(data)

    // 创建用户
    const user = await this.userRepository.create(data)

    // 发送欢迎邮件
    await this.emailService.sendWelcomeEmail(user.email)

    return user
  }
}

// Repository层:数据访问
class UserRepository {
  async create(data) {
    return await db.users.create(data)
  }

  async findById(id) {
    return await db.users.findOne({ where: { id } })
  }
}

// ===== 模式2:事件驱动架构 =====
/*
 * ┌─────────┐    事件    ┌─────────┐
 * │ 生产者  │ ────────> │ 消息队列 │
 * └─────────┘           └────┬────┘
 *                            │
 *              ┌─────────────┼─────────────┐
 *              ▼             ▼             ▼
 *         ┌─────────┐  ┌─────────┐  ┌─────────┐
 *         │ 消费者1 │  │ 消费者2 │  │ 消费者3 │
 *         └─────────┘  └─────────┘  └─────────┘
 */

// 事件发布
class OrderService {
  async createOrder(data) {
    const order = await this.orderRepository.create(data)

    // 发布事件,不直接调用其他服务
    await eventBus.publish("order.created", {
      orderId: order.id,
      userId: order.userId,
      amount: order.amount,
    })

    return order
  }
}

// 事件消费
class InventoryService {
  constructor() {
    // 订阅事件
    eventBus.subscribe("order.created", this.handleOrderCreated.bind(this))
  }

  async handleOrderCreated(event) {
    // 扣减库存
    await this.deductInventory(event.orderId)
  }
}

class NotificationService {
  constructor() {
    eventBus.subscribe("order.created", this.handleOrderCreated.bind(this))
  }

  async handleOrderCreated(event) {
    // 发送通知
    await this.sendOrderNotification(event.userId, event.orderId)
  }
}

// ===== 模式3:CQRS(命令查询职责分离)=====
/*
 * 写操作(Command)和读操作(Query)使用不同的模型
 *
 *         ┌─────────────┐
 *         │   客户端    │
 *         └──────┬──────┘
 *                │
 *       ┌────────┴────────┐
 *       ▼                 ▼
 * ┌───────────┐    ┌───────────┐
 * │  Command  │    │   Query   │
 * │  Service  │    │  Service  │
 * └─────┬─────┘    └─────┬─────┘
 *       │                │
 *       ▼                ▼
 * ┌───────────┐    ┌───────────┐
 * │  写数据库  │───>│  读数据库  │
 * │  (MySQL)  │同步│  (Redis)  │
 * └───────────┘    └───────────┘
 */

// 命令服务:处理写操作
class OrderCommandService {
  async createOrder(command) {
    const order = await this.orderRepository.create(command)

    // 同步到读模型
    await this.syncToReadModel(order)

    return order.id
  }
}

// 查询服务:处理读操作
class OrderQueryService {
  async getOrderList(userId, page, pageSize) {
    // 从读优化的数据源查询
    return await this.readCache.getOrders(userId, page, pageSize)
  }
}

2.3 第三式:技术选型

// 融会贯通必修:技术选型的艺术

// 技术选型不是选"最好的",而是选"最合适的"

// ===== 数据库选型 =====
const databaseSelection = {
  // 关系型数据库
  MySQL: {
    适合: ["事务要求高", "数据结构稳定", "复杂查询"],
    不适合: ["海量数据", "频繁schema变更", "高并发写入"],
    场景: "电商订单、用户系统、金融系统",
  },
  PostgreSQL: {
    适合: ["复杂查询", "JSON支持", "地理数据"],
    不适合: ["简单CRUD", "极致性能"],
    场景: "数据分析、GIS系统、复杂业务",
  },

  // NoSQL数据库
  MongoDB: {
    适合: ["文档型数据", "schema灵活", "快速迭代"],
    不适合: ["复杂事务", "强一致性要求"],
    场景: "内容管理、日志存储、原型开发",
  },
  Redis: {
    适合: ["缓存", "会话存储", "排行榜", "计数器"],
    不适合: ["持久化存储", "复杂查询"],
    场景: "缓存层、实时数据、消息队列",
  },
}

// ===== 技术选型决策框架 =====
function evaluateTechnology(options) {
  const criteria = {
    // 功能匹配度
    functionalFit: {
      weight: 0.3,
      questions: [
        "能否满足核心需求?",
        "是否需要大量定制?",
        "有没有现成的解决方案?",
      ],
    },
    // 团队能力
    teamCapability: {
      weight: 0.25,
      questions: [
        "团队是否熟悉这个技术?",
        "学习成本有多高?",
        "能否招到相关人才?",
      ],
    },
    // 生态系统
    ecosystem: {
      weight: 0.2,
      questions: ["社区活跃度如何?", "文档是否完善?", "有没有成熟的工具链?"],
    },
    // 运维成本
    operationalCost: {
      weight: 0.15,
      questions: ["部署复杂度?", "监控和调试是否方便?", "故障恢复难度?"],
    },
    // 未来发展
    futureProof: {
      weight: 0.1,
      questions: ["技术是否在上升期?", "是否有大公司背书?", "是否会被淘汰?"],
    },
  }

  // 评估每个选项
  return options
    .map((option) => ({
      name: option.name,
      score: Object.entries(criteria).reduce((total, [key, { weight }]) => {
        return total + (option.scores[key] || 0) * weight
      }, 0),
    }))
    .sort((a, b) => b.score - a.score)
}

2.4 第四式:性能优化

// 融会贯通必修:系统级性能优化

// ===== 性能优化的层次 =====
/*
 * 1. 架构层面:选择合适的架构
 * 2. 数据库层面:索引、查询优化、读写分离
 * 3. 缓存层面:多级缓存策略
 * 4. 代码层面:算法优化、并发处理
 * 5. 网络层面:CDN、压缩、HTTP/2
 */

// ===== 缓存策略 =====
class CacheService {
  constructor() {
    this.localCache = new Map() // L1: 本地缓存
    this.redis = redisClient // L2: Redis缓存
  }

  async get(key) {
    // L1: 先查本地缓存
    if (this.localCache.has(key)) {
      return this.localCache.get(key)
    }

    // L2: 再查Redis
    const redisValue = await this.redis.get(key)
    if (redisValue) {
      // 回填本地缓存
      this.localCache.set(key, JSON.parse(redisValue))
      return JSON.parse(redisValue)
    }

    return null
  }

  async set(key, value, ttl = 3600) {
    // 同时写入两级缓存
    this.localCache.set(key, value)
    await this.redis.setex(key, ttl, JSON.stringify(value))
  }

  async invalidate(key) {
    // 同时失效两级缓存
    this.localCache.delete(key)
    await this.redis.del(key)
  }
}

// ===== 数据库优化 =====
class QueryOptimizer {
  // 避免N+1查询
  async getUsersWithOrders_bad(userIds) {
    const users = await db.users.findAll({ where: { id: userIds } })

    // N+1问题:每个用户查一次订单
    for (const user of users) {
      user.orders = await db.orders.findAll({ where: { userId: user.id } })
    }

    return users
  }

  async getUsersWithOrders_good(userIds) {
    // 使用JOIN或预加载
    return await db.users.findAll({
      where: { id: userIds },
      include: [{ model: db.orders }],
    })
  }

  // 分页优化
  async getOrderList_bad(page, pageSize) {
    // OFFSET大了会很慢
    return await db.orders.findAll({
      offset: (page - 1) * pageSize,
      limit: pageSize,
    })
  }

  async getOrderList_good(lastId, pageSize) {
    // 使用游标分页
    return await db.orders.findAll({
      where: { id: { [Op.gt]: lastId } },
      limit: pageSize,
      order: [["id", "ASC"]],
    })
  }
}

// ===== 并发处理 =====
class ConcurrencyHandler {
  // 并行处理独立任务
  async processParallel(items) {
    // 不好:串行处理
    // for (const item of items) {
    //   await processItem(item);
    // }

    // 好:并行处理
    await Promise.all(items.map((item) => processItem(item)))
  }

  // 控制并发数
  async processWithLimit(items, limit = 5) {
    const results = []
    const executing = []

    for (const item of items) {
      const promise = processItem(item).then((result) => {
        executing.splice(executing.indexOf(promise), 1)
        return result
      })

      results.push(promise)
      executing.push(promise)

      if (executing.length >= limit) {
        await Promise.race(executing)
      }
    }

    return Promise.all(results)
  }
}

2.5 第五式:系统稳定性

// 融会贯通必修:保障系统稳定性

// ===== 熔断器模式 =====
class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5
    this.resetTimeout = options.resetTimeout || 30000
    this.state = "CLOSED" // CLOSED, OPEN, HALF_OPEN
    this.failureCount = 0
    this.lastFailureTime = null
  }

  async call(fn) {
    if (this.state === "OPEN") {
      if (Date.now() - this.lastFailureTime > this.resetTimeout) {
        this.state = "HALF_OPEN"
      } else {
        throw new Error("Circuit breaker is OPEN")
      }
    }

    try {
      const result = await fn()
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }

  onSuccess() {
    this.failureCount = 0
    this.state = "CLOSED"
  }

  onFailure() {
    this.failureCount++
    this.lastFailureTime = Date.now()

    if (this.failureCount >= this.failureThreshold) {
      this.state = "OPEN"
    }
  }
}

// 使用
const breaker = new CircuitBreaker({ failureThreshold: 3 })

async function callExternalService() {
  return breaker.call(async () => {
    return await fetch("https://external-api.com/data")
  })
}

// ===== 重试机制 =====
async function withRetry(fn, options = {}) {
  const {
    maxRetries = 3,
    delay = 1000,
    backoff = 2, // 指数退避
    shouldRetry = () => true,
  } = options

  let lastError

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error

      if (attempt === maxRetries || !shouldRetry(error)) {
        throw error
      }

      const waitTime = delay * Math.pow(backoff, attempt)
      await new Promise((resolve) => setTimeout(resolve, waitTime))
    }
  }

  throw lastError
}

// 使用
const result = await withRetry(() => fetch("https://api.example.com/data"), {
  maxRetries: 3,
  delay: 1000,
  shouldRetry: (error) => error.status >= 500, // 只重试服务端错误
})

// ===== 限流 =====
class RateLimiter {
  constructor(limit, windowMs) {
    this.limit = limit
    this.windowMs = windowMs
    this.requests = new Map()
  }

  isAllowed(key) {
    const now = Date.now()
    const windowStart = now - this.windowMs

    // 获取该key的请求记录
    let timestamps = this.requests.get(key) || []

    // 清理过期记录
    timestamps = timestamps.filter((t) => t > windowStart)

    if (timestamps.length >= this.limit) {
      return false
    }

    timestamps.push(now)
    this.requests.set(key, timestamps)
    return true
  }
}

// 使用
const limiter = new RateLimiter(100, 60000) // 每分钟100次

app.use((req, res, next) => {
  const key = req.ip

  if (!limiter.isAllowed(key)) {
    return res.status(429).json({ error: "Too many requests" })
  }

  next()
})

// ===== 降级策略 =====
class DegradationService {
  constructor() {
    this.degradationFlags = {
      useCache: false,
      skipNonEssential: false,
      returnDefault: false,
    }
  }

  async getProductDetail(productId) {
    // 正常流程
    if (!this.degradationFlags.useCache) {
      try {
        return await this.fetchFromDatabase(productId)
      } catch (error) {
        // 数据库出问题,自动降级
        this.degradationFlags.useCache = true
      }
    }

    // 降级:使用缓存
    const cached = await this.getFromCache(productId)
    if (cached) {
      return { ...cached, _degraded: true }
    }

    // 再降级:返回默认数据
    if (this.degradationFlags.returnDefault) {
      return {
        id: productId,
        name: "商品信息加载中",
        price: 0,
        _degraded: true,
        _default: true,
      }
    }

    throw new Error("Service unavailable")
  }
}

2.6 第六式:团队协作

// 融会贯通必修:技术领导力

// ===== 技术方案评审 =====
const technicalReviewTemplate = {
  // 1. 背景与目标
  background: {
    问题描述: "当前系统存在什么问题?",
    业务目标: "这个方案要达成什么业务目标?",
    技术目标: "这个方案要达成什么技术目标?",
  },

  // 2. 方案设计
  design: {
    整体架构: "系统架构图",
    核心流程: "关键流程图",
    数据模型: "数据库设计",
    接口设计: "API设计",
  },

  // 3. 技术选型
  techStack: {
    选型理由: "为什么选择这个技术?",
    备选方案: "考虑过哪些其他方案?",
    对比分析: "各方案的优缺点对比",
  },

  // 4. 风险评估
  risks: {
    技术风险: "可能遇到的技术难点",
    业务风险: "可能影响的业务场景",
    缓解措施: "如何降低风险",
  },

  // 5. 实施计划
  plan: {
    里程碑: "关键节点和交付物",
    资源需求: "需要多少人、多长时间",
    依赖项: "依赖哪些其他团队或系统",
  },
}

// ===== 代码审查指导 =====
const codeReviewGuidelines = {
  // 审查重点
  focus: [
    "代码是否符合设计方案?",
    "是否有明显的性能问题?",
    "错误处理是否完善?",
    "是否有安全隐患?",
    "代码是否可测试?",
    "是否有足够的日志?",
  ],

  // 反馈方式
  feedback: {
    必须修改: "🔴 [Must Fix] 这个问题必须修复",
    建议修改: "🟡 [Suggestion] 建议这样改会更好",
    讨论: "🔵 [Discussion] 这里我有个疑问",
    赞赏: "🟢 [Nice] 这个写法很棒",
  },

  // 审查态度
  attitude: [
    "对事不对人",
    "提供具体的改进建议",
    "解释为什么这样更好",
    "承认自己也可能是错的",
  ],
}

// ===== 技术分享 =====
class TechSharingSession {
  constructor(topic) {
    this.topic = topic
    this.outline = []
  }

  // 分享结构
  createOutline() {
    return {
      // 1. 引入(5分钟)
      introduction: {
        hook: "一个引人入胜的问题或故事",
        context: "为什么这个话题重要",
        overview: "今天要讲什么",
      },

      // 2. 主体(20-30分钟)
      body: {
        concept: "核心概念解释",
        demo: "实际演示",
        codeWalkthrough: "代码讲解",
        bestPractices: "最佳实践",
        pitfalls: "常见陷阱",
      },

      // 3. 总结(5分钟)
      conclusion: {
        keyTakeaways: "关键要点回顾",
        resources: "进一步学习资源",
        qa: "问答环节",
      },
    }
  }
}

第三章:融会贯通的常见瓶颈

3.1 过度架构

// 症状:简单问题复杂化

// 需求:一个内部工具,用户量<100

// 过度架构版本
/*
 * ┌─────────────────────────────────────────────────────────┐
 * │                      API Gateway                        │
 * └─────────────────────────────────────────────────────────┘
 *                            │
 *         ┌──────────────────┼──────────────────┐
 *         ▼                  ▼                  ▼
 *   ┌───────────┐     ┌───────────┐     ┌───────────┐
 *   │ User      │     │ Order     │     │ Product   │
 *   │ Service   │     │ Service   │     │ Service   │
 *   └─────┬─────┘     └─────┬─────┘     └─────┬─────┘
 *         │                 │                 │
 *         ▼                 ▼                 ▼
 *   ┌───────────┐     ┌───────────┐     ┌───────────┐
 *   │ User DB   │     │ Order DB  │     │ Product DB│
 *   └───────────┘     └───────────┘     └───────────┘
 *         │                 │                 │
 *         └────────────┬────┴────────────────┘
 *                      ▼
 *              ┌───────────────┐
 *              │ Message Queue │
 *              └───────────────┘
 */

// 合适的架构版本
/*
 * ┌─────────────────────────────────────────────────────────┐
 * │                    单体应用                              │
 * │  ┌─────────┐  ┌─────────┐  ┌─────────┐                 │
 * │  │ User    │  │ Order   │  │ Product │                 │
 * │  │ Module  │  │ Module  │  │ Module  │                 │
 * │  └─────────┘  └─────────┘  └─────────┘                 │
 * └─────────────────────────────────────────────────────────┘
 *                            │
 *                            ▼
 *                    ┌───────────────┐
 *                    │    MySQL      │
 *                    └───────────────┘
 */

// 教训:架构要匹配业务规模
// 小项目用微服务 = 用大炮打蚊子

3.2 技术选型偏见

// 症状:只推荐自己熟悉的技术

// 错误的选型思路
function chooseTechnology(requirements) {
  // "我熟悉React,所以用React"
  // "我们一直用MySQL,所以继续用MySQL"
  // "这个新技术很火,我们也用"

  return myFavoriteTech
}

// 正确的选型思路
function chooseTechnology(requirements) {
  const options = getAllOptions()

  return options
    .filter((tech) => tech.meetsFunctionalRequirements(requirements))
    .map((tech) => ({
      tech,
      score: evaluateTech(tech, {
        teamFamiliarity: 0.3,
        communitySupport: 0.2,
        performanceNeeds: 0.2,
        maintenanceCost: 0.2,
        futureProof: 0.1,
      }),
    }))
    .sort((a, b) => b.score - a.score)[0].tech
}

3.3 沟通障碍

// 症状:技术方案讲不清楚

// 错误的沟通方式
function explainToNonTech() {
  return `
    我们需要用Redis做缓存层,配合MySQL的读写分离,
    通过消息队列实现异步解耦,用熔断器保证系统稳定性...
  `
  // 产品经理:???
}

// 正确的沟通方式
function explainToNonTech() {
  return `
    问题:现在系统在高峰期会变慢
    
    方案:
    1. 加一个"记忆层",常用数据不用每次都去数据库查
       (就像你常用的文件放桌面,不用每次去柜子里找)
    
    2. 把一些不紧急的任务放到后台处理
       (就像餐厅点餐后,你不用站在厨房等,可以先坐下)
    
    3. 加一个"保险丝",某个服务出问题时自动切断
       (就像家里的电闸,短路时自动跳闸保护其他电器)
    
    效果:高峰期响应时间从3秒降到0.5秒
    成本:需要2周开发时间,增加一台服务器
  `
}

第四章:融会贯通的突破契机

4.1 第一次系统设计

// 场景:负责设计一个新系统

// 你的设计过程
const systemDesignProcess = {
  // 第一步:需求分析
  step1_requirements: {
    功能需求: ["用户注册登录", "商品浏览", "下单支付", "订单管理"],
    非功能需求: {
      性能: "QPS 1000,响应时间 < 200ms",
      可用性: "99.9%",
      安全性: "数据加密,防SQL注入",
    },
    约束条件: {
      时间: "3个月",
      人力: "3个后端 + 2个前端",
      预算: "云服务费用 < 5000/月",
    },
  },

  // 第二步:架构设计
  step2_architecture: {
    整体架构: "单体应用 + 读写分离",
    技术栈: {
      后端: "Node.js + Express",
      数据库: "MySQL + Redis",
      前端: "React",
      部署: "Docker + Kubernetes",
    },
  },

  // 第三步:详细设计
  step3_detailedDesign: {
    数据模型: "用户表、商品表、订单表...",
    API设计: "RESTful API",
    缓存策略: "热点数据缓存 + 会话缓存",
  },

  // 第四步:评审与迭代
  step4_review: {
    评审意见: ["考虑分库分表", "增加监控告警", "补充降级方案"],
    迭代优化: "根据反馈调整设计",
  },
}

4.2 第一次处理线上事故

// 场景:凌晨3点,系统崩了

// 事故处理流程
const incidentResponse = {
  // 1. 快速止血(5分钟内)
  step1_stopBleeding: {
    actions: ["确认影响范围", "启动降级方案", "通知相关人员"],
    你的操作: `
      // 发现数据库连接池耗尽
      // 立即重启应用服务器
      // 开启限流,减少请求压力
    `,
  },

  // 2. 定位问题(30分钟内)
  step2_findRoot: {
    actions: ["查看监控指标", "分析日志", "检查最近变更"],
    你的发现: `
      // 发现是新上线的功能有慢查询
      // 一个没加索引的查询,在数据量大时变得很慢
      // 导致连接池被占满
    `,
  },

  // 3. 修复问题
  step3_fix: {
    临时方案: "回滚代码",
    根本方案: "添加索引 + 优化查询",
  },

  // 4. 复盘总结
  step4_postmortem: {
    时间线: "完整的事故时间线",
    根因分析: "为什么会发生?",
    改进措施: [
      "上线前必须进行性能测试",
      "添加慢查询监控告警",
      "完善代码审查checklist",
    ],
  },
}

第五章:融会贯通的修炼心法

5.1 心法一:没有银弹

// 融会贯通的认知
// "没有一种技术或方法能解决所有问题"

// 实践
const noSilverBullet = {
  微服务: {
    不是银弹: "小团队用微服务可能是灾难",
    适用场景: "大团队、复杂业务、需要独立部署",
  },

  缓存: {
    不是银弹: "缓存带来一致性问题",
    适用场景: "读多写少、可以容忍短暂不一致",
  },

  NoSQL: {
    不是银弹: "牺牲了事务和复杂查询能力",
    适用场景: "数据结构灵活、不需要复杂事务",
  },

  异步: {
    不是银弹: "增加了系统复杂度和调试难度",
    适用场景: "耗时操作、不需要立即返回结果",
  },
}

// 选择技术方案时,要问:
// 1. 这个方案解决了什么问题?
// 2. 这个方案带来了什么新问题?
// 3. 新问题是否可以接受?

5.2 心法二:权衡的艺术

// 融会贯通的核心能力:在各种约束下做出最优选择

const tradeoffs = {
  // 一致性 vs 可用性
  consistencyVsAvailability: {
    选择一致性: "金融系统、库存系统",
    选择可用性: "社交媒体、内容系统",
  },

  // 性能 vs 可维护性
  performanceVsMaintainability: {
    选择性能: "核心热点路径",
    选择可维护性: "大部分业务代码",
  },

  // 快速上线 vs 完美设计
  speedVsPerfection: {
    选择速度: "验证业务假设、抢占市场",
    选择完美: "核心系统、长期维护的代码",
  },

  // 自研 vs 采购
  buildVsBuy: {
    选择自研: "核心竞争力、特殊需求",
    选择采购: "通用功能、节省时间",
  },
}

5.3 心法三:系统思维

// 融会贯通要学会从系统角度看问题

// 不只是看代码,还要看:
const systemThinking = {
  // 上下游依赖
  dependencies: {
    上游: "谁调用我?他们的调用模式是什么?",
    下游: "我调用谁?他们的SLA是什么?",
  },

  // 数据流
  dataFlow: {
    输入: "数据从哪里来?格式是什么?",
    处理: "数据如何被处理?",
    输出: "数据到哪里去?谁会使用?",
  },

  // 故障模式
  failureModes: {
    问: "如果这个组件挂了会怎样?",
    问: "如果网络延迟增加10倍会怎样?",
    问: "如果数据量增加100倍会怎样?",
  },

  // 演进路径
  evolution: {
    问: "半年后业务会怎么变?",
    问: "这个设计能支撑多久?",
    问: "什么时候需要重构?",
  },
}

第六章:融会贯通的毕业考核

6.1 毕业标准

┌─────────────────────────────────────────────────────────────┐
│              融会贯通毕业标准 ✓                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   □ 能独立完成中等复杂度系统的架构设计                       │
│   □ 能进行合理的技术选型,并说明理由                         │
│   □ 能识别系统瓶颈,并提出优化方案                           │
│   □ 能处理线上事故,并进行有效复盘                           │
│   □ 能指导初中级开发者,进行有效的代码审查                   │
│   □ 能与产品、测试等角色有效沟通技术方案                     │
│   □ 能在技术方案评审中提出有价值的意见                       │
│   □ 开始形成自己的技术判断力和方法论                         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

结语:融会贯通的意义

融会贯通是程序员从"执行者"到"设计者"的关键转变。

在这个阶段,你会:

  • 开始负责更大范围的技术决策
  • 学会在各种约束下做出权衡
  • 开始影响团队的技术方向
  • 形成自己的技术判断力

融会贯通的核心是:建立系统思维,学会权衡取舍。

下一篇,我们将进入登峰造极——当你开始在更大范围内产生技术影响力,成为团队或公司的技术专家时,你就踏入了绝顶高手的大门。


预告:登峰造极

在登峰造极,你将学习:

  • 技术战略与规划
  • 跨团队技术协调
  • 技术影响力建设
  • 人才培养与团队建设
  • 如何成为绝顶高手

敬请期待!


本文是《程序员武学修炼手册》系列的第三篇。

如果你正处于融会贯通的境界,恭喜你已经成为团队的技术骨干。

继续修炼,登峰造极在向你招手! 🚀

insertAdjacentHTML踩坑实录:AI没搞定的问题,我给搞定啦

2026年1月11日 20:40

今天开发「Todo-List」应用新特性:任务支持配置标签功能。

在做添加标签的美化效果:点击【+标签】按钮,自动转换为可编辑标签名的输入框。

标签功能示例.gif

用AI编写效果,很快就编码完了。

💡提示词:请优化标签在页面的显示方式,要求: 1.创建任务或更新任务时,可以显示数据库现有标签供用户选择; 2.用户在创建或更新任务时,可以通过选择或取消选择的方式来设置任务关联的标签; 3.用户在创建或更新任务时,可以新增标签,在现有标签列最后是一个+标签,点击该标签即可转换为标签编辑模式进行添加新标签,而后添加完新标签,在新标签后一个又出现新的+标签; 4.创建或更新任务中,除+标签外,数据库已有标签的名字后面可以以数字形式显示当前标签关联任务数据; 5.创建或更新任务时,除+标签外,显示数字是0的标签可以通过标签末端的×进行删除。请实现上述功能!

但是尬尴:重复点击【+标签】按钮,监听事件仅首次生效,后续点击都不生效。而且关键是给AI说明了问题场景后,AI没修复!

💡提示词:当前+标签存在bug,点击首次可以生效转变为编辑模式,使用×关闭后,重新点击+标签,无法再次转换为编辑模式。要求实现可以重复点击和转换

(PS:为了证明AI没修复对,此处附上AI修复方式的截图呢)

靠AI有时候确实也是靠不住,还是得靠自己啊。

一波调试定位,发现这行代码: selector.insertAdjacentHTML('beforeend', inputHtml),前后两次执行效果竟然不一样。

这时候,第二个AI大佬就该登场了。(PS:毕竟我前端代码也不熟,还是得仰仗AI啊)

insertAdjacentHTML() 是 DOM 操作 API,用于在指定位置插入 HTML 字符串。如果元素有id,且id重复插入,那么就会出现问题。

<!-- 第一次插入 -->
<input id="myInput" type="text">

<!-- 第二次插入(相同ID) -->
<input id="myInput" type="text"> <!-- 重复ID,事件可能失效 -->

好吧,检查了下代码,确实是这个原因。

最终修复方式也很简单,id动态化。

generateRandomId() {
  // 时间戳确保唯一性,随机数增加安全性
  return `new-tag-input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

// 显示新增标签输入框
showAddTagInput() {
    const addBtn = document.getElementById('add-tag-btn');
    if (!addBtn) return;

    // 生成当前counter值对应的ID
    const currentId = this.generateRandomId();

    const inputHtml = `
        <span class="tag-input-mode" id="tag-input-mode">
            <input type="text" id="${currentId}" placeholder="标签名" maxlength="20">
            <button class="btn-cancel" id="cancel-add-tag">×</button>
        </span>
    `;

    addBtn.replaceWith(document.createElement('span'));
    const inputContainer = document.getElementById('tag-input-mode');
    if (inputContainer) {
        inputContainer.outerHTML = inputHtml;
    } else {
        const selector = document.getElementById('tags-selector');
        selector.insertAdjacentHTML('beforeend', inputHtml);
    }

    // 绑定事件
    // ...
}

所以,前端的开发同学要留意啦,insertAdjacentHTML 这个API使用的时候可以要注意插入的元素的id或class不能重复哦,不然会出现二次点击事件不生效的呢。

今天分享的内容就到这了,感谢阅读,欢迎三连哦!

Promise为什么比回调函数更好

2026年1月11日 19:45

Promise是什么

promise是什么, promise是一种为你等待底层异步值完成的机制,同时promise也是一种异步任务流程控制的机制,以下举例说明这两种理解

Promise等待了未来值

我们先看一个例子, 假设我们有两个函数 fetchX, fetchY,我们需要把这两个函数的结果相加

let x, y;
fetchX(function (x) {
    x = x;
});
fetchY(function (Y) {
    y = y;
});

console.log(x + y);

在上述中,console.log(x + y);的结果是不确定的,当x已经决议(完成),但是y还没有决议,或者是y已经决议,但是x还没有决议,又或者是两者都没有决议,那么我们就完全得不到想要的结果,怎么去解决这个问题呢,我们可以写一个函数去解决这个问题

function add (fetchX, fetchY, cb) {
    let x, y;
    fetchX(function (valX) {
        x = valX;
        
        if (y !== undefined) { //y准备好了?
            cb(x + y);
        }
    });
    
    fetchY(function (valY) {
        y = valY;
        
        if (x !== undefined) {//x准备好了?
            cb(x + y);
        }
    });
    
}

//调用add
add(fetchX, fetchY, function (sum) {
    console.log(sum);
})

在这段代码中,我们使用了一个函数add,这个函数它为我们等待了x或者y决议,并且在全部完成决议的时刻执行打印,这个函数把现在和将来的结果都统一了,它不在会有undefined的成分了,同时你可以发现,我们把console.log的代码放到了将来执行,也就是说,这个函数把现在的代码(指打印操作)和将来的代码(指赋值操作),全部放到了将来,这个也就是promise的概念,请看以下代码

function add (xPromise, yPromise) {
    return Promise.all([xPromise, yPromise])
      .then((value) => {
        return value[0] + value[1];
    })
}

add(fetchX, fetchY)
  .then((sum) => {
    console.log(sum);
}, (err) => {
    console.error(err);
})

这段函数和上述的函数结果是一样的,区别在于上述的代码非常丑陋,所以我们可以说promise是一种为你等待未来值并且把代码全部变成异步的机制

Promise控制异步任务流程

请看以下代码

function foo (x) {
    return new Promise((resolve, reject) => {
        //最终可能调用resolve(...)或者reject(...)
    })
}

function bar () {
    //foo完成,执行bar的任务
}

function oopsBar () {
    //foo执行出错
}

function baz () {
    //foo完成,执行baz的任务
}
function oopsBar() {
    //foo执行出错
}

var p = foo(42);

p.then(bar, oopsBar);
p.then(baz, oopsBaz);

在上述代码中,当p执行成功。则会执行函数bar, baz,如果出错,则执行函数oopsBar,oopsBaz,从这个意义上说,foo在成功的时候才会调用bar, baz,出错则调用oppsBar,oppsBaz,不管是什么结果,都是通过Promise控制接下来的流程,,从这个角度上我们说promise是一种控制异步任务流程的机制

Promise为什么解决了回调函数的问题

回调函数在异步流程中的两个问题,一个是回调函数在异步编程中非线性非顺序,一个是回调函数在异步编程中的信任问题。首先,promise写法是线性的,每一个then都不需要关心上一个then的时间状态,当then的回调执行的时候,上一个then的promise必然是已经决议了,这就解决了嵌套带来的问题。 其次是回调函数的信任问题。

调用过早

调用过早主要是指回调函数是否是有时同步有时异步,回调函数如果有时候是同步,有时候又是异步,这样的问题被称为Zalgo,但是promise天生就避免了这个问题,Promise的then总是会被异步调用,即使你的给Promise传入的函数是立即决议的,类似于new Promise(function () { resolve(42) } ), 它的then也会被异步的调用

调用过晚

Promise的对象在完成的时候(Promise对象调用resolve或reject),这个promise的then注册的回调函数就在下一个异步事件点上一定会被触发, 请你判断下面的例子的顺序

p.then(function () {
   p.then(funcion () {
          console.log("C");
        });
    console.log("A");
});
p.then(function () {
   console.log("B"); 
});

在这个例子中, C无法打断或者抢占B, 执行顺序一定是B,Promise这样的运行方式保证了B不会被后注册的回调函数执行打印了C导致从调用过晚

回调未调用

Promise的then一定会被调用,这是promise机制本身保证的,如果promise永远没有被完成,也可以使用代码解决

function timeoutPromise(delay) {
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            reject("Timeout");
        }, delay)
    })
}


Promise.race([
    foo(),
    timeoutPromise(3000)
]).then(function () {
    //foo..及时完成
}, function () {
    //foo拒绝或者未按时完成
})

总结

Promise为什么比回调函数好,一点在于可读性,另一点在于Promise把回调函数的控制反转再反转,通过Promise去执行我们的回调,所以Promise解决信任问题的方法可以说是把控制权给了一个绝对可信任的系统(Promise),而不是第三方库

普通前端仔的 2025 : 年终总结与 AI 对我的影响

2026年1月12日 09:30

前言

2025 年已经过去,时间滚滚向前。恍惚间,仿佛上一次过年还在不久之前,转眼一年却已走到尾声。借着掘金这次 # 🏆2025 AI / Vibe Coding 对我的影响|年终征文 活动的机会,我想和大家分享一下自己的年终总结,并聊一聊 2025 年 AI 在工作与学习中对我的实际帮助。

开始坚持写文章分享

在今年年初,我和老婆完成了订婚,年中正式领取了结婚证💗。我的肩上多了一份对家庭的责任,也开始一起规划未来的生活,坚定了一个目标:一定要多赚一些钱,才能更有底气地生活。

后来我想到,之前曾看到有人提到在技术社区持续写文章,有机会接到外包或私活。于是,我决定在自己最常逛的技术社区——掘金,开始发布一些原创技术文章。

最早是在 2024 年 12 月底,因为工作阶段性需求不大,有了一些空闲时间,我便开始动笔。但现实很快给了我反馈:文章写完后几乎没人看。其实这也很正常,就像刚开始做自媒体需要“起号”一样,一个新账号发布的第一篇文章,基本不会有太多曝光。

后来,好朋友韬哥提醒我,文章审核通过后可以让朋友帮忙点点赞,新文章有机会进入周榜,从而获得更多曝光。这里也要感谢我老婆以及几位朋友,对我写作的支持与鼓励和建议。万分感谢🙏

接下来就是不断地写。直到有一篇 # 前端开发又幸福了,Cursor + Figma MCP 快速还原设计稿 意外火了,不仅阅读量明显上涨,还被掘金公众号转发。事实上,这篇文章反而是我写得最随意、耗时最短的一篇,可能正好踩中了 MCP 的热点。当时 MCP 刚出现不久,那段时间我确实非常开心。

或许是因为好奇心比较强——说得直白一点,其实也是想“偷懒”——我一直很愿意尝试新事物😂,所以第一时间体验了 MCP,确实让人眼前一亮。随后我便迫不及待地想把这些体验分享出来,担心同事在实际使用中踩坑,便写下了这篇文章,想着审核通过后可以直接转发给同事参考实践。后面关于 AI 的相关内容,我也会继续深入,具体聊一聊 AI 在工作方式和工作内容上带来的改变。

我在写文章的过程中,也会适当借助一些 AI 辅助。毕竟我的文笔并不算好,容易偏口语化,自己写完再读一遍时,常常都有些读不下去,因此我通常会让 AI 帮我优化一下文案表达。在这里也确实要感谢“AI 老师”,在写作效率和可读性上给了我很大的帮助。

但与此同时,我也非常排斥“AI 味”过重的文章。掘金上有些上周榜的内容,我几乎一眼就能看出是 AI 生成的。或许现在还能分辨,再过两年就未必了。我记得有一次刷到一篇讲“2025 年 JavaScript 新语法糖”的文章,通篇都是 AI 胡编乱造的内容,作者既没有自行验证,也没有标注 AI 生成,就这样直接发布出来。这种行为在我看来完全是在误导新人,想到这里就会感到非常生气。

我始终认为,每个人都应该对自己分享的知识负责。因此,我写的文章尽量都是真人思考、真人实践得出的内容,只是借助 AI 做一些文字层面的润色,而不是让它替我“创作观点”。

随着 AI 能力不断增强,一些常见、零散的编程问题其实已经不太值得单独分享了,比如 JavaScript 时间处理中的各种坑,AI 的回答往往已经足够准确和全面。相比之下,更有价值的内容,反而是系统化、体系化的实践流程与思考总结,这也是我之后更希望持续输出的方向。

跳槽

另一方面,也是想多赚一些钱。成家之前,我的工资养活自己绰绰有余,但成家之后,现实问题就变得具体起来:未来如果有孩子、还没有买房,这些都需要更强的经济支撑。我也很清楚,在中国大部分程序员的职业生命周期大概率只有十几年,到了年龄偏大时,可能就需要考虑转型。2025 年,是我毕业、正式进入社会工作的第三年,因此我做出了一个决定——准备跳槽。

马云曾说过一句话:

跳槽无外乎两个原因,钱给少了,心里受委屈了。

这两点,我可能都占了。在这家公司干了两年,年初时,领导、CTO,以及当初面试我的帆叔,或许是出于生活和前途的考虑,陆续选择了离开。核心人物走后,公司换了新的领导,但我明显感觉到一种“死海效应”。感觉开了很多没有必要的会议,真的像过家家一样,我也感觉到没有效率无头苍蝇一样东一榔头西一棒的做事情。

所谓“死海效应”,是指组织中优秀员工不断流失,如同死海水分蒸发,导致低质量员工比例上升,从而影响整体效率和企业发展。

其实在我第一次提出离职时,公司也给我调了薪。当时我一度以为,自己可能会在这里长期干下去。但后来发生了一些不太方便细说的矛盾,如今回头看,我依然认为自己并没有做错。最终,出于职业发展与前途的考虑我还是选择了离开。

我悄悄提交了离职申请,只提前和一直合作的产品同学说了一声。说实话,我们组的产品在我看来是非常有能力的人才。直到我离职的最后一天,很多同事看到我的签名留言(相遇是缘,祝大家越来越好),才意识到我要走了。那天有十几位同事和我道别,让我非常感动。直到现在,我也还会和前同事们时不时在微信上聊聊天,聊前端,聊 AI。我跟每个同事都很熟悉,可能是我的性格善于把大家链接起来。

提完离职之后,我便立刻开始找工作。我并没有打算 gap 一段时间,因为之前已经 gap 过一次。那次裸辞后玩了两个月,前期确实很爽,像是在过寒暑假;但等旅游结束回到出租屋后,每天不是躺着就是刷手机、玩电脑,生活逐渐失去了目标感。那时我才真正意识到,人是需要劳动的,需要在社会工作中获得价值感。

正因如此,那次我很快重新投入找工作,也正是在那段时间,柯总收留了当时只有一年工作经验的我🙏。

正如马克思所说:

劳动是人类生存的基石,是人自身发展的决定性要素。在共产主义社会高级阶段,“劳动已经不仅仅是谋生的手段,而是本身成了生活的第一需要”。

在跳槽过程中,我也观察到了招聘市场风向的变化:越来越多的公司更倾向于简历中带有 AI 项目经历的候选人。幸运的是,我在 2023 年第一份工作时就参与过一个 AI 相关的生图项目,这让我的简历在市场上颇受欢迎。不过,当时市场对 AI 的重视还有滞后性,真正对 AI 项目经历感兴趣的公司并不多。到了这次跳槽,情况明显不同——AI 相关经历几乎成为必问项,也显著提升了候选人的吸引力。这让我深刻体会到,AI 对程序员带来的不是威胁,而是新的机会。

在面试过程中,我也会主动考察部门的 AI 使用情况。令我震惊的是,很多小公司的团队虽然知道 AI 的存在,但根本没有实际应用,仍然依赖传统的手工编码。显然,我不会选择加入这样的团队,因为对于我而言,高效利用 AI 不只是工具加成,而是能显著提升团队整体效率和技术成长空间的重要指标。

有了上一次裸辞的经历后,这一次在“多赚钱”的前提下,我几乎没有给自己任何休息时间,离职后便立刻投入到找工作中。或许缘分就是这么巧,我很快找到了一份听起来前途还不错的工作。但由于当时没有把工作时长和薪资细节问清楚,也没有在谈薪阶段据理力争到自己真正满意的程度,入职后还是产生了一些后悔的情绪。不过再找一份工作的成本不低,加上自己也有些懒,索性就先在这家公司干了下来。

这是一家总部在北京的做游戏的大公司,在广州新成立的一个部门,部门在 5 月份成立,而我是 8 月份加入的。由于我之前的技术栈和项目经验主要集中在管理后台领域,入职后便从0到1参与并负责了一个内部 BI 后台项目的建设。新公司的领导能力很强,一人同时承担后端开发、产品规划以及与设计师沟通协调等多重角色。

团队规模不大,我们是一个前端、一个后端,再加上一位测试同学,三个人协作完成了这个中台系统的开发,用于支持游戏发行部门的日常业务。

AI

也该聊到 AI 了,不然有点偏题太久了。😂

2022年的 AI

第一次接触 AI 辅助编程,是在 2022 年通过 GitHub Copilot。当时我在写毕业设计,用到的是一个需要发邮件申请试用的 VS Code 插件。印象很深的是,只要写一个诸如“二分查找”的注释,下面很快就能自动补全出完整代码,确实让人觉得相当聪明。

后来在 2022 年 12 月左右,ChatGPT 横空出世。现在回头看,那真的是一件非常幸运的事情——我刚参加工作没多久,大语言模型就已经出现了。那段时间最大的感受是:GPT 写出来的代码,甚至比当时作为初级程序员的我还要规范、完整。

于是后来每次遇到代码重构或优化相关的问题,我都会先问一问它。在不断的使用过程中,也确实从“AI 老师”那里学到了不少编程思路和实践技巧。

2023,2024年的 AI

那时候 ChatGPT 还没有免费开放,基本都是通过国内的镜像站之类的方式在使用,稳定性和体验都谈不上好,但依然挡不住大家的热情。我还记得 Cursor 刚出来的时候,最大的优势就是不需要科学上网就能直接用 GPT,这一点在当时非常有吸引力。谁能想到,后来这个工具不断迭代升级,从一个“能用”的编辑器插件,逐渐成长为 AI IDE 的第一梯队,甚至在某些场景下彻底改变了写代码的方式。

在那个阶段,我的使用方式其实还比较“传统”:写完一段代码,复制出来,粘贴到 GPT 里提问,让它帮我看看有没有优化空间、潜在问题,或者让它补全缺失逻辑,然后再把结果复制回编辑器中。这个流程现在看起来有些笨重,但在当时已经极大提升了效率。很多原本需要翻文档、查 Stack Overflow 的问题,几分钟内就能得到一个相对完整的答案。

那时的 AI 更多还是“辅助工具”的角色,而不是直接参与到编码流程中。它更像是一位随叫随到、耐心十足的高级同事,帮你查资料、给思路、补细节。虽然偶尔也会胡编乱造,需要自己具备判断能力,但不可否认的是,从 2023 年开始,我已经明显感受到:写代码这件事,正在被 AI 悄然重塑。

2025 年的 AI

一直到 2024 年底,Cursor 突然火了起来。我记得好像是某公司的一个大佬的女儿在几乎没有编程经验的情况下,用 Cursor 写了一个小程序,这篇推特被广泛转发后,Cursor 迅速走红。我看到后也下载了最新版,试用后直接被震撼到了——它的补全功能丝滑得让人难以置信,好像能直接理解我脑子里的想法,这是我第一次体验到如此智能又顺手的 AI 编程提示。

当时,我也尝试了 Cursor 的一个竞品 Winsurf,但整体体验还是 Cursor 更佳。有人会说,这不过是把 AI 模型套个壳而已,但我认为“套壳”也有高低之分。作为普通程序员,我们不必去研究模型的理论,但在应用层的交互体验、细节设计做得出色,同样非常了不起。使用 Cursor 后,我明显感受到工作效率提升,甚至可以达到两倍、五倍甚至十倍。

我当时非常积极地向同事推荐,但发现部分同事带有悲观色彩,担心 AI 会替代程序员,因此不愿尝试。对此,我的观点是:AI 是提效工具,它能帮你节省重复劳动,让你有更多时间去学习新技术、思考产品设计和架构优化。AI 的核心意义在于,让程序员从繁琐的 CRUD 工作中解放出来,把时间用在更高价值的工作上,让创意和想象力真正发挥作用。

与此同时,字节跳动推出了 Trae,我也体验过并写过相关征文,但整体体验还是不如 Cursor 顺手。也许是 Trae 的宣传和营销做得比较好,所以在我跳槽面试时,不少团队表示虽然自己没有使用 AI 编程,但知道字节出了 Trae。

后面过春节的时候,国产开源模型之光 DeepSeek 横空出世,连家里的长辈都知道中国出来个 nb 的 AI。太伟大了 DeepSeek 直接选择了开源,给全世界分享他们的成果,respect🫡!!!

在高强度使用了月左右后,我积累了一些经验和方法,也在文章中分享给了大家。

随着 AI 工具的发展,我也开始尝试其他工具,例如 Winsurf 和 Argument Code。特别是 Argument Code,这是一个 VS Code 插件,能够智能寻找代码中相关的影响范围,非常适合进行复杂逻辑分析。背后的 AI 模型 Claude 在这里表现得很聪明,但订阅价格不低,当时约 100 美元/月。

后来我也尝试了 Claude Code 和 Codex 的 CLI,不得不说,Claude 模型确实很强(题外话:但最近对第三方的封禁以及反华的一些魔幻操作,真希望预告新年发布的DeepSeek v4能挫挫这家公司锐气!),尤其在编码和设计相关的理解上非常到位。开源的 Claude-agent-sdk 也很优秀,很多人在它的基础上可以做自己的 CLI 二次开发。不过,我个人还是不太习惯在终端里使用 AI,习惯了有 GUI 界面的 IDE,操作起来更直观、顺手。

谷歌的 Antigravity我也体验了,都是在侧边栏有个对话框,可以试用 Gemini 与 Claude,我经常用 Gemini 写页面,但是写逻辑他很喜欢 any 跳过,很无语😅,写逻辑还是需要用 Claude。每周会限制一些使用额度,不得不说谷歌还是家大业大,想要超车提速就是这么快。但是这个产品名称起的真的不好拼写哈哈。

目前我在试用 Kiro 的 Claude 服务,用的是白嫖的 30 天、500 积分版本。不过这个 IDE 似乎没有智能提示功能(可能是我使用姿势不对?但我理解应该是默认开启的)。

总的来说,虽然 CLI 强大,但对我而言,GUI 界面的交互体验更符合日常编码习惯。我估计下一步还是回到 cursor 了。

对 AI 的思考与想法

写了这么多,我也有些累了。这是我第一次写这么长的文章,可能是因为想表达的内容实在太多了。码了一上午,最后想和大家聊聊我个人对 AI 的理解与思考。

AI 给我的工作效率带来了成倍提升。面试时我也常提到,以前写代码都是一行行敲,现在几乎可以“一片一片”地生成代码。但这并不意味着可以无脑相信 AI 输出的结果。如果每天只是依赖 AI 完成 Vibe Coding,长期下来可能会非常痛苦——-因为你不了解 AI 的实现细节。选用性能差的模型,即便功能实现了,后续改造或迭代可能会非常困难,只能再次依赖 AI 来处理。久而久之,就可能形成“AI 生成的代码屎山”。

因此,我的做法是:每次命令 AI 完成任务后,都会仔细 review 它的代码,再进行提交。如果项目是一次性的小型任务,或许可以不用过于严格,但对于需要长期维护的系统,认真 review 并与 AI 协作至关重要。

同时,AI 目前还无法替代程序员,其根本原因在于缺乏责任感。AI 的上下文长度有限,它无法像人一样,在公司里长期维护多个项目四五年。上下文越长,它遗忘的内容也越多。新建一个窗口,之前的事情就忘记了(可以设置全局 rule) 此外,一些自媒体常吹嘘用 AI 完全不会编程也能完成系统开发,虽然 AI 越来越强,一次性任务看起来很漂亮,但遇到小细节或后续改动时,如果没有懂一点的人去指挥和优化,代码很容易崩溃。

所以,至少需要一个懂技术的人来指导 AI,确保输出可靠。实际上,AI 也可以成为学习的辅助工具:通过它快速学习新的编程语言语法、软件架构最佳实践,再用这些知识高效指挥 AI 完成任务。总结来看,AI 是效率的倍增器,但仍然需要人的经验与判断力来控制风险、保证质量。

我觉得大家应该积极拥抱 AI,面对它、理解它,并善加利用,让 AI 成为让自己如虎添翼的工具。AI 的发展必然会带来产业变革和技术革新,但从更宏观的角度看,它是推动人类文明进步的重要力量。我们正加速步入一个生产力大爆发的时代,AI 将程序员从以往繁琐的搬砖任务中解放出来,让我们有更多精力去思考架构设计、创新功能,以及探索新的技术边界。

更进一步,AI 的真正价值在于它能够让人类在创造力和效率之间找到平衡。以前很多重复性工作占据了大量时间,现在这些工作可以交给 AI 来处理,而程序员可以把精力放在更高层次的思考上:如何设计更优的系统、如何优化用户体验、如何在复杂业务中做出更合理的决策。AI 不仅是工具,也是学习的助力,它能够快速提供信息、分析方案,让我们在短时间内掌握新技术、新方法,从而实现知识和能力的快速积累。

可以说,AI 对程序员而言,是一种能力的放大器,而不是替代品。未来,能够合理运用 AI 的人,将比单纯依赖传统技能的人更具竞争力。在这个过程中,保持学习、理解和掌控 AI 的能力,比单纯追求 AI 生成的结果更重要。真正掌握了这项能力的人,将能够在技术创新和生产力提升的浪潮中站稳脚跟,甚至引领变革。

结语

过去的一年是成长的一年,我也能明显感受到,相比去年的自己,有了不少进步。

希望在新的一年里,AI 能够展现出更惊艳的能力,带来更多创新和可能。期待未来,也祝大家新年快乐,工作顺利,生活愉快,每个人都能不断成长、越来越好。

🎉TinyPro v1.4.0 正式发布:支持 Spring Boot、移动端适配、新增卡片列表和高级表单页面

2026年1月12日 09:24

你好,我是 Kagol,个人公众号:前端开源星球

TinyPro 是一个基于 TinyVue 打造的前后端分离的后台管理系统,支持在线配置菜单、路由、国际化,支持页签模式、多级菜单,支持丰富的模板类型,支持多种构建工具,功能强大、开箱即用!

我们很高兴地宣布,2026年1月10日,TinyPro 正式发布 v1.4.0 版本,本次发布集中在扩展后端模板、增强移动端体验以及对 NestJS 后端功能的实用增强。

本次 v1.4.0 版本主要有以下重大变更:

  • 增加 Spring Boot 后端
  • 增强移动端适配
  • 增加卡片列表和高级表单页面
  • 支持多设备登录
  • 支持配置预览模式

你可以更新 @opentiny/tiny-toolkit-pro@1.4.0 进行体验!

tiny install @opentiny/tiny-toolkit-pro@1.4.0

详细的 Release Notes 请参考:github.com/opentiny/ti…

1 支持 Spring Boot 后端

之前只有 NestJS 后端,有不少开发者提出需要 Java 版本后端,大家的需求必须安排,所以本次版本新增对 Spring Boot 的支持,使得偏 Java / Spring 的团队可以更快速地用熟悉的后端框架搭建 TinyPro 全栈样板。

该支持包括 Docker 化示例、配置覆盖示例(application.yaml 覆写示例)以及针对 deploy 的说明,便于在容器化环境中直接部署或做二次开发。

如果你或团队偏向 Java 技术栈,这次更新显著降低了启动成本与集成难度。

详细使用指南请参考文档:Spring Boot 后端开发指南

2 移动端响应式与布局优化

本次引入移动端适配方案,包含布局调整、样式优化和若干移动交互逻辑改进。配套增加了端到端测试(E2E),保证常见移动场景(小屏导航、侧边栏收起、页签/页面切换)行为稳定。

适配覆盖了常见断点,页面在手机端的易用性和可读性有明显提升,适合需要同时兼顾桌面与移动管理后台的项目。

效果如下:

移动端效果.png

详细介绍请参考文档:TinyPro 响应式适配指南

3 增加卡片列表页面

之前列表页仅提供单一的查询表格形式,功能相对有限,难以满足日益多样化、复杂化的业务需求。为了提升用户体验、增强系统的灵活性,我们在原有基础上新增了一个卡片列表页面,以更直观、灵活的方式展示数据,满足不同场景下的使用需求。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

卡片列表.png

4 增加高级表单页面

表单页增加了高级表单,在普通表单基础上增加了表格整行输入功能。

体验地址:opentiny.design/vue-pro/pag…

效果如下:

高级表单.png

5 支持多设备登录

之前只能同时一个设备登录,后面登录的用户会“挤”掉前面登录的用户,本次版本为账号登录引入设备限制(Device Limit)策略,可限制单账号并发活跃设备数,有助于减少滥用和提高安全性,适配企业安全合规需求。

可通过 nestJs/.env 中的 DEVICE_LIMIT 进行配置。

比如配置最多 2 人登录:

DEVICE_LIMIT=2

如果不想限制登录设备数,可以设置为 -1:

DEVICE_LIMIT=-1

6 演示模式

由于配置了 RejectRequestGuard,默认情况下,所有接口都只能读,不能写,本次版本增加了演示模式(PREVIEW_MODE),要修改 NestJS 后端代码才能改成可写的模式(nestJs/src/app.module.ts)。

本次版本增加了演示模式的配置,可通过 nestJs/.env 中的 PREVIEW_MODE 进行配置。

PREVIEW_MODE 默认为 true, 会拒绝所有的增加、修改、删除操作,设置为 false,则变成可写模式。

PREVIEW_MODE=false

7 Redis 引入应用安装锁(redis app install lock)

主要用于避免重复安装或初始化时的竞态问题。

默认情况下,第一次运行 NestJS 后端,会生成 Redis 锁,后续重新运行 NestJS 后端,不会再更新 MySQL 数据库的数据。

如果你修改了默认的菜单配置(nestJs/src/menu/init/menuData.ts)或者国际化词条(nestJs/locales.json),希望重新初始化数据库,可以在开发机器 Redis 中运行 FLUSHDB 进行解锁,这样重新运行 NestJS 后端时,会重新初始化 MySQL 数据库的数据。

更多更新,请参考 Release Notes:github.com/opentiny/ti…

8 社区贡献

感谢所有为 v1.4.0 做出贡献的开发者!你们的辛勤付出让 TinyPro 变得更好!

注:排名不分先后,按名字首字母排序。

如果你有任何建议或反馈,欢迎通过 GitHub Issues 与我们联系,也欢迎你一起参与 TinyPro 贡献。

往期推荐文章

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-pro

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

Skill 真香!5 分钟帮女友制作一款塔罗牌 APP

作者 乘风gg
2026年1月12日 09:14

最近发现一个 AI 提效神器 ——Skills,用它配合 Cursor 开发,我仅用 5 分钟就帮女友做出了一款塔罗牌 H5 APP!在说如何操作之前,我们先大概了解下 Skills 的原理

一、Skills的核心内涵与技术构成

(一)本质界定

Skills 可以理解为给 AI Agent 定制的「专业技能包」,把特定领域的 SOP、操作逻辑封装成可复用的模块,让 AI 能精准掌握某类专业能力,核心目标是实现领域知识与操作流程的标准化传递,使AI Agent按需获取特定场景专业能力。其本质是包含元数据、指令集、辅助资源的结构化知识单元,通过规范化封装将分散专业经验转化为AI Agent可理解执行的“行业SOP能力包”,让 AI 从‘只会调用工具’变成‘懂专业逻辑的执行者

(二)技术构成要素

完整Skill体系由三大核心模块构成,形成闭环能力传递机制:

  1. 元数据模块:以SKILL.md或meta.json为载体,涵盖技能名称、适用场景等关键信息约 100 个字符(Token),核心功能是实现技能快速识别与匹配,为AI Agent任务初始化阶段的加载决策提供依据。
  2. 指令集模块:以instructions.md为核心载体,包含操作标准流程(SOP)、决策逻辑等专业规范,是领域知识的结构化转化成果,明确AI Agent执行任务的步骤与判断依据。
  3. 辅助资源模块:可选扩展组件,涵盖脚本代码、案例库等资源,为AI Agent提供直接技术支撑,实现知识与工具融合,提升执行效率与结果一致性。

和传统的函数调用、API 集成相比,Skills 的核心优势是:不只是 “告诉 AI 能做什么”,更是 “教会 AI 怎么做”,让 AI 理解专业逻辑而非机械执行

二、Skills与传统Prompt Engineering的技术差异

从技术范式看,Skills与传统Prompt Engineering存在本质区别,核心差异体现在知识传递的效率、灵活性与可扩展性上:

  1. 知识封装:传统为“一次性灌输”,冗余且复用性差;Skills为“模块化封装”,一次创建可跨场景复用,降低冗余成本。
  2. 上下文效率:传统一次性加载所有规则,占用大量令牌且易信息过载;Skills按需加载,提升效率并支持多技能集成。
  3. 任务处理:传统面对复杂任务易逻辑断裂,无法整合外部资源;Skills支持多技能组合调用,实现复杂任务全流程转化。
  4. 知识迭代:传统更新需逐一修改提示词,维护成本高;Skills为独立模块设计,更新成本低且关联任务可同步受益。

上述差异决定Skills更适配复杂专业场景,可破解传统Prompt Engineering规模化、标准化应用的瓶颈。

三、渐进式披露:Skills的核心技术创新

(一)技术原理与实现机制

Skills能在不增加上下文负担的前提下支撑多复杂技能掌握,核心在于“按需加载”的渐进式披露(Progressive Disclosure)设计,将技能加载分为三阶段,实现知识传递与上下文消耗的动态平衡:

  1. 发现阶段(启动初始化):仅加载所有Skills元数据(约100个令牌/个),构建“技能清单”明确能力边界,最小化初始化上下文负担。
  2. 激活阶段(任务匹配时):匹配任务后加载对应技能指令集,获取操作规范,实现精准加载并避免无关知识干扰。
  3. 执行阶段(过程按需加载):动态加载辅助资源,进一步优化上下文利用效率。

(二)技术优势与价值

渐进式披露机制使Skills具备三大核心优势:

  1. 降低令牌消耗:分阶段加载避免资源浪费,支持单次对话集成数十个技能,降低运行成本。
  2. 提升执行准确性:聚焦相关知识组件,减少干扰,强化核心逻辑执行精度。
  3. 增强扩展性:模块化设计支持灵活集成新知识,无需重构系统,适配领域知识快速迭代。

四、Cursor Skills

介绍完 Skills 是什么之后,我将使用的是 Cursor 作为我的开发工具。先说明一下,最开始只有 Claude Code 支持 Skills、Codex 紧随其后,口味自己选。

好消息是,Cursor 的 Skills 机制采用了与 Claude Code 几乎完全一致的 SKILL.md 格式。这意味着,你完全不需要从头编写,可以直接将 Claude Code 的生态资源迁移到 Cursor。

(一)Cursor 设置

因为 Cursor 刚支持不久,并且是 Beta 才能使用,所以要进行下面操作

Agent Skills 仅在 Nightly 更新渠道中可用。
要切换更新渠道,打开 Cursor 设置( Cmd+Shift+J ),选择 Beta,然后将更新渠道设置为 Nightly。更新完成后,你可能需要重新启动 Cursor。 如下图所示

要启用或禁用 Agent Skills:

  1. 打开 Cursor Settings → Rules
  2. 找到 Import Settings 部分
  3. 切换 Agent Skills 开关将其开启或关闭 如下图所示

(二)复制 Claude Skills

然后我们直接去 Anthropic 官方维护的开源仓库 anthropics/skills,里面提供了大量经过验证的 Skill 范例,涵盖了创意设计、开发技术、文档处理等多个领域。

你可以访问 github.com/anthropics/… 查看完整列表。以下是这次用到的 Skills

Frontend Design:这是一个专门用于提升前端设计质量的技能。它包含了一套完整的 UI 设计原则(排版、色彩、布局)

然后我们直接把 Skills 里面的 .claude/skills/frontend-design 到当前项目文件下,如图:

模型和模式如下图

提示词如下,不一定非得用我的。

使用 Skill front-design。我要做一个 H5 ,功能是一个塔罗牌。

你是一名经验丰富的产品设计专家和资深前端专家,擅长UI构图与前端页面还原。现在请你帮我完成这个塔罗牌应用的 UI/UX 原型图设计。请输出一个包含所有设计页面的完整HTML文件,用于展示完整UI界面。

注意:生成代码的时候请一步一步执行,避免单步任务过大,时间执行过长

然后 Cursor 会自动学习 Skills,并输出代码

然后就漫长的等待之后,Cursor 会自动做一个需求技术文档,然后会一步一步的实现出来,这时候可以去喝杯茶,再去上个厕所!

最终输出了 5 个页面

  1. 首页 (Home)
  2. 每日抽牌页 (Daily Draw)
  3. 牌阵占卜页 (Spread Reading)
  4. 塔罗百科页 (Encyclopedia)
  5. 占卜历史页 (History)

最终效果如下,整体效果看起来,完全是一个成熟的前端工程师的水准,甚至还带有过渡动画和背景效。因为掘金无法上传视频,欢迎私信我找我要或者关注我:

image.png

扩展阅读

因为 Cursor 目前仅在 Nightly 版本上才可以使用 Skills。如果担心切换此模式会引发意想不到的情况,可以使用另一种方案

OpenSkills 是一个开源的通用技能加载器。

  • 完全兼容:它原生支持 Anthropic 官方 Skill 格式,可以直接使用 Claude 官方市场或社区开发的技能。
  • 桥梁作用:它通过简单的命令行操作,将这些技能转换为 Cursor、Windsurf 等工具可识别的配置(AGENTS.md),从而让 Cursor 具备与 Claude Code 同等的“思考”与“技能调用”能力。
❌
❌