CSS transform 对固定定位的影响
作者介绍
Dirk,货拉拉高级前端工程师,多年一线开发经验,目前负责货拉拉司机平台运力相关 Web 开发工作,在调优与 CSS 方面有较丰富经验。
注:头图来自 unsplash.com
一、问题背景
在开发司机社区的需求时,需要为司机社区添加动态、话题分享功能功能,其中在点击首页的动态项的小分享按钮点击也需要唤起弹窗分享。
期望效果
如下图所示,点击各动态内小分享按钮,弹出半屏分享弹窗。
代码结构
<!-- 列表 -->
<van-list>
<dynamic-item /> * n
</van-list>
<!-- 动态组件 -->
dynamicItem = (
<div>
<!-- 动态内容 -->
<div>
code...
</div>
<!-- 分享弹窗 -->
<share-pop />
</div>
)
实际效果
如下图左边所示:页面表现为遮罩层仅覆盖列表区域,分享弹窗在可视区域不可见。
切换到图层视角(下图右侧)弹出窗位置跑到的列表最底部,position: fixed;
失效,变成了相对于列表的定位。
明明布局和样式都写对了,但结果与我们期望的效果不符。
二、问题原因
排查后发现列表有这样一段 CSS 代码,目的是为了开启 GPU 加速。
尝试将其去掉,弹窗与遮罩范围都正常了。
根本原因
查阅了相关资料,发现 W3C 对于 CSS transform 的元素有如下解释,参见 CSS Transforms Module Level 1。
For elements whose layout is governed by the CSS box model, any value other than none for the transform property also causes the element to establish a containing block for all descendants. Its padding box will be used to layout for all of its absolute-position descendants, fixed-position descendants, and descendant fixed background attachments.
对于布局受 CSS 盒模型控制的元素,拥有 transform 属性的元素,其值除 none 以外的任何值都会导致元素为其所有后代建立一个包含块(containing block)。它将用于布局它的所有 absolute 定位后代、fixed 定位后代。
For elements whose layout is governed by the CSS box model, any value other than none for the transform property results in the creation of a stacking context.
对于布局受 CSS盒模型控制的元素,拥有 transform 属性的元素,其值除 none 以外的任何值都会导层叠上下文(stacking context)的创建。
包含块(containing block)
通常情况下包含块指的是距离元素最近的祖先元素的内容区。
如果一个元素 position 属性值为 absolute ,则将距离该元素最近且 position 属性值不为 none 的祖先元素作为包含块。
如果 position 属性值为 fixed,则将视口(viewport)作为包含块。
如果祖先元素拥有下列属性,会被作为 absolute 和 fixed 的包含块。
- transform/perspective 属性值不为 none
- will-change 属性值为 transform/perspective
- filter 属性值不为 none
- ......
问题结论
通过以上内容我们可以得出结论:因为列表添加了 transform: translateZ(0);
使得其含有 position: fixed;
属性的后代变为相对于包含块(本文开头示例的列表元素)定位,所以产生了最开始我们看到遮罩覆盖整个列表区域,分享弹窗则定位于列表的最底端的表现。
三、其他知识
在查阅资料的过程中,还了解到一些其他知识,对于我们理解 GPU 加速和为何需要 GPU 加速有一定帮助。
合成层
页面渲染阶段简化步骤:
根据 RenderLayers Tree 的结果,再生成 GraphicsLayers Tree,其中每个节点称为 GraphicsLayer。每个 GraphicsLayer 都有自己的 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图会传到 GPU 中,因此每个 GraphicsLayer 可以独立的进行渲染,仅在自己的 GraphicsLayer 中进行 reflow、repaint 等。
某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。
满足特殊属性的渲染层,会被提升为合成层,以下是一些常见的情况:
- 3D transforms:translate3d、translateZ 等
- video、canvas、iframe 等元素
- 通过
Element.animate()
实现的 opacity 动画转换 - 通过 СSS 动画实现的 opacity 动画转换
- position: fixed
- 具有 will-change 属性
- 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition
GPU 与 GPU 加速
上面分别是 CPU 和 GPU 的架构示意图,绿色的(ALU)是计算单元,橙色的(Cache、DRAM)是存储单元,黄色的(Control)是控制单元。
CPU:需要很强的通用性来处理各种不同的数据类型,同时又要逻辑判断又会引入大量的分支跳转和中断的处理。这些都使得CPU的内部结构异常复杂。
GPU:拥有大量计算单元,面对的则是类型高度统一的、相互无依赖的大规模数据和不需要被打断的纯净的计算环境。
好比大佬与小弟,大佬负责处理各种复杂逻辑与决策,小弟负责处理工作量大,技术含量低,且要多次重复的工作,对于大量简单工作最好的办法就是交由一大帮小弟来处理。
图形计算就属于大量重复简单计算,由 GPU 处理再合适不过。
四、解决思路
测试 GPU 加速
由于前面在排查过程中发现列表有一段 translateZ(0px)
代码,是为了开启 GPU 加速。
在提出解决思路之前,我们先测试下有无 translateZ(0px)
的差别。
去除 translateZ(0px)
添加 translateZ(0px)
测试视频如上,左上角的绿字为 FPS(每秒渲染画面帧数)。测试后发现二者对列表滚动时手机 FPS 几乎无任何影响。当然也可能对于较老的机型才会有明显效果,在较新机型上这个就有点玄学了,但是并不能去掉。
解决思路一
将分享弹框放置于上层,所有动态组件共用,但梳理代码后发现现有代码动态组件复用多,应在 7 至 8 处左右,采用此方法改动量会稍大,故先从思路二着手。
解决思路二
将元素挂载到含有 transform 属性的元素之外,首先想到的是 React 在 v16 开始官方提供的 React Portal(传送门),可以将元素渲染到另一个地方去。
// 使用方法
import React from 'react';
import {createPortal} from 'react-dom';
class Test extends React.Component {
render() {
return createPortal(
// Child
<div class="test">Test</div>,
// Container
this.node
);
}
}
Vue3 官方支持传送门:内置组件 | Vue.js
但司机社区项目是由 Vue2 开发,Vue2 官方并没有相关实现,好在有第三方提供了类似的传送门功能:Usage example | Portal-Vue
组件库的解决方案
到此为止我们基本了解了框架相关的解决方案,接下来看看组件库是否有解决方案。
Vant 的实现
司机社区分享功能是以 Vant 的 Popup 组件作为弹出层容器,提供有 get-container
来指定挂载点。
其实现代码如下:
export function PopupMixin(options = {}) {
return {
mixins: [
TouchMixin,
CloseOnPopstateMixin,
// 混入传送门 Mixin
PortalMixin({
afterPortal() {
if (this.overlay) {
updateOverlay();
}
},
}),
],
// code...
}
};
function getElement(selector) {
if (typeof selector === 'string') {
return document.querySelector(selector);
}
return selector();
}
export function PortalMixin({ ref, afterPortal } = {}) {
return {
props: {
getContainer: [String, Function],
},
watch: {
getContainer: 'portal',
},
mounted() {
if (this.getContainer) {
this.portal();
}
},
methods: {
portal() {
const { getContainer } = this;
const el = ref ? this.$refs[ref] : this.$el;
let container;
// 如传入 getContainer 则 container 为其传入的挂载点
if (getContainer) {
container = getElement(getContainer);
// 未指定则默认为其父元素
} else if (this.$parent) {
container = this.$parent.$el;
}
if (container && container !== el.parentNode) {
// 使用 appendChild 将内容挂载至目标元素
container.appendChild(el);
}
if (afterPortal) {
afterPortal.call(this);
}
},
},
};
}
如果设置有 getContainer
,则挂载点为设置的元素否则默认为父元素,之后使用 appendChild()
将其挂载。
对比 antd-mobile
antd-mobile 对于 Popup 组件同样提供了挂载节点,与 Vant 不同 ,antd-mobile 默认就将挂载点设置为 body,最后使用 React Portal 能力,将内容挂载到目标节点。相较于 Vant,antd-mobile 默认挂载到 body 也会少遇到一些问题。
其实现代码如下:
src/components/popup/popup.tsx
// import...
import { defaultPopupBaseProps, PopupBaseProps } from './popup-base-props'
import { renderToContainer } from '../../utils/render-to-container'
const defaultProps = {
// 默认参数
...defaultPopupBaseProps,
position: 'bottom',
}
export const Popup: FC<PopupProps> = p => {
const props = mergeProps(defaultProps, p)
// code...
return (
<ShouldRender
active={active}
forceRender={props.forceRender}
destroyOnClose={props.destroyOnClose}
>
// 渲染至目标容器
{renderToContainer(props.getContainer, node)}
</ShouldRender>
)
}
src/components/popup/popup-base-props.ts
export const defaultPopupBaseProps = {
closeOnMaskClick: false,
destroyOnClose: false,
disableBodyScroll: true,
forceRender: false,
getContainer: () => document.body, // 此处默认设置 popup 挂载点为 body
mask: true,
showCloseButton: false,
stopPropagation: ['click'],
visible: false,
}
src/utils/render-to-container.ts
export function renderToContainer(
getContainer: GetContainer,
node: ReactElement
) {
if (canUseDom && getContainer) {
const container = resolveContainer(getContainer)
return createPortal(node, container) as ReactPortal // 使用 React Portal 能力
}
return node
}
五、总结
本文记录了以开发中遇到的 position: fixed;
失效问题为起点,逐步分析并解决问题的过程。
transform: translateZ(0px);
通常用于开启 GPU 加速,强制提升图层以使用 GPU 加速,提高页面性能。这其实是一种非正常的方式,GPU 硬件加速需要新建图层,而把该元素移动到新图层是个耗时操作,所以最好提前做。
will-change 会提前告诉浏览器在一开始就把元素放到新图层,提前一步操作,方便之后的渲染。如果想强制触发硬件加速,推荐使用 will-change
。少数不支持的浏览器,仍可使用 translateZ(0)
来解决。另外也不要为过多的元素开启 GPU 加速,如果开启的元素过多,反倒会使页面变得卡慢,甚至崩溃。
附:参考资料
转载自:https://juejin.cn/post/7117817631930843149