阅读视图
创业板指涨幅扩大至1%
恒生科技指数涨幅扩大至2%
深成指涨逾1%,北证50涨近4%
工信部部长李乐成:“十五五”时期将聚焦量子科技、人形机器人、脑机接口、深海极地、6G等领域
工信部部长李乐成:2026年将着重抓好“稳”“扩”“创”“增”四方面工作,保持工业经济平稳增长
工信部部长李乐成:下一步将实施新一轮中央财政支持专精特新中小企业高质量发展政策
从零实现富文本编辑器#10-React视图层适配器的模式扩展
在编辑器最开始的架构设计上,我们就以MVC模式为基础,分别实现模型层、核心层、视图层的分层结构。在先前我们讨论的主要是模型层以及核心层的设计,即数据模型以及编辑器的核心交互逻辑,在这里我们以React为例,讨论其作为视图层的模式扩展设计。
- 开源地址: github.com/WindRunnerM…
- 在线编辑: windrunnermax.github.io/BlockKit/
- 项目笔记: github.com/WindRunnerM…
从零实现富文本编辑器系列文章
概述
多数编辑器实现了本身的视图层,而重新设计视图层需要面临渲染问题,诸如处理复杂的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>
生命周期同步
从最开始的编辑器设计中,我们就已经将核心层和视图层分离,并且为了更灵活地调度编辑器,我们将编辑器实例化的时机交予用户来控制。这种情况下若是暴露出Editor的ref/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的事件。
然而若是将相关的事件粒度拆得更细,即在插件中同样需要定义为onMount和onUnmount的生命周期,这样就能更好地控制相关处理时机问题。然而这种情况下,对于用户来说就需要有更复杂的插件逻辑,与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的状态更新,然而这种方式的问题在于性能,当文档内容非常大的时候,全量计算将会导致大量的状态重建。并且其本身的改变也会导致React的diff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。
不过,上述的监听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,但是能够直接复用视图层还是有价值的。
在Lexical的README中可以看到是可以支持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实现的框架就比较喜欢规定一些约定性的内容,例如React的Hooks函数都需要以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节点来获取相关的状态的,或者诸如querySelectorAll、createTreeWalker等方法来获取相关的节点。但是这样明显是会存在诸多无效的遍历操作,因此我们需要考虑是否有更高效的方式来获取相关的节点。
在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)
写在前面
在前端圈子里,我们常说“这个组件太耦合了”或者“这段逻辑改不动”。大多数人习惯性地把锅甩给业务复杂或者前人留下的“屎山”。但如果我们冷静下来复盘,你会发现绝大多数的维护噩梦都指向同一个设计缺陷:高层逻辑被低层细节给绑架了。
今天我们要聊的依赖倒置原则(Dependency Inversion Principle, DIP) ,就是专门用来破解这种“死局”的架构利器。
一、 场景:一次痛苦的“技术升级”
想象一下,你负责一个复杂的 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 引用,陷入了沉思。不仅要改代码,还要面对全量回归测试的风险。这时候你才会意识到:你的业务逻辑,已经和具体的网络库“殉情”了。
二、 什么是依赖倒置?(别背定义,看本质)
传统的开发思维是自顶向下的:页面依赖组件,组件依赖工具类。这就像在盖房子时,把电线直接浇筑在混凝土里,想换根线就得拆墙。
依赖倒置原则核心就两句话:
- 高层模块不应依赖低层模块,两者都应依赖抽象。
- 抽象不应依赖细节,细节应依赖抽象。
通俗点说:谁拥有接口(Interface),谁就是老大。
在架构突围中,我们要把“控制权”翻转过来。高层业务不应该问:“我该怎么去调用 Axios?”而是应该傲娇地声明:“我需要一个能发请求的东西,至于你是用 Axios 还是 Fetch,我不在乎。”
三、 代码案例:从“死耦合”到“神解耦”
让我们用 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 端 "方言" | 小程序端 "方言" |
|---|---|---|
| 网络请求 |
fetch 或 axios
|
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,我们将跨端架构分成了清晰的三层:
- 核心业务层(稳定) :定义 Interface,编写业务逻辑。这一层代码在多端是完全共用的,一行都不用改。
-
接口契约层(抽象) :即
IHttpClient,IStorage等 Interface 定义。 - 基础设施层(易变) :各个端的具体 Adapter 实现(WebAdapter, MiniProgramAdapter)。
2. 制定官方语言(定义抽象接口)
业务层声明它需要什么能力,而不关心这能力怎么实现。这些 Interface 定义在核心业务域中。
3. 无感知的 Mock 与测试
写单元测试最痛苦的是 Mock 全局库。如果你遵循了 DIP,你只需要给业务逻辑注入一个 MockClient,连 jest.mock('axios') 这种黑盒操作都不用了。
4. 插件化架构
像 VS Code 或大型低代码平台,其核心框架并不依赖具体的插件。它定义了一套规范(抽象),所有的插件必须实现这些规范,这正是 DIP 的高级应用。
五、 结语:突围的核心是“心智负担”的转移
很多前端同学抗拒 DIP,觉得“我就写个业务,有必要搞这么复杂吗?”
确实,对于三天就扔的小程序,DIP 属于过度设计。但如果你在构建一个长期迭代的工程,DIP 的本质是在隔离变化。它把最不稳定的部分(第三方库、API 协议、浏览器差异)挡在了抽象层之外。
架构突围,不是为了炫技,而是为了在下一次需求变动、技术迁移时,你能气定神闲地改一行代码,而不是通宵改两百个文件。
互动环节: 你在项目中遇到过“因为换个库导致全线崩溃”的经历吗?或者你觉得在开发中,Context API 是否已经足够支撑起 DIP 的职责?欢迎在评论区博弈。
逐际动力发布具身智能体OS系统LimX COSA
【节点】[Channel-Swizzle节点]原理解析与实际应用
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 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)
Counterpoint Research:存储市场已进入“超级牛市”阶段
沪深两市成交额突破2万亿元
数字孪生项目效率翻倍!AI技术实测与场景验证实录
引言
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官网