谈谈 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 结构,目录如下

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 对象中可以看到具体的样式规则

总结下创建时的核心流程

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

代码生成

代码生成可以包含两部分,运行时和插件,它们生成和创建的具体内容的规则代码都在 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 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 框架雏形的脑暴分享文章

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

原文链接:https://juejin.cn/post/7312724198987743295 作者:usagisah

(0)
上一篇 2023年12月16日 上午10:05
下一篇 2023年12月16日 上午10:16

相关推荐

发表回复

登录后才能评论