styled-components 深入浅出 (三) : 原理解析

366 阅读8分钟

源码解析

首先拉取 styled-components 仓库,然后找到 /packages/styled-components 文件夹,核心代码都放在这个文件夹里;为了方便,后面会把 styled-components 简写为 sc。

往期 styled-components 文章:

《styled-components 深入浅出 (一) : 基础使用》

《styled-components 深入浅出 (二) : 高阶组件》

如何阅读源代码?

当我们对该项目有所了解的时候。

  • 第一种:当我们使用过该项目的一些 api ,对于某个功能是如何实现的,带着这个问题去看,比如当我们使用 styled 函数的时候,我们会想到 styled 函数应该是通过 document.createElement 来创建 <style> 标签来实现样式化的,然后全局搜索 document.createElement 快速定位到。这种方式适合看具体单独的某个功能如何实现。
  • 第二种:从入口文件看起,根据导入、导出的文件,找到对应的文件,一层一层剥开,但是这种在比较复杂的项目中,可能需要花比较长的时间才能理清逻辑。这个时候我们就需要通过打断点的方式,来调试代码,找到一些关键函数的调用关系,梳理清楚主要逻辑。

对于 sc 这个项目我是采用两种方式结合使用。

makeStyleTag

一开始我就通过 document.createElement('style') 定位到 makeStyleTag 函数。代码所在文件:

image.png

代码非常简单易懂,这是去掉类型后的代码:

export const makeStyleTag = (target) => {
  const head = document.head;
  const parent = target || head;
  const style = document.createElement('style');
  // style 标签的插入位置
  const prevStyle = findLastStyleTag(parent);
  const nextSibling = prevStyle !== undefined ? prevStyle.nextSibling : null;

  // 自定义属性 data-set
  style.setAttribute(SC_ATTR, SC_ATTR_ACTIVE);
  style.setAttribute(SC_ATTR_VERSION, SC_VERSION);

  const nonce = getNonce();

  // 设置 style 标签的 nonce 属性:一种加密的随机数(一次使用的数字)
  if (nonce) style.setAttribute('nonce', nonce);

  // 在父节点里的最后一个子节点的位置插入新的 style 节点
  parent.insertBefore(style, nextSibling);

  return style;
};

我们可以看到,这个函数主要做了以下几件事:

  1. 获取父节点,如果没有传入,则默认获取 document.head
  2. 获取父节点的最后一个 <style> 标签,如果没有,则返回 null
  3. 创建 <style> 标签,并设置了自定义属性
  4. 设置 <style> 标签的 nonce 属性
  5. <style> 标签插入到父节点的最后一个子节点的位置,然后返回 style 节点。

标签模板字符串

通过第一篇文章,我们知道通过 styled.divstyled.div() 都能创建一个样式化的 <div> 标签,这是怎么回事?来看一个例子:

image.png

这其实是 ES6 的一个新语法:模板字符串,在这可以把它看做是一个函数,接受传参。

styled 函数

我们找到入口文件:packages/styled-components/src/index.ts,

import styled from './constructors/styled';

export * from './base';
export {
  CSSKeyframes,
  CSSObject,
  CSSProp,
  CSSProperties,
  CSSPseudos,
  DefaultTheme,
  ExecutionContext,
  ExecutionProps,
  IStyledComponent,
  IStyledComponentFactory,
  IStyledStatics,
  PolymorphicComponent,
  PolymorphicComponentProps,
  RuleSet,
  Runtime,
  StyledObject,
  StyledOptions,
  WebTarget,
} from './types';
export { styled as default, styled };

默认导出的 styled 函数是从 packages/styled-components/src/constructors/styled.tsx 导出的,找到它,去掉类型后的代码方便阅读。

import createStyledComponent from '../models/StyledComponent';

// HTML 标签列表
import domElements from '../utils/domElements';
import constructWithOptions from './constructWithOptions';

// 创建基础的 styled 方法
const baseStyled = (tag) =>
 constructWithOptions(createStyledComponent, tag);
 
const styled = baseStyled;

// 实现通过 styled[domElement] 和 styled(domElement) 都能创建样式化组件
domElements.forEach(domElement => {
 styled[domElement] = baseStyled(domElement);
});

export default styled;

createStyledComponent 构造样式化组件

我们看到通过 styled 函数是基础的 styled 方法:baseStyled 调用了 constructWithOptions 方法,找到 constructWithOptions 方法所在的文件 src/constructors/constructWithOptions.ts,去掉类型只留下关键的代码如下

import css from './css';

export default function constructWithOptions(componentConstructor, tag, options) {

 const templateFunction = (initialStyles, ...interpolations) =>
   componentConstructor(tag, options, css(initialStyles, ...interpolations));
 
 // 返回样式化组件
 return templateFunction;
 
}

constructWithOptions 函数的核心是 templateFunction 方法,它调用组件的构造方法 componentConstructor返回一个样式化组件(携带样式的组件)。其实这个方法就是上面说到的 baseStyled 方法传入的 createStyledComponent 方法。该方法所在文件:src/models/StyledComponent.ts

image.png

为了方便理解,我画了个简单的函数调用关系图:

image.png

从这我们可以看出进来,当 sc 的使用者调用 styled() 这个api创建样式化组件时,本质上是通过 createStyledComponent 这个组件构造函数来实现的. 它返回一个 WrappedStyledComponent 组件,这个组件是通过 React.forwardRef 创建的,并且在返回之前会对 WrappedStyledComponent 组件添加一些属性。

生成唯一类名

sc的样式是组件级隔离的,通过生成唯一的类名来实现组件级隔离,sc 是如何生成唯一的类名的呢?接着上面来继续分析哈!

通过前面的分析我知道通过 React.forwardRef(forwardRefRender) 创建返回 WrappedStyledComponent 组件,我们找到 forwardRefRender 发现是调用了 useStyledComponentImpl 来创建元素的。

  function forwardRefRender(props: ExecutionProps & OuterProps, ref: Ref<Element>) {
    return useStyledComponentImpl<OuterProps>(WrappedStyledComponent, props, ref);
  }

找到 useStyledComponentImpl 的实现,这是去掉类型精简后的代码:

function useStyledComponentImpl(
  forwardedComponent,
  props,
  forwardedRef
) {
  const {
    attrs: componentAttrs,
    componentStyle,
    defaultProps,
    foldedComponentIds,
    styledComponentId,
    target,
  } = forwardedComponent;

  const elementToBeCreated = context.as || target;

  // 生成组件的类名
  const generatedClassName = useInjectedStyle(componentStyle, context);

  if (process.env.NODE_ENV !== 'production' && forwardedComponent.warnTooManyClasses) {
    forwardedComponent.warnTooManyClasses(generatedClassName);
  }

  // 合并类名
  let classString = joinStrings(foldedComponentIds, styledComponentId);
  if (generatedClassName) {
    classString += ' ' + generatedClassName;
  }
  if (context.className) {
    classString += ' ' + context.className;
  }

  propsForElement[
    // handle custom elements which React doesn't properly alias
    isTag(elementToBeCreated) &&
    !domElements.has(elementToBeCreated)
      ? 'class'
      : 'className'
  ] = classString;


  return createElement(elementToBeCreated, propsForElement);
}

我们可以看到这个函数返回一个 React 创建的元素,而这个元素的唯一类名是取决于 styledComponentIdgeneratedClassNamecontext.className来生成的。接下来我们看看这三个东西是如何生成的。

styledComponentId

我们在 createStyledComponent 中找到 styledComponentId 的生成规则

  const {
    attrs = EMPTY_ARRAY,
    componentId = generateId(options.displayName, options.parentComponentId),
    displayName = generateDisplayName(target),
  } = options;

  // 生成 styledComponentId
  const styledComponentId =
    options.displayName && options.componentId
      ? `${escape(options.displayName)}-${options.componentId}`
      : options.componentId || componentId;

一开始的时候 options 里面没有东西,会直接返回组件唯一id componentsId ,找到 generateId 方法查看是如何生成组件唯一id的,这是去掉类型精简后的代码

function generateId(
  displayName,
  parentComponentId
) {
  const name = typeof displayName !== 'string' ? 'sc' : escape(displayName);
  // Ensure that no displayName can lead to duplicate componentIds
  identifiers[name] = (identifiers[name] || 0) + 1;

  const componentId = `${name}-${generateComponentId(
    // SC_VERSION gives us isolation between multiple runtimes on the page at once
    // this is improved further with use of the babel plugin "namespace" feature
    SC_VERSION + name + identifiers[name]
  )}`;

  return parentComponentId ? `${parentComponentId}-${componentId}` : componentId;
}

我们可以看到组件的唯一ID 是 name 加上 generateComponentId()函数对 sc 的版本号 + name + identifiers[name] 结果进行 has 加密后产生的唯一值后的组合。由于刚开始的时候 displayNameparentComponentId 都没有值,所以这里面的 name 就是字符串 sc

如果我们调用 styled api一次创建了多个相同的元素,identifiers[name] 的值是根据相同元素个数递增来确保 identifiers[name] 不重复的

我们平常使用 styled api 创建样式化组件的时候,如果有观察的话,可以发现一般生成的类名都有两个 sc-xxxx xxxx ,根据这个结构特点我们发现生成的 componentsId 就是类名的第一个。而前面我们也有说到元素的唯一类名是取决于 styledComponentId 、generatedClassName来生成的,所以我猜类名的第二个应该和 generatedClassName 有很大的联系。

generatedClassName

  // 生成组件的类名
  const generatedClassName = useInjectedStyle(componentStyle, context);

我们看到入参里的 componentStyle ,在调用 createStyledComponent 的时候就实例化好并放到 WrappedStyledComponent 上了,

  const componentStyle = new ComponentStyle(
    rules,
    styledComponentId,
    isTargetStyledComp ? styledComponentTarget.componentStyle : undefined
  );
  
   WrappedStyledComponent.componentStyle = componentStyle;
   return WrappedStyledComponent;

构造函数里面的 rules 其实就是 css 代码,而 styledComponentId 就是元素类的前一个 sc-xxxx; 继续查看 useInjectedStyle 代码

function useInjectedStyle(
  componentStyle,
  resolvedAttrs
) {
  const ssc = useStyleSheetContext();

  const className = componentStyle.generateAndInjectStyles(
    resolvedAttrs,
    ssc.styleSheet,
    ssc.stylis
  );

  if (process.env.NODE_ENV !== 'production') useDebugValue(className);

  return className;
}

它又调用了 generateAndInjectStyles 方法:

  generateAndInjectStyles(
    executionContext: ExecutionContext,
    styleSheet: StyleSheet,
    stylis: Stringifier
  ) {
    let names = this.baseStyle
      ? this.baseStyle.generateAndInjectStyles(executionContext, styleSheet, stylis)
      : '';

    // force dynamic classnames if user-supplied stylis plugins are in use
    if (this.isStatic && !stylis.hash) {
      if (this.staticRulesId && styleSheet.hasNameForId(this.componentId, this.staticRulesId)) {
        names = joinStrings(names, this.staticRulesId);
      } else {
        const cssStatic = joinStringArray(
          flatten(this.rules, executionContext, styleSheet, stylis) as string[]
        );
        const name = generateName(phash(this.baseHash, cssStatic) >>> 0);

        if (!styleSheet.hasNameForId(this.componentId, name)) {
          const cssStaticFormatted = stylis(cssStatic, `.${name}`, undefined, this.componentId);
          styleSheet.insertRules(this.componentId, name, cssStaticFormatted);
        }

        names = joinStrings(names, name);
        this.staticRulesId = name;
      }
    } else {
      let dynamicHash = phash(this.baseHash, stylis.hash);
      let css = '';

      for (let i = 0; i < this.rules.length; i++) {
        const partRule = this.rules[i];

        if (typeof partRule === 'string') {
          css += partRule;

          if (process.env.NODE_ENV !== 'production') dynamicHash = phash(dynamicHash, partRule);
        } else if (partRule) {
          const partString = joinStringArray(
            flatten(partRule, executionContext, styleSheet, stylis) as string[]
          );
          dynamicHash = phash(dynamicHash, partString);
          css += partString;
        }
      }

      if (css) {
        const name = generateName(dynamicHash >>> 0);

        if (!styleSheet.hasNameForId(this.componentId, name)) {
          styleSheet.insertRules(
            this.componentId,
            name,
            stylis(css, `.${name}`, undefined, this.componentId)
          );
        }

        names = joinStrings(names, name);
      }
    }

    return names;
  }

我们发现 name 受 dynamicHash 影响,dynamicHash 通过 this.bashHash 生成, this.bashHash 是通过 phash(SEED, componentId) 实现。 SEED 是通过 hash(SC_VERSION) 哈希sc的版本号生成。这个过程进行了很多次哈希处理,只需要找到对应元素的第一个类 componentsId 插入类就可以了。

然后只需要把创建好的样式插入到 <head></head> 中就可以了,我们找到 styleSheet.insertRules 方法

  getTag() {
    return this.tag || (this.tag = makeGroupedTag(makeTag(this.options)));
  }
  
  insertRules(id, name, rules) {
    this.registerName(id, name);
    this.getTag().insertRules(getGroupForId(id), rules);
  }

因为初始化时 tag 为空,会调用 makeTag 方法:

/** Create a CSSStyleSheet-like tag depending on the environment */
export const makeTag = ({ isServer, useCSSOMInjection, target }: SheetOptions) => {
  if (isServer) {
    return new VirtualTag(target);
  } else if (useCSSOMInjection) {
    return new CSSOMTag(target);
  } else {
    return new TextTag(target);
  }
};

export const CSSOMTag = class CSSOMTag implements Tag {
  element: HTMLStyleElement;

  sheet: CSSStyleSheet;

  length: number;

  constructor(target?: HTMLElement | undefined) {
    this.element = makeStyleTag(target);

    // Avoid Edge bug where empty style elements don't create sheets
    this.element.appendChild(document.createTextNode(''));

    this.sheet = getSheet(this.element);
    this.length = 0;
  }

  insertRule(index: number, rule: string): boolean {
    try {
      this.sheet.insertRule(rule, index);
      this.length++;
      return true;
    } catch (_error) {
      return false;
    }
  }

  deleteRule(index: number): void {
    this.sheet.deleteRule(index);
    this.length--;
  }

  getRule(index: number): string {
    const rule = this.sheet.cssRules[index];

    // Avoid IE11 quirk where cssText is inaccessible on some invalid rules
    if (rule && rule.cssText) {
      return rule.cssText;
    } else {
      return '';
    }
  }
};

/** A Tag that emulates the CSSStyleSheet API but uses text nodes */
export const TextTag = class TextTag implements Tag {
  element: HTMLStyleElement;
  nodes: NodeListOf<Node>;
  length: number;

  constructor(target?: HTMLElement | undefined) {
    this.element = makeStyleTag(target);
    this.nodes = this.element.childNodes;
    this.length = 0;
  }

  insertRule(index: number, rule: string) {
    if (index <= this.length && index >= 0) {
      const node = document.createTextNode(rule);
      const refNode = this.nodes[index];
      this.element.insertBefore(node, refNode || null);
      this.length++;
      return true;
    } else {
      return false;
    }
  }

  deleteRule(index: number) {
    this.element.removeChild(this.nodes[index]);
    this.length--;
  }

  getRule(index: number) {
    if (index < this.length) {
      return this.nodes[index].textContent as string;
    } else {
      return '';
    }
  }
};

/** A completely virtual (server-side) Tag that doesn't manipulate the DOM */
export const VirtualTag = class VirtualTag implements Tag {
  rules: string[];

  length: number;

  constructor(_target?: HTMLElement | undefined) {
    this.rules = [];
    this.length = 0;
  }

  insertRule(index: number, rule: string) {
    if (index <= this.length) {
      this.rules.splice(index, 0, rule);
      this.length++;
      return true;
    } else {
      return false;
    }
  }

  deleteRule(index: number) {
    this.rules.splice(index, 1);
    this.length--;
  }

  getRule(index: number) {
    if (index < this.length) {
      return this.rules[index];
    } else {
      return '';
    }
  }
};

我们看到 textTag 中的 insertRule 方法 通过 document.createTextNode() 将css代码作为文本节点的方式插入到 this.element 中,这个 this.element 是指什么呢?通过构造函数发现 this.elementmakeStyleTag 这个方法构建的。

还记得我们最开始猜想的 style api是通过 document.createElement('style') 来创建 style 标签然后插入到 head 标签里面实现的吗?来看代码:

export const makeStyleTag = (target) => {
  const head = document.head;
  const parent = target || head;
  const style = document.createElement('style');
  // style 标签的插入位置
  const prevStyle = findLastStyleTag(parent);
  const nextSibling = prevStyle !== undefined ? prevStyle.nextSibling : null;

  // 自定义属性 data-set
  style.setAttribute(SC_ATTR, SC_ATTR_ACTIVE);
  style.setAttribute(SC_ATTR_VERSION, SC_VERSION);

  const nonce = getNonce();

  // 设置 style 标签的 nonce 属性:一种加密的随机数(一次使用的数字)
  if (nonce) style.setAttribute('nonce', nonce);

  // 在父节点里的最后一个子节点的位置插入新的 style 节点
  parent.insertBefore(style, nextSibling);

  return style;
};

makeStyleTag 通过 document.createElement('style') 创建 style 标签 然后设置了 SC_ATTRSC_ATTR_VERSION 属性。然后在父节点里的最后一个子节点的位置插入新的 style 节点。

sc 的基本核心实现源码就是这些了,源码分析暂时结束了,后面遇到其他api的疑问也可以通过查看源码的方式来解决。

写在最后

我是 AndyHu,目前暂时是一枚前端搬砖工程师。

文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注呀😊

未经许可禁止转载💌

speak less,do more.