背景
最近开发了一个内部问答平台(deepseek套层皮🐷),前端实现很简单,用户输入一个问题,跟后端建立一个SSE连接,后端将结果流式输出,前端流式渲染展示。
突然有一天老板想搞点事:小明,页面这个流式内容能不能支持缩小到页面右下角,让用户更加聚焦输入框内容?
WTF,只见过全屏展示的,还没见过小屏展示的。
好像也不是,暂停播放的时候,各大视频网站就喜欢小屏放内容,让你更加聚焦于弹窗广告👻。
没有办法,在AI的冲击下前端已经死了至少七次了,可不敢惹恼boss,不然就等不到被AI的第八次kill,已经被老板一次KO了🫡。
秉着负(牛)责(马)的态度,帮助boss梳理交互细节~
希望缩小后流式内容还在动态输出...
希望缩小后右侧区域自动扩张到全部区域,然后小窗"悬浮"在右下角...
希望可以丝滑地还原...
明确了需求,直接开干。
方案思路
希望缩小后流式内容还在动态输出...
把当前内容区的容器整个缩小不就行了transform: scale(0.x),也就是从视觉上缩小流式内容区,其他渲染逻辑完全不动,SSE流的解析和渲染还是在内容区组件里完成,太easy了🥰
希望缩小后右侧区域自动扩张到全部区域,然后小窗"悬浮"在右下角...
啧啧,也好实现,在内容区多包一层div,让这个div脱离文档流position:absolute,再设置一下相对位置就可以悬浮到指定位置啦。
希望可以丝滑地还原...
CSS加上过渡效果,transition: transform 0.3s ease简单需求简单做嘛,哈哈哈
技术实现
容器结构
分为两层容器,父容器负责定位,实际缩小内容放置在 .right-panel-content__container 中:
为了拥有过渡效果,通过setTimeout制造时间差,让过渡动画完成后才让父容器.right-panel-content浮动到右下角为止。看下实现效果:

唉,唉!唉?缩小的内容怎么不见了?
F12打开调试面板看下DOM结构:

DOM结构正常啊,内容都在正确位置,但是展示怎么就不对呢?
继续捣鼓CSS...猛然间看到div.right-panel-content.min-mode 的大小,突然感到一丝诡异,按理说待缩放区域设置的缩小比例为scale(0.2),计算下来容器的宽高至少应该是:90x158。怎么会是64x46呢?
继续分析DOM结构及大小,发现.right-panel-content__header的大小是:46x28,加上div.right-panel-content.min-mode自己的padding: 8px,好家伙,刚好凑成62x44(=64x46- 1px border😭)。
感情.right-panel-content__container被缩小得根本不占空间了嘛~
问题分析
为什么会这样呢?因为被.right-panel-content__container被二次缩小了,原因在于container的父容器为了定位而做出的自身宽高调整。下面分步骤还原一下内容缩放过程:
设置以下别名:
.right-panel-content__container =>
content__container.right-panel-content__header =>
content__header.right-panel-content =>
panel-content
- 初始时宽高正常,content__container缩放后达到了目标大小90x158;
- 然后panel-content进入min-mode,宽高被强制设置为
auto !important,因为内容区坍塌,panel-content被迫坍塌到跟内容一样的高度90x158; - panel-content高度变化激活了content__container的 scale(0.2),content__container继续缩小;
- content__container的缩小又引发panel-content的高度坍塌...直到 content__header "独自"撑起panel-content的内容。
一切都解释清楚了,核心问题变成了怎么解决循环坍塌。
解决方案
既然问题父子容器间高度变化引发的循环坍塌,那可以不可以在中间加一层,阻断这种循环效果呢?
而且要阻断循环,那增加的一层应该脱离文档流,让其大小不受父容器和其子容器干扰。 新的DOM结果如下:
为了让操作栏样式更合理,根据minMode状态对 ContentHeader 进行动态渲染:
js
<div className="right-panel-middle-wrapper">
{minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
<div className={`right-panel-content__container ${containerClassname}`}>
{!minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
<ContentSection />
</div>
</div>;
完整实现
js
import { useEffect, useRef, useState } from "react";
import { Layout, Button } from "@arco-design/web-react";
import { IconExpand, IconShrink } from "@arco-design/web-react/icon";
import "./App.less";
const Sider = Layout.Sider;
const Header = Layout.Header;
const Footer = Layout.Footer;
const Content = Layout.Content;
function App() {
const [minMode, setMinMode] = useState(false);
const [panelClassname, setPanelClassname] = useState("");
const [containerClassname, setContainerClassname] = useState("");
const classnameTimer = useRef<number>(0);
useEffect(() => {
if (minMode) {
// 立即启动缩小动效
setContainerClassname("min-mode-transition");
// 动效结束后,再设置 min-mode 类名
if (classnameTimer.current) {
clearTimeout(classnameTimer.current);
}
// 动画约350ms,需要等动画结束后再设置min-mode
classnameTimer.current = setTimeout(() => {
setPanelClassname("min-mode");
}, 350);
} else {
setPanelClassname("");
setContainerClassname("");
}
return () => {
if (classnameTimer.current) {
clearTimeout(classnameTimer.current);
}
};
}, [minMode]);
return (
<div className="app-layout">
<Layout>
<Header>Header</Header>
<Layout>
<Sider>Sider</Sider>
<Content>
<div className="left-panel-content">左侧内容栏</div>
<div className={`right-panel-content ${panelClassname}`}>
<div className="right-panel-middle-wrapper">
{minMode && (
<ContentHeader minMode={minMode} setMinMode={setMinMode} />
)}
<div
className={`right-panel-content__container ${containerClassname}`}
>
{!minMode && (
<ContentHeader minMode={minMode} setMinMode={setMinMode} />
)}
<ContentSection />
</div>
</div>
</div>
</Content>
</Layout>
<Footer>Footer</Footer>
</Layout>
</div>
);
}
function ContentHeader({
minMode,
setMinMode,
}: {
minMode: boolean;
setMinMode: (minMode: boolean) => void;
}) {
return (
<div className="right-panel-content__header">
{minMode ? (
<Button type="text" size="small" onClick={() => setMinMode(false)}>
<IconExpand style={{ color: "var(--color-border-4)" }} />
</Button>
) : (
<Button type="text" size="small" onClick={() => setMinMode(true)}>
<IconShrink style={{ color: "var(--color-border-4)" }} />
</Button>
)}
</div>
);
}
function ContentSection() {
return (
<div className={`right-panel-content__section`}>
<div className="right-panel-content__section-title">
张若虚《春江花月夜》
</div>
春江潮水连海平,海上明月共潮生。 <br />
滟滟随波千万里,何处春江无月明。
<br />
江流宛转绕芳甸,月照花林皆似霰。
<br /> 空里流霜不觉飞,汀上白沙看不见。
<br />
江天一色无纤尘,皎皎空中孤月轮。
<br /> 江畔何人初见月?江月何年初照人?
<br />
人生代代无穷已,江月年年望相似。
<br />
不知江月待何人,但见长江送流水。
<br />
白云一片去悠悠,青枫浦上不胜愁。
<br />
谁家今夜扁舟子?何处相思明月楼?
<br />
可怜楼上月裴回,应照离人妆镜台。
<br />
玉户帘中卷不去,捣衣砧上拂还来。
<br /> 此时相望不相闻,愿逐月华流照君。
<br />
鸿雁长飞光不度,鱼龙潜跃水成文。
<br /> 昨夜闲潭梦落花,可怜春半不还家。
<br />
江水流春去欲尽,江潭落月复西斜。
<br /> 斜月沉沉藏海雾,碣石潇湘无限路。
<br />
不知乘月几人归,落月摇情满江树。
</div>
);
}
export default App;
样式文件:
css
.app-layout {
height: 100%;
width: 100%;
.arco-layout {
height: 100%;
}
.arco-layout-header,
.arco-layout-footer,
.arco-layout-sider,
.arco-layout-sider-children,
.arco-layout-content {
color: var(--color-white);
text-align: center;
font-stretch: condensed;
font-size: 16px;
display: flex;
flex-direction: column;
justify-content: center;
}
.arco-layout-content {
flex-direction: row;
}
.arco-layout-header,
.arco-layout-footer {
height: 64px;
background-color: var(--color-primary-light-4);
}
.arco-layout-sider {
width: 206px;
background-color: var(--color-primary-light-3);
}
.arco-layout-content {
background-color: rgb(var(--arcoblue-6));
.left-panel-content {
flex: auto;
}
.right-panel-content {
background-color: var(--color-primary-light-3);
height: 100%;
flex: 1;
.right-panel-middle-wrapper {
width: 100%;
height: 100%;
}
.right-panel-content__container {
width: 100%;
height: 100%;
.right-panel-content__header {
height: 40px;
line-height: 40px;
background-color: var(--color-warning-light-1);
text-align: right;
padding: 0 10px;
}
.right-panel-content__section {
width: 100%;
height: calc(100% - 40px);
color: #000;
.right-panel-content__section-title {
font-size: 20px;
padding: 10px 0;
}
}
}
.min-mode-transition {
position: absolute;
z-index: 1999;
width: 442px;
height: 794px;
transform: scale(0.2);
transform-origin: right bottom;
transition: transform 0.3s ease;
}
&.min-mode {
position: absolute;
z-index: 499;
bottom: 25px;
right: 25px;
border-radius: 6px;
padding: 8px;
width: auto !important;
height: auto !important;
display: flex;
flex-direction: column;
align-items: center;
transition: none;
border: 1px solid var(--color-border);
box-sizing: border-box;
.right-panel-middle-wrapper {
width: 90px;
height: 158px;
overflow: hidden;
position: relative;
.right-panel-content__header {
height: fit-content;
line-height: unset;
background-color: unset;
padding: 0;
}
.right-panel-content__container {
transform-origin: top left;
}
}
}
}
}
}
最终效果

小结
文中的小窗效果虽然谈不上尽善尽美,但胜在简单,只需要调整css样式就可以实现。在实现中需要注意的点便是父容器与待缩放内容间的循环依赖问题。本文通过增加一个wrapper层达到依赖解耦的目的。方案有点不够优雅,如果有哪位大佬知道更好的方案,欢迎在评论区给出,让笔者也学习学习🖖