谈谈 stylex 源码 和 前端 css 方案

谈谈 stylex 源码 和 前端 css 方案

本文的探讨的范围,仅限当前年份已有的, css 相关的方案

截至目前 stylex 的版本是 0.3.0,源码的核心脉络看的差不多了,作为用户或者是应对一些八股文感觉已经足够了。这也是我看的第一个,在编译期间就能把所有样式抽离,并且在运行时还能保持动态更新能力的库

看完之后让我有种豁然开朗的感觉,感觉我对于 css 普遍应用层面的认识更加丰富了,顺便说下

  • 另一个能做到类似事情的是 vue,人家是自带的功能,所以就不算在内了
  • 普遍应用是指,抛开各种 css 框架给予的各种花里胡哨的功能,只剩下的大家普遍都在用的方式和功能

目录

  • stylex
    • 我对于源码的关注点脉络
    • 源码结构
    • 代码生成
    • 如何做到只在编译期间生效,还能保留动态更新能力
  • 我认为 stylex 目前有所欠缺的地方
  • 当前 css 方案对比
  • 如果让我现在做一个 css 框架
  • 总结

stylex

我对于源码的关注点脉络

这里我真的想吐个槽(也可能是我比较菜哈),源码是用 flow 写的,我不懂这玩意,以 ts 的思维看源码,有些类型似懂非懂的,但并不妨碍我看具体的逻辑,你源码饶老绕去的我可以 debug 呀,可是吧

官方仓库有 3 个 demo

我自己按官网配的 nextjs 跑不起来,用官方仓库的发现无法 debug 进源码

rollupwebpack 版本只有编译成纯 js 没有和框架交互的代码,里边放了 html 的插件为什么不顺便建一个 html 文件呢

rollup 插件不支持 vite,最后我只好把源码手动编译出来,放到 vite 结合 vue/react 一起 debug 了。不过我发现源码流程没想象中的复杂,所以我自制的测试版本和真实使用其实是差不多的

stylex 对外暴露的 api 并不多,我的关注点如下

  • 核心方法的流程,即 create/props 这两个函数是怎么执行的
  • 怎么做到开发提取所有样式,同时还能在没有运行时的情况下做到生产版本的动态更新
  • 和框架怎么结合的,毕竟这是款跨框架的库,我就很好奇为什么和 vite 不好兼容了

源码结构

源码仓库是 monorepo 结构,目录如下

txt 复制代码
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 方法举例

ts 复制代码
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__ 给打猴子补丁

js 复制代码
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 包中,猴子补丁就是用新的方法覆盖已有的方法

js 复制代码
const originLog = console.log
console.log = function myLog(...value) {
  //dosoming...
  originLog.call(...value)
}

比如这里我用自己写的方法覆盖了原来的方法就是一个猴子补丁,它通常是用来在调用某些方法时,在不更改源码下做点什么的手段

但这里能看到 __implementations 对象一开始就是个空对象,前边是 flow 类型代码不用管

我们还是主要看 create 方法在 dev-runtime 里是如何执行的

js 复制代码
__monkey_patch__('create', getStyleXCreate({ ...config, insert }));

inject 方法中给挂载的方法是一个科里化后的方法,闭包缓存了 config/insert 两个方法,它们都来自 inject 方法的参数,和如果没传则使用的默认参数,直到用户调用才开启实际的逻辑流程

js 复制代码
// 代码太多了,有所省略
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 对象中可以看到具体的样式规则

总结下创建时的核心流程

  1. 初始化:打个猴子补丁方法,并科里化缓存用于配置
  2. 根据运行时传的样式配置,生成代码
  3. 插入 dom
  4. 缓存一些东西

代码生成

代码生成可以包含两部分,运行时和插件,它们生成和创建的具体内容的规则代码都在 shared 包中。由于代码比较多我就文字描述了

运行时期间

为了更好的代码体验,用到了 styleq 包来帮我合并代码,比如一个数组的样式最终都会合并成一个对象

类名的哈希生成依赖了一个库 murmurhash,它会生成固定长度的哈希,但我自己电脑和 hash-sum 包比,发现性能不如它呀,也不知道为什么不用嘞。。。

由于是原子 css,样式只需要拆开就行。但是有些内容是散着写的,比如动画和媒体查询代码

js 复制代码
const pulse = stylex.keyframes({
  '0%': {transform: 'scale(1)'},
  '50%': {transform: 'scale(1.1)'},
  '100%': {transform: 'scale(1)'},
});

内部会对它们做一些操作,最终结果会被拼起来,就是我们在 style 标签里裸写的样子,具体流程我觉得不重要直接跳过

插件

插件生成就是把运行时要干的事搬到了编译阶段,实际插件干的事不止这些,就生成逻辑而言就这些了

如何做到只在编译期间生效,还能保留动态更新能力

这个原理有 2 个,样式更新依赖的是 css变量,逻辑更新是依赖的插件编译,这里我只需要贴一个开发和打包后的代码对比就懂了

js 复制代码
//打包前,开发时的源码
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 个很坑的点

js 复制代码
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 jsvue 风格加内置的 css 变量控制方案

由于现在都是处于组件开发状态,所以处理器和裸用的方案肯定会变得难用,因为要自己处理样式冲突,处理动态更新等

css scope 是个折中的方案,说它折中是因为它只提供了基础所需,比上不足比下够用

vue 的方案是可以注入变量的,可是它只适用于 vue

css in js / atom-css 这是两个极端

原子样式的优缺点都非常的分明,极致的体积和糟糕的开发体验。说它糟糕是因为我自己的博客项目前后台都用的它,以前写库也在用,我慢慢的就发现了无法解决的 2 个致命的点

  1. 样式都写在页面内,行内样式太痛苦了,unocss 能够有所缓解但远远不够,tailwindcss 简直不能看,太长太长太长了,我尝试了自定义类和自定义规则,期初确实减少了不少,可是后续的更改则会产生更多共同的,如果放在业务代码中,哪来的机会重构,只要敢多自定点东西分分钟钟变屎山,不自定义就又臭又长。如果把类名变成变量维护,代码提示就丢了,复杂的动态拼接可能还有问题
  2. 样式选择器,比如我内部封装好了外部如果要改该怎么办?只能选择自定义类或者传类名进去,前者一旦开了头业务代码中就会一发不可收拾,后者则是真心难用啊

如果我用的不对可以联系我反驳,接收任何我提到的,能解决我开发痛点,以及业务中团队协作使用痛点,封装痛点的方案

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 框架雏形的脑暴分享文章

所以对于我不感兴趣的源码细节我懒得深入的看,所以将来有没有按照我这个思路做一版出来呢?

相关推荐
杨进军几秒前
React 实现节点删除
前端·react.js·前端框架
yanlele22 分钟前
【实践篇】【01】我用做了一个插件, 点击复制, 获取当前文章为 Markdown 文档
前端·javascript·浏览器
爱编程的喵25 分钟前
React useContext 深度解析:告别组件间通信的噩梦
前端·react.js
望获linux1 小时前
【实时Linux实战系列】多核同步与锁相(Clock Sync)技术
linux·前端·javascript·chrome·操作系统·嵌入式软件·软件
魂祈梦1 小时前
rsbuild的环境变量
前端
赫本的猫1 小时前
告别生命周期!用Hooks实现更优雅的React开发
前端·react.js·面试
LaoZhangAI1 小时前
Browser MCP完全指南:5分钟掌握AI浏览器自动化新范式(2025最新)
前端·后端
咸鱼青菜好好味1 小时前
node的项目实战相关3-部署
前端
赫本的猫1 小时前
React中的路由艺术:用react-router-dom实现无缝页面切换
前端·react.js·面试
极客三刀流1 小时前
el-select 如何修改样式
前端