封裝多層的 styled-components 時,.attrs 執行順序的陷阱

Published on

遇到的問題

嘗試用 attrs 作 props mapping 封裝,讓封裝後的 components 在使用時,可以直接傳定義在 theme 當中的 colorName

const BaseText = styled.div`
color: ${(props) => props.$color};
// ... other styles
`;
const ArticleText = styled(Message).attrs((props) => ({
$color: props.theme.colors[props.colorName],
}))`
// ... some other styles
`;

如此就可以像這樣使用,<ArticleText> 最終產生的 <div> 會拿到 theme object 中定義的 red-100 的顏色值:

// (O) THIS WORKS FINE
<ArticleText colorName="red-100" {...props}>

而當想要封裝共用某個同樣顏色的 SpecialArticleText,原本想說可以用 .attrs() 方法一律傳入 colorName: 'red-100',但發現並沒有效果:

// (X) THIS DOES NOT WORK
const SpecialArticleText = styled(ArticleText).attrs({ colorName: 'red-100' })`
// ...some other styles
`
<SpecialArticleText>{content}</SpecialArticleText>

探討原因

仔細看了 .attrs() 的原始碼就知道原因。

假如我們有這樣封裝的 components:

const BaseDiv = styled.div`
/* ... */
`;
const StyledBaseDiv = styled(BaseDiv).attrs(attrsOfStyledBaseDiv)`
/* ... */
`;
const StyledStyledBaseDiv = styled(StyledBaseDiv).attrs(attrsOfStyledStyledBaseDiv)`
/* ... */
`;

其中 attrsOfStyledBaseDivattrsOfStyledStyledBaseDiv 都是作 props mapping 的函數。

那麼在 render StyledStyledBaseDiv 時,styled-components v5.3.6 處理會是類似像這樣的邏輯,

// outerProps are the props passed to <StyledStyledBaseDiv>
const copiedPropsWithTheme = { ...outerProps, theme };
const resolvedAttrs = {};
[attrsOfStyledBaseDiv, attrsOfStyledStyledBaseDiv].forEach((attrDef) => {
let resolvedAttrDef = attrDef;
let key;
if (isFunction(resolvedAttrDef)) {
// attrs 參數可以是 `Object` 或 `() => Object`
resolvedAttrDef = resolvedAttrDef(copiedPropsWithTheme);
}
for (key in resolvedAttrDef) {
copiedPropsWithTheme[key] = resolvedAttrs[key] =
key === "className"
? joinStrings(resolvedAttrs[key], resolvedAttrDef[key])
: resolvedAttrDef[key];
}
});
const computedProps = {
...outerProps,
...resolvedAttrs,
};
// filter out $xxx, as, shouldForwardProps() === false ...
const propsToElement = propsFilters(computedProps);
return <div {...propsToElement} />;

可以看到:

  1. attrs 參數的初始拿到的 props 是傳給最外層的 outerProps
  2. 但 attrs 參數的 mapping 順序是由內層到外層
  3. 最後的 computedProps 是用 mapping 的結果覆蓋掉給最外層的 outerProps

回到前面的例子:

const BaseText = styled.div`
color: ${props => props.$color};
// ... other styles
`
const ArticleText = styled(Message).attrs(
props => ({
$color: props.theme.colors[props.colorName]
})
)`
// ... some other styles
`
// (X) THIS DOES NOT WORK
const SpecialArticleText = styled(ArticleText).attrs({ colorName: 'red-100' })`
// ...some other styles
`
<SpecialArticleText>{content}</SpecialArticleText>

在這樣的情況下,會先執行 ArticleTextattrsDef,才用 SpecialArticleTextattrs 更新 props,跟想像的順序是反過來的。

更一般化的範例:

const BaseDiv = styled.div``;
// StyledBaseDiv:
const attrsOfStyledBaseDiv = (props) => {
console.log("StyledBaseDiv attrs", { ...props });
return {
_viaStyledBaseDiv: true,
value: "StyledBaseDiv",
};
};
const StyledBaseDiv = styled(BaseDiv).attrs(attrsOfStyledBaseDiv)``;
// StyledStyledBaseDiv:
const attrsOfStyledStyledBaseDiv = (props) => {
console.log("StyledStyledBaseDiv attrs", { ...props });
return {
_viaStyledStyledBaseDiv: true,
value: "StyledStyledBaseDiv",
};
};
const StyledStyledBaseDiv = styled(StyledBaseDiv).attrs(attrsOfStyledStyledBaseDiv)``;
// StyledStyledStyledBaseDiv
const attrsOfStyledStyledStyledBaseDiv = (props) => {
console.log("StyledStyledStyledBaseDiv attrs", { ...props });
return {
_viaStyledStyledStyledBaseDiv: true,
value: "StyledStyledStyledBaseDiv",
};
};
const StyledStyledStyledBaseDiv = styled(StyledStyledBaseDiv).attrs(
attrsOfStyledStyledStyledBaseDiv,
)``;
// What will be printed to console?
return <StyledStyledStyledBaseDiv value="outerProps" />;
// Order:
// 1. StyledBaseDiv attrs
// 2. StyledStyledBaseDiv attrs
// 3. StyledStyledStyledBaseDiv attrs
// Props gotten in each callback:
// In StyledBaseDiv attrs:
// {
// value: "outerProps"
// }
// In StyledStyledBaseDiv attrs:
// {
// _viaStyledBaseDiv: true,
// value: "StyledBaseDiv"
// }
// In StyledStyledStyledBaseDiv attrs:
// {
// _viaStyledBaseDiv: true,
// _viaStyledStyledBaseDiv: true,
// value: "StyledStyledBaseDiv"
// }
// And the <div> will get
// {
// _viaStyledBaseDiv: true,
// _viaStyledStyledBaseDiv: true,
// _viaStyledStyledStyledBaseDiv: true,
// value: "StyledStyledStyledBaseDiv"
// }

小結

  • 可以看到:
    1. 當 styled 多層時,會從外面傳的 props 開始,從最裡面的 attrs 開始跑 props mapping
    2. 外面的 attrs function return 的結果會蓋掉裡面的結果(包括 return 的 object 裡值是 undefined 的 property)。而非外面的 props 會傳給裡面的(跟 component 封裝不同)
    3. 過濾掉 props 不傳給 element,是所有 mapping 全部跑完才做,包括 $xxx 這種 props。而非一層一層過濾
    4. 每一層的 attrs 接到的 props 是會被後面執行的 attrs mutate 的,直接 console 出來會是最後的結果
  • styled-component 6 create component 和 attrs 都改寫,不確定是否有動到邏輯

結論

  1. 當把 .attrs 用在 styled(StyledComponent) 時,它其實不是在指定這一層的 props,而是在覆蓋前面的封裝設定過要傳給 element 的 props,而且當使用 function 時,它可以拿到前面的封裝設定完的 props 作為參數。
  2. 當在寫一個 component 時,如果是想要它 map 外面傳來的 prop,用一般的 function component 或 tagged template literal 都比較安全,不會遇到前面再被 styled().attrs() 時會不如預期的問題
  3. 目前沒想到有什麼 use case 是適合用 .attrs 傳 function
  4. 使用 shouldForwardProps 也要小心,一個 prop 要每一層的 shouldForwardProps 都通過才會傳給 element,且每一層的 shouldForwardProps 參數接到的 props 都是跑過全部的 attrs mapped 完之後的 props

Ref