谈谈 stylex 源码 和 前端 css 方案
本文的探讨的范围,仅限当前年份已有的, css
相关的方案
截至目前 stylex
的版本是 0.3.0
,源码的核心脉络看的差不多了,作为用户或者是应对一些八股文感觉已经足够了。这也是我看的第一个,在编译期间就能把所有样式抽离,并且在运行时还能保持动态更新能力的库
看完之后让我有种豁然开朗的感觉,感觉我对于 css
普遍应用层面的认识更加丰富了,顺便说下
- 另一个能做到类似事情的是
vue
,人家是自带的功能,所以就不算在内了 - 普遍应用是指,抛开各种
css
框架给予的各种花里胡哨的功能,只剩下的大家普遍都在用的方式和功能
目录
stylex
- 我对于源码的关注点脉络
- 源码结构
- 代码生成
- 如何做到只在编译期间生效,还能保留动态更新能力
- 我认为
stylex
目前有所欠缺的地方 - 当前
css
方案对比 - 如果让我现在做一个
css
框架 - 总结
stylex
我对于源码的关注点脉络
这里我真的想吐个槽(也可能是我比较菜哈),源码是用 flow
写的,我不懂这玩意,以 ts
的思维看源码,有些类型似懂非懂的,但并不妨碍我看具体的逻辑,你源码饶老绕去的我可以 debug
呀,可是吧
官方仓库有 3 个 demo
我自己按官网配的 nextjs
跑不起来,用官方仓库的发现无法 debug
进源码
rollup
和 webpack
版本只有编译成纯 js 没有和框架交互的代码,里边放了 html 的插件为什么不顺便建一个 html
文件呢
rollup
插件不支持 vite
,最后我只好把源码手动编译出来,放到 vite
结合 vue/react
一起 debug
了。不过我发现源码流程没想象中的复杂,所以我自制的测试版本和真实使用其实是差不多的
stylex
对外暴露的 api
并不多,我的关注点如下
- 核心方法的流程,即
create/props
这两个函数是怎么执行的 - 怎么做到开发提取所有样式,同时还能在没有运行时的情况下做到生产版本的动态更新
- 和框架怎么结合的,毕竟这是款跨框架的库,我就很好奇为什么和
vite
不好兼容了
源码结构
源码仓库是 monorepo
结构,目录如下
babel-plugin
eslint-plugin
open-props
scripts
stylex
dev-runtime
nextjs-plugin
rollup-plugin
shared
webpack-plugin
这里需要值得关注的有 3 个
stylex
开发者使用的方法都是这里引入的dev-runtime
开发期间的运行时,这里能看到核心的生成原理babel-plugin
stylex
有多个端的插件,插件的核心逻辑都在这里shared
开发运行时和生成生产代码等,一些公共逻辑代码
在 stylex
包中,主要包含了基础 api
方法的代理,以及真实操纵 dom
的封装类
基础方法代理就是指,create/props/defineVars/...
一些其他方法,那 create
方法举例
function stylexCreate<S: { +[string]: mixed }>(styles: S): MapNamespaces<S> {
if (__implementations.create != null) {
const create: Stylex$Create = __implementations.create;
return create<S>(styles);
}
throw new Error(
'stylex.create should never be called. It should be compiled away.',
);
}
基本都是这种,从 __implementations
上面拿到真实的方法,如果没有就抛出异常,这个对象上的内容都是从 dev-runtime
中拿到的
在开发期间插件会注入该运行时代码,此时不需要打包,依靠运行时就可以达到编译同等的效果
在打包阶段会直接生成最终代码,运行时代码则不会包含,所以在非开发期间调用就会抛出异常
在 dev-runtime
包中,需要首先调用内部导出的 inject
方法,官网中也提到了。这个方法会调用 __monkey_patch__
给打猴子补丁
const __implementations: { [string]: $FlowFixMe } = {};
export function __monkey_patch__(
key: string,
implementation: $FlowFixMe,
): void {
if (key === 'types') {
Object.assign(types, implementation);
} else {
__implementations[key] = implementation;
}
}
__monkey_patch__
方法是在 stylex
包中,猴子补丁就是用新的方法覆盖已有的方法
const originLog = console.log
console.log = function myLog(...value) {
//dosoming...
originLog.call(...value)
}
比如这里我用自己写的方法覆盖了原来的方法就是一个猴子补丁,它通常是用来在调用某些方法时,在不更改源码下做点什么的手段
但这里能看到 __implementations
对象一开始就是个空对象,前边是 flow
类型代码不用管
我们还是主要看 create
方法在 dev-runtime
里是如何执行的
__monkey_patch__('create', getStyleXCreate({ ...config, insert }));
在 inject
方法中给挂载的方法是一个科里化后的方法,闭包缓存了 config/insert
两个方法,它们都来自 inject
方法的参数,和如果没传则使用的默认参数,直到用户调用才开启实际的逻辑流程
// 代码太多了,有所省略
function createWithFns<S: { ... }>(
styles: S,
{ insert, ...config }: RuntimeOptionsWithInsert,
): CompiledNamespaces<S> {
const stylesWithoutFns: { [string]: RawStyles } = {};
const stylesWithFns: {
[string]: (...args: any) => { +[string]: string | number },
} = {};
//循环 styles,把内容拆分成 stylesWithoutFns 合 stylesWithFns 的内容
//根据配置和参数,编译出,类样式和类的实际代码,还有些内部属性
const [compiledStyles, injectedStyles] = create(stylesWithoutFns, config);
//插入到 dom
for (const key in injectedStyles) {
const { ltr, priority, rtl } = injectedStyles[key];
insert(key, ltr, priority, rtl);
}
spreadStyles(compiledStyles);
//根据 debug 看到的,应该是内部存储了一些内容,影响不大
const temp: {
+[string]:
| FlatCompiledStyles
| ((
...args: any
) => [FlatCompiledStyles, { +[string]: string | number }]),
} = compiledStyles;
const finalStyles: {
[string]:
| FlatCompiledStyles
| ((
...args: any
) => [FlatCompiledStyles, { +[string]: string | number }]),
} = { ...temp };
// Now we put the functions back in.
for (const key in stylesWithFns) {
// $FlowFixMe
finalStyles[key] = (...args) => [temp[key], stylesWithFns[key](...args)];
}
return (finalStyles: $FlowFixMe);
}
生成的流程在更下边
插入 dom
的代码封装在 stylex
包里的 StyleXSheet
类中,可以认为主要就是缓存需要注入的内容,生成的 hash
类名和它们的对应关系,然后操作 style
标签实例的 sheet
对象进行增删改查实际的样式规则,感兴趣的可以运行代码 document.querySelector("style").sheet
看浏览器控制台输出的内容,在 rules
对象中可以看到具体的样式规则
总结下创建时的核心流程
- 初始化:打个猴子补丁方法,并科里化缓存用于配置
- 根据运行时传的样式配置,生成代码
- 插入 dom
- 缓存一些东西
代码生成
代码生成可以包含两部分,运行时和插件,它们生成和创建的具体内容的规则代码都在 shared
包中。由于代码比较多我就文字描述了
运行时期间
为了更好的代码体验,用到了 styleq
包来帮我合并代码,比如一个数组的样式最终都会合并成一个对象
类名的哈希生成依赖了一个库 murmurhash
,它会生成固定长度的哈希,但我自己电脑和 hash-sum
包比,发现性能不如它呀,也不知道为什么不用嘞。。。
由于是原子 css
,样式只需要拆开就行。但是有些内容是散着写的,比如动画和媒体查询代码
const pulse = stylex.keyframes({
'0%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.1)'},
'100%': {transform: 'scale(1)'},
});
内部会对它们做一些操作,最终结果会被拼起来,就是我们在 style
标签里裸写的样子,具体流程我觉得不重要直接跳过
插件
插件生成就是把运行时要干的事搬到了编译阶段,实际插件干的事不止这些,就生成逻辑而言就这些了
如何做到只在编译期间生效,还能保留动态更新能力
这个原理有 2 个,样式更新依赖的是 css变量
,逻辑更新是依赖的插件编译,这里我只需要贴一个开发和打包后的代码对比就懂了
//打包前,开发时的源码
const pulse = stylex.keyframes({
'0%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.1)'},
'100%': {transform: 'scale(1)'},
});
const style = stylex.create({
root: {
backgroundColor: 'red',
padding: '1rem',
paddingInlineStart: '2rem',
},
pulse: {
animationName: pulse,
animationDuration: '1s',
animationIterationCount: 'infinite',
},
dynamic: (r, g, b) => ({
color: `rgb(${r}, ${g}, ${b})`,
}),
});
export default function App() {
return stylex.props(style.button, style.dynamic(0,0,0));
}
//打包后
const style = {
root: {
backgroundColor: "xrkmrrc",
padding: "x1uz70x1",
paddingStart: null,
paddingLeft: null,
paddingEnd: null,
paddingRight: null,
paddingTop: null,
paddingBottom: null,
paddingInlineStart: "xld8u84",
$$css: true
},
pulse: {
animationName: "x1rpuqfj",
animationDuration: "x1q3qbx4",
animationIterationCount: "xa4qsjk",
$$css: true
},
dynamic: (r, g, b) => [{
color: "x19dipnz",
$$css: true
}, {
"--color": `rgb(${r}, ${g}, ${b})` != null ? `rgb(${r}, ${g}, ${b})` : "initial"
}]
};
function App() {
return stylex.props(style.button, style.dynamic(0, 0, 0));
}
dynamic
是我们的动态代码,插件会直接修改我们的源码变成它需要的样子,同时创建逻辑也变成了常量,此时样式、变量,什么的已经生成好了,不管我们用不用它都会挂到 dom
里,用的时候只需要把相关的类名放上去就行了
stylex.props
的作用就是把生成好的类名拿出来而已
我认为 stylex
目前有所欠缺的地方
个人的拙见,看看就好
目前来看这东西还是非常依赖插件的,可以看到打包后的会变得很简洁,可问题就出来
依靠 babel
分析会有 2 个很坑的点
const c = stylex
const style = c.create({
root: {
backgroundColor: 'red',
padding: '1rem',
paddingInlineStart: '2rem',
},
});
export default function App() {
return c.props(style.button, style.dynamic(0,0,0));
}
比如我稍微改变下写法插件就无法分析了,我赋值一个新的变量,会发现就失效了,
代码分析非常非常非常的慢,因为插件不仅要分析源码,还会修改源码,难以想象上了规模编译得多慢
感觉设计的优点迷
比如 defineVars
方法只能定义在指定后缀文件中,而其他的方法则可以放到任何 js/ts
文件。那么假如,如果把创建和定义全部放在指定后缀文件中,代码分析应该会简单的多,比如分析导出语句,因为 es6
导出的语法是静态的,cjs
就保持原样即可,这样容错率也会高很多
当前 css
方案对比
我目前用过以下风格的 css
方案
-
裸用,或者搭配预处理器(
less/sass/less/styl
) -
css scope
就是vue/angular/css-module
风格的 -
css in js
代表是emotion
-
原子css/atom-css
代表是tailwindcss/unocss
个人比较喜欢的是 css in js
和 vue
风格加内置的 css
变量控制方案
由于现在都是处于组件开发状态,所以处理器和裸用的方案肯定会变得难用,因为要自己处理样式冲突,处理动态更新等
css scope
是个折中的方案,说它折中是因为它只提供了基础所需,比上不足比下够用
vue
的方案是可以注入变量的,可是它只适用于 vue
css in js / atom-css
这是两个极端
原子样式的优缺点都非常的分明,极致的体积和糟糕的开发体验。说它糟糕是因为我自己的博客项目前后台都用的它,以前写库也在用,我慢慢的就发现了无法解决的 2 个致命的点
- 样式都写在页面内,行内样式太痛苦了,
unocss
能够有所缓解但远远不够,tailwindcss
简直不能看,太长太长太长了,我尝试了自定义类和自定义规则,期初确实减少了不少,可是后续的更改则会产生更多共同的,如果放在业务代码中,哪来的机会重构,只要敢多自定点东西分分钟钟变屎山,不自定义就又臭又长。如果把类名变成变量维护,代码提示就丢了,复杂的动态拼接可能还有问题 - 样式选择器,比如我内部封装好了外部如果要改该怎么办?只能选择自定义类或者传类名进去,前者一旦开了头业务代码中就会一发不可收拾,后者则是真心难用啊
如果我用的不对可以联系我反驳,接收任何我提到的,能解决我开发痛点,以及业务中团队协作使用痛点,封装痛点的方案
被 atom-css
折磨得死去活来后我还是果断选择了 css in js
。这里我要为 css in js
正个名,因为有些死脑筋就是觉得逻辑耦合了样式就是一坨子屎,不接受任何反驳的观点
react
当初有个演讲讲的就很好,因为前端的试图样式逻辑三者分身就是耦合关系
- 因为逻辑如果不能操作页面那不就变成纯静态页面了
- 如果逻辑不能操作样式,那么多炫酷的动画也许就做不了,比如
d3.js
那些 - 样式只有捆绑视图才能生效,只有捆绑正确的视图才不会样式乱套(比如微前端多系统)
我们把三者分成三个文件写只能算是源码分离,说它们不是耦合关系都是在自欺欺人。组件化开发让我们可以更好的用逻辑来操作视图,而 css in js
则是让我们可以用逻辑更好的操作样式,因为 js
的动态性可以让操作样式的能力变得强大
至于把 css in js
写成屎的,我能想到的只能是人的问题,毕竟行内样式写多了不好咱可以搞样式组件。而有个非常致命的缺点就是性能比较差,根据我若干年使用的经验来看,只要不是特别屎的写法的累计,即便是 input
输入通常也不会构成性能瓶颈
而stylex
的做法则是弥补了性能问题,也具备了 js
的动态性,可我实际用下来发现的问题是
- 编译太太太太慢了,这让我想到了
RSC
,一对难兄难弟 - 开发体验和
emotion
之类的比差了点,存在模版代码,样式是会很频繁的写,所以就会变得非常琐碎 - 需要注意写法,稍微玩点花的就不会被编译了
如果让我现在做一个 css
框架
这就是我的脑暴了,不想看可以跳过哈
有了 stylex
的原理铺垫,在结合业务开发的情况,各个地方抄一点或许就会变得挺不错
我们用 stylex
的编译生成策略保障性能
将能写样式的地方放到固定后缀文件中,并且只支持 esm
导出。这样保障了编译的可靠性和超高的容错率
固定后悔文件用 js/ts
就行,内部或许可以写法用 css in js
的写法,但不能写组件。这样保证了 js 的灵活性,也保证了类似于 css scope
的写法,防止团队的人引水平问题乱写样式组件
编译时直接编译样式文件就能拿到哪个文件导出了哪些内容,然后动态改写源码,编译用esbuild
,可能执行会慢一点点,但和stylex
插件比,能预想到会快得多的多
然后把输出文件注入进页面就行了,服务端渲染就把编译的内容导出来给用户
最后还差个内容是怎么穿透修改样式,比如外部覆盖组件库内部的样式,这东西我目前没什么好想法,我觉得或许可以沿用css in js
的做法挂个运行时编译
毕竟一个项目不会有那么多必须强覆盖样式的场景,因为同一个项目靠样式文件和组件传参就能实现共享,强需求应该大多出现在组件库之类的地方,不是非常频繁的动态样式变动,编译样式是消耗不了多少性能的,体积优化优化编译的实现源码降到 0.5 kb
估计也是可以的(自己做过类似emotion
的东西,类比过来是可以的)
总结
这不是篇正儿八经的教学文章,只是我看了 stylex
源码心血来潮想了个 css in js
框架雏形的脑暴分享文章
所以对于我不感兴趣的源码细节我懒得深入的看,所以将来有没有按照我这个思路做一版出来呢?
原文链接:https://juejin.cn/post/7312724198987743295 作者:usagisah