我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:佳嵐 回顧傳統React動畫 對於普通的 React 動畫,我們大多使用官方推薦的 react-transition-group,其提供了四個基本組件 Tra ...
我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。
本文作者:佳嵐
回顧傳統React動畫
對於普通的 React 動畫,我們大多使用官方推薦的 react-transition-group,其提供了四個基本組件 Transition、CSSTransition、SwitchTransition、TransitionGroup
Transition
Transition 組件允許您使用簡單的聲明式 API 來描述組件的狀態變化,預設情況下,Transition 組件不會改變它呈現的組件的行為,它只跟蹤組件的“進入”和“退出”狀態,我們需要做的是賦予這些狀態意義。
其一共提供了四種狀態,當組件感知到 in prop 變化時就會進行相應的狀態過渡
'entering'
'entered'
'exiting'
'exited'
const defaultStyle = {
transition: `opacity ${duration}ms ease-in-out`,
opacity: 0,
}
const transitionStyles = {
entering: { opacity: 1 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
exited: { opacity: 0 },
};
const Fade = ({ in: inProp }) => (
<Transition in={inProp} timeout={duration}>
{state => (
<div style={{
...defaultStyle,
...transitionStyles[state]
}}>
I'm a fade Transition!
</div>
)}
</Transition>
);
CSSTransition
此組件主要用來做 CSS 樣式過渡,它能夠在組件各個狀態變化的時候給我們要過渡的標簽添加上不同的類名。所以參數和平時的 className 不同,參數為:classNames
<CSSTransition
in={inProp}
timeout={300}
classNames="fade"
unmountOnExit
>
<div className="star">⭐</div>
</CSSTransition>
// 定義過渡樣式類
.fade-enter {
opacity: 0;
}
.fade-enter-active {
opacity: 1;
transition: opacity 200ms;
}
.fade-exit {
opacity: 1;
}
.fade-exit-active {
opacity: 0;
transition: opacity 200ms;
}
SwitchTransition
SwitchTransition 用來做組件切換時的過渡,其會緩存傳入的 children,併在過渡結束後渲染新的 children
function App() {
const [state, setState] = useState(false);
return (
<SwitchTransition>
<CSSTransition
key={state ? "Goodbye, world!" : "Hello, world!"}
classNames='fade'
>
<button onClick={() => setState(state => !state)}>
{state ? "Goodbye, world!" : "Hello, world!"}
</button>
</CSSTransition>
</SwitchTransition>
);
}
TransitionGroup
如果有一組 CSSTransition 需要我們去過渡,那麼我們需要管理每一個 CSSTransition 的 in 狀態,這樣會很麻煩。
TransitionGroup 可以幫我們管理一組 Transition 或 CSSTransition 組件,為此我們不再需要給 Transition 組件傳入 in 屬性來標識過渡狀態,轉用 key 屬性來代替 in
<TransitionGroup>
{
this.state.list.map((item, index) => {
return (
<CSSTransition
key = {item.id}
timeout = {1000}
classNames = 'fade'
unmountOnExit
>
<TodoItem />
</CSSTransition>
)
}
}
</TransitionGroup>
TransitionGroup 會監測其 children 的變化,將新的 children 與原有的 children 使用 key 進行比較,就能得出哪些 children 是新增的與刪除的,從而為他們註入進場動畫或離場動畫。
FLIP 動畫
FLIP 是什麼?
FLIP 是 First
、Last
、Invert
和 Play
四個單詞首字母的縮寫
First
, 元素過渡前開始位置信息
Last
:執行一段代碼,使元素位置發生變化,記錄最後狀態的位置等信息.
Invert
:根據 First 和 Last 的位置信息,計算出位置差值,使用 transform: translate(x,y) 將元素移動到First的位置。
Play
: 給元素加上 transition 過渡屬性,再講 transform 置為 none,這時候因為 transition 的存在,開始播放絲滑的動畫。
Flip 動畫可以看成是一種編寫動畫的範式,方法論,對於開始或結束狀態未知的複雜動畫,可以使用 Flip 快速實現
位置過渡效果
代碼實現:
const container = document.querySelector('.flip-container');
const btnAdd = document.querySelector('#add-btn')
const btnDelete = document.querySelector('#delete-btn')
let rectList = []
function addItem() {
const el = document.createElement('div')
el.className = 'flip-item'
el.innerText = rectList.length + 1;
el.style.width = (Math.random() * 300 + 100) + 'px'
// 加入新元素前重新記錄起始位置信息
recordFirst();
// 加入新元素
container.prepend(el)
rectList.unshift({
top: undefined,
left: undefined
})
// 觸發FLIP
update()
}
function removeItem() {
const children = container.children;
if (children.length > 0) {
recordFirst();
container.removeChild(children[0])
rectList.shift()
update()
}
}
// 記錄位置
function recordFirst() {
const items = container.children;
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
rectList[i] = {
left: rect.left,
top: rect.top
}
}
}
function update() {
const items = container.children;
for (let i = 0; i < items.length; i++) {
// Last
const rect = items[i].getBoundingClientRect();
if (rectList[i].left !== undefined) {
// Invert
const transformX = rectList[i].left - rect.left;
const transformY = rectList[i].top - rect.top;
items[i].style.transform = `translate(${transformX}px, ${transformY}px)`
items[i].style.transition = "none"
// Play
requestAnimationFrame(() => {
items[i].style.transform = `none`
items[i].style.transition = "all .5s"
})
}
}
}
btnAdd.addEventListener('click', () => {
addItem()
})
btnDelete.addEventListener('click', () => {
removeItem()
})
使用 flip 實現的動畫 demo
亂序動畫:
縮放動畫:
React跨路由組件動畫
在 React 中路由之前的切換動畫可以使用 react-transition-group 來實現,但對於不同路由上的組件如何做到動畫過渡是個很大的難題,目前社區中也沒有一個成熟的方案。
使用flip來實現
在路由 A 中組件的大小與位置狀態可以當成 First, 在路由 B 中組件的大小與位置狀態可以當成 Last,
從路由 A 切換至路由B時,向 B 頁面傳遞 First 狀態,B 頁面中需要過渡的組件再進行 Flip 動畫。
為此我們可以抽象出一個組件來幫我們實現 Flip 動畫,並且能夠在切換路由時保存組件的狀態。
對需要進行過渡的組件進行包裹, 使用相同的 flipId 來標識他們需要在不同的路由中過渡。
<FlipRouteAnimate className="about-profile" flipId="avatar" animateStyle={{ borderRadius: "15px" }}>
<img src={require("./touxiang.jpg")} alt="" />
</FlipRouteAnimate>
完整代碼:
import React, { createRef } from "react";
import withRouter from "./utils/withRouter";
class FlipRouteAnimate extends React.Component {
constructor(props) {
super(props);
this.flipRef = createRef();
}
// 用來存放所有實例的rect
static flipRectMap = new Map();
componentDidMount() {
const {
flipId,
location: { pathname },
animateStyle: lastAnimateStyle,
} = this.props;
const lastEl = this.flipRef.current;
// 沒有上一個路由中組件的rect,說明不用進行動畫過渡
if (!FlipRouteAnimate.flipRectMap.has(flipId) || flipId === undefined) return;
// 讀取緩存的rect
const first = FlipRouteAnimate.flipRectMap.get(flipId);
if (first.route === pathname) return;
// 開始FLIP動畫
const firstRect = first.rect;
const lastRect = lastEl.getBoundingClientRect();
const transformOffsetX = firstRect.left - lastRect.left;
const transformOffsetY = firstRect.top - lastRect.top;
const scaleRatioX = firstRect.width / lastRect.width;
const scaleRatioY = firstRect.height / lastRect.height;
lastEl.style.transform = `translate(${transformOffsetX}px, ${transformOffsetY}px) scale(${scaleRatioX}, ${scaleRatioY})`;
lastEl.style.transformOrigin = "left top";
for (const styleName in first.animateStyle) {
lastEl.style[styleName] = first.animateStyle[styleName];
}
setTimeout(() => {
lastEl.style.transition = "all 2s";
lastEl.style.transform = `translate(0, 0) scale(1)`;
// 可能有其他屬性也需要過渡
for (const styleName in lastAnimateStyle) {
lastEl.style[styleName] = lastAnimateStyle[styleName];
}
}, 0);
}
componentWillUnmount() {
const {
flipId,
location: { pathname },
animateStyle = {},
} = this.props;
const el = this.flipRef.current;
// 組件卸載時保存自己的位置等狀態
const rect = el.getBoundingClientRect();
FlipRouteAnimate.flipRectMap.set(flipId, {
// 當前路由路徑
route: pathname,
// 組件的大小位置
rect: rect,
// 其他需要過渡的樣式
animateStyle,
});
}
render() {
return (
<div
className={this.props.className}
style={{ display: "inline-block", ...this.props.style, ...this.props.animateStyle }}
ref={this.flipRef}
>
{this.props.children}
</div>
);
}
}
實現效果:
共用組件的方式實現
要想在不同的路由共用同一個組件實例,並不現實,樹形的 Dom 樹並不允許我們這麼做。
我們可以換個思路,把組件提取到路由容器的外部,然後通過某種方式將該組件與路由頁面相關聯。
我們將 Float 組件提升至根組件,然後在每個路由中使用 Proxy 組件進行占位,當路由切換時,每個 Proxy 將其位置信息與其他 props 傳遞給 Float 組件,Float 組件再根據接收到的狀態信息,將自己移動到對應位置。
我們先封裝一個 Proxy 組件, 使用 PubSub 發佈元信息。
// FloatProxy.tsx
const FloatProxy: React.FC<any> = (props: any) => {
const el = useRef();
// 保存代理元素引用,方便獲取元素的位置信息
useEffect(() => {
PubSub.publish("proxyElChange", el);
return () => {
PubSub.publish("proxyElChange", null);
}
}, []);
useEffect(() => {
PubSub.publish("metadataChange", props);
}, [props]);
const computedStyle = useMemo(() => {
const propStyle = props.style || {};
return {
border: "dashed 1px #888",
transition: "all .2s ease-in",
...propStyle,
};
}, [props.style]);
return <div {...props} style={computedStyle} ref={el}></div>;
};
在路由中使用, 將樣式信息進行傳遞
class Bar extends React.Component {
render() {
return (
<div className="container">
<p>bar</p>
<div style={{ marginTop: "140px" }}>
<FloatProxy style={{ width: 120, height: 120, borderRadius: 15, overflow: "hidden" }} />
</div>
</div>
);
}
}
創建全局變數用於保存代理信息
// floatData.ts
type ProxyElType = {
current: HTMLElement | null;
};
type MetaType = {
attrs: any;
props: any;
};
export const metadata: MetaType = {
attrs: {
hideComponent: true,
left: 0,
top: 0
},
props: {},
};
export const proxyEl: ProxyElType = {
current: null,
};
創建一個FloatContainer容器組件,用於監聽代理數據的變化, 數據變動時驅動組件進行移動
import { metadata, proxyEl } from "./floatData";
class FloatContainer extends React.Component<any, any> {
componentDidMount() {
// 將代理組件上的props綁定到Float組件上
PubSub.subscribe("metadataChange", (msg, props) => {
metadata.props = props;
this.forceUpdate();
});
// 切換路由後代理元素改變,保存代理元素的位置信息
PubSub.subscribe("proxyElChange", (msg, el) => {
if (!el) {
metadata.attrs.hideComponent = true;
// 在下一次tick再更新dom
setTimeout(() => {
this.forceUpdate();
}, 0);
return;
} else {
metadata.attrs.hideComponent = false;
}
proxyEl.current = el.current;
const rect = proxyEl.current?.getBoundingClientRect()!;
metadata.attrs.left = rect.left;
metadata.attrs.top = rect.top
this.forceUpdate();
});
}
render() {
const { timeout = 500 } = this.props;
const wrapperStyle: React.CSSProperties = {
position: "fixed",
left: metadata.attrs.left,
top: metadata.attrs.top,
transition: `all ${timeout}ms ease-in`,
// 當前路由未註冊Proxy時進行隱藏
display: metadata.attrs.hideComponent ? "none" : "block",
};
const propStyle = metadata.props.style || {};
// 註入過渡樣式屬性
const computedProps = {
...metadata.props,
style: {
transition: `all ${timeout}ms ease-in`,
...propStyle,
},
};
console.log(metadata.attrs.hideComponent)
return <div className="float-element" style={wrapperStyle}>{this.props.render(computedProps)} </div>;
}
}
將組件提取到路由容器外部,並使用 FloatContainer 包裹
function App() {
return (
<BrowserRouter>
<div className="App">
<NavLink to={"/"}>/foo</NavLink>
<NavLink to={"/bar"}>/bar</NavLink>
<NavLink to={"/baz"}>/baz</NavLink>
<FloatContainer render={(attrs: any) => <MyImage {...attrs}/>}></FloatContainer>
<Routes>
<Route path="/" element={<Foo />}></Route>
<Route path="/bar" element={<Bar />}></Route>
<Route path="/baz" element={<Baz />}></Route>
</Routes>
</div>
</BrowserRouter>
);
}
實現效果:
目前我們實現了一個單例的組件,我們將組件改造一下,讓其可以被覆用
首先我們將元數據更改為一個元數據 map,以 layoutId 為鍵,元數據為值
// floatData.tsx
type ProxyElType = {
current: HTMLElement | null;
};
type MetaType = {
attrs: {
hideComponent: boolean,
left: number,
top: number
};
props: any;
};
type floatType = {
metadata: MetaType,
proxyEl: ProxyElType
}
export const metadata: MetaType = {
attrs: {
hideComponent: true,
left: 0,
top: 0
},
props: {},
};
export const proxyEl: ProxyElType = {
current: null,
};
export const floatMap = new Map<string, floatType>()
在代理組件中傳遞layoutId 來通知註冊了相同layoutId的floatContainer做出相應變更
// FloatProxy.tsx
// 保存代理元素引用,方便獲取元素的位置信息
useEffect(() => {
const float = floatMap.get(props.layoutId);
if (float) {
float.proxyEl.current = el.current;
} else {
floatMap.set(props.layoutId, {
metadata: {
attrs: {
hideComponent: true,
left: 0,
top: 0,
},
props: {},
},
proxyEl: {
current: el.current,
},
});
}
PubSub.publish("proxyElChange", props.layoutId);
return () => {
if (float) {
float.proxyEl.current = null
PubSub.publish("proxyElChange", props.layoutId);
}
};
}, []);
// 在路由中使用
<FloatProxy layoutId='layout1' style={{ width: 200, height: 200 }} />
在FloatContainer組件上也加上layoutId來標識同一組
// FloatContainer.tsx
// 監聽到自己同組的Proxy發送消息時進行rerender
PubSub.subscribe("metadataChange", (msg, layoutId) => {
if (layoutId === this.props.layoutId) {
this.forceUpdate();
}
});
// 頁面中使用
<FloatContainer layoutId='layout1' render={(attrs: any) => <MyImage imgSrc={img} {...attrs} />}></FloatContainer>
實現多組過渡的效果
最後
歡迎關註【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star
- 大數據分散式任務調度系統——Taier
- 輕量級的 Web IDE UI 框架——Molecule
- 針對大數據領域的 SQL Parser 項目——dt-sql-parser
- 袋鼠雲數棧前端團隊代碼評審工程實踐文檔——code-review-practices
- 一個速度更快、配置更靈活、使用更簡單的模塊打包器——ko