Skip to content

写一个插件获取元素的样式

  • 其背景是可以获取好看网站的样式可直接复制
  • 对css scan类似插件原理探索
price

看看,卖的挺贵的,买不起又无法不白嫖,只好自己动手了

第一版的对比效果 diff

百度输入框的效果

baidu

主要apigetComputedStyle,getPropertyValue

js
var style = window.getComputedStyle(document.querySelector('.RCK'));

// 获取 style 对象的所有属性名
var properties = Object.keys(style);

// 迭代 properties 数组
for (var i = 0; i < properties.length; i++) {
  // 获取属性名
  var property = properties[i];

  // 获取属性的值
  var value = style.getPropertyValue(property);
  if(value&&value!='none'&&value!='auto'&&value!='normal'){
      console.log(property + ": " + value);
  }
}

基本思路

js
const currentEl = ref<Element>()
function highlightElements(event: MouseEvent) {
  // 获取鼠标指针的位置
  const x = event.clientX
  const y = event.clientY

  // 获取鼠标指针处的元素
  const element: Element | null = document.elementFromPoint(x, y)

  // 如果没有找到元素,就返回
  if (!element)
    return
  currentEl.value = element
}
// currentEl改变是触发
watch(currentEl as any, (el: Element) => {
  addGuidelines(el)
})

let styleTable: any = ''
function getComputedStyle(el: Element) {
  const style = window.getComputedStyle(el)
  // 获取元素的伪类
  const pseudo = window.getComputedStyle(el, ':before')
  console.log(pseudo)
  // 获取当前元素的第一个class
  const className = el.className.split(' ')[0] || el.tagName

  // 获取 style 对象的所有属性名
  const properties = Object.keys(style).filter((key) => {
    // 排除不需要的基础属性
    const ignore = [
      'r',
      'x',
      'y',
      'widows',
      'orphans',
      'order',
      'offset',
      'inset',
      'hyphens',
      'fill',
      'direction',
      'cx',
      'cy',
    ]

    // 定义一个map,用来存储需要排除的属性
    const ignoreMap = [
      {
        key: 'position',
        value: 'static',
        ignore: [
          'position',
          'top',
          'left',
          'right',
          'bottom',
          'z-index',
        ],
      },
      {
        key: 'animation',
        value: 'none 0s ease 0s 1 normal none running',
        ignore: [
          'animation',
          'animation-name',
          'animation-duration',
          'animation-timing-function',
          'animation-delay',
          'animation-iteration-count',
          'animation-direction',
          'animation-fill-mode',
          'animation-play-state',
        ],
      },
      {
        key: 'zoom',
        value: '1',
        ignore: [
          'zoom',
        ],
      },
      {
        key: 'padding',
        value: '0px',
        ignore: [
          'padding',
          'padding-top',
          'padding-right',
          'padding-bottom',
          'padding-left',
        ],
      },
      {
        key: 'columns',
        value: 'auto auto',
        ignore: [
          'columns',
          'column-count',
          'column-width',
        ],
      },
      {
        key: 'outline',
        value: 'rgb(0, 0, 0) none 0px',
        ignore: [
          'outline',
          'outline-color',
          'outline-style',
          'outline-width',
        ],
      },
      {
        key: 'font',
        value: (val: string) => {
          return val.includes('0px / 0px')
        },
        ignore: [
          'font',
          'font-family',
          'font-size',
          'font-size-adjust',
          'font-stretch',
          'font-style',
          'font-variant',
          'font-weight',
          'line-height',
        ],
      },
    ]

    let ignoreList: any = ignore

    // 判断是否需要排除的属性ignoreMap
    ignoreMap.forEach((item) => {
      if (typeof item.value === 'function') {
        if (item.value(style.getPropertyValue(item.key)))
          ignoreList = ignoreList.concat(item.ignore)
      }
      else {
        if (style.getPropertyValue(item.key) === item.value)
          ignoreList = ignoreList.concat(item.ignore)
      }
    })

    // 排除不需要的属性
    if (ignoreList.some(i => key.includes(i)))
      return false
    return true
  })

  // 迭代 properties 数组
  let content = ''

  for (let i = 0; i < properties.length; i++) {
    // 获取属性名
    const property = properties[i]
    // 获取属性的值
    const value = style.getPropertyValue(property)
    if (value && value !== 'none' && value !== 'auto' && value !== 'normal') {
      // 存储样式 字符串拼接
      content += `${property}: ${value};`
    }
  }
  const css = `.${className} {${content}}`
  styleTable += css
  // 递归获取子元素的样式
  if (el.children.length > 0) {
    for (let i = 0; i < el.children.length; i++) {
      const child = el.children[i]
      getComputedStyle(child)
    }
  }
}
const style = ref({
  top: '0px',
  left: '0px',
  right: '0px',
  bottom: '0px',
  width: '0px',
  height: '0px',
})
const styleTop = computed(() => {
  return {
    top: `${style.value.top}`,
    left: '0',
    width: '100%',
    height: '1px',
    borderTop: '1px dashed red',
  }
})
const styleBottom = computed(() => {
  return {
    top: `${style.value.bottom}`,
    left: '0',
    width: '100%',
    height: '1px',
    borderTop: '1px dashed red',
  }
})
const styleLeft = computed(() => {
  return {
    top: '0px',
    left: `${style.value.left}`,
    width: '1px',
    height: '100%',
    borderLeft: '1px dashed red',
  }
})
const styleRight = computed(() => {
  return {
    top: '0px',
    left: `${style.value.right}`,
    width: '1px',
    height: '100%',
    borderLeft: '1px dashed red',
  }
})
function addGuidelines(element: Element) {
  // 获取元素的位置和大小
  const rect = element.getBoundingClientRect()
  const left = rect.left + window.pageXOffset
  const top = rect.top + window.pageYOffset
  const right = rect.right + window.pageXOffset
  const bottom = rect.bottom + window.pageYOffset
  const width = rect.width
  const height = rect.height

  style.value = {
    top: `${top}px`,
    left: `${left}px`,
    right: `${right}px`,
    bottom: `${bottom}px`,
    width: `${width}px`,
    height: `${height}px`,
  }
}
onMounted(() => {
  document.addEventListener('mousemove', highlightElements)
  document.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    // 计算元素的最终样式
    getComputedStyle(currentEl.value as Element)
    // styleTable转样式
    console.log(styleTable)

    // currentEl.value转字符串
    const html = currentEl.value?.parentNode?.innerHTML
    const t = `<template>${html}</template> <style>
      ${styleTable}
    </style>`
    // styleStr2写到剪贴板
    navigator.clipboard
      .writeText(t)
  })
})

上面是一个基本思路,getComputedStyle第二个参数可以获取伪类,需要分场景排除默认值,上面代码还有很大的优化空间

直接通过className去匹配外部样式表

js
onMounted(() => {
setTimeout(() => {
    // ------------------------------------------------
    const head = document.head
    for (let i = 0; i < document.styleSheets.length; i++) {
      const styleSheet = document.styleSheets[i]
      if (styleSheet.href) {
        console.log(styleSheet.href)
        // 将 styleSheet.href 写入到 head
        document.querySelector(`link[href="${styleSheet.href}"]`)?.remove()
        const link = document.createElement('link')
        link.setAttribute('rel', 'stylesheet')
        link.setAttribute('href', styleSheet.href)
        link.setAttribute('crossorigin', 'anonymous')
        head.appendChild(link)
      }
    }
  }, 200)
  document.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    for (let i = 0; i < document.styleSheets.length; i++) {
      const styleSheet = document.styleSheets[i]
      if (styleSheet.href) {
        console.log(styleSheet.cssRules)
        for (let j = 0; j < styleSheet.cssRules.length; j++) {
          const rule = styleSheet.cssRules[j]
          console.log(`${rule.selectorText}: ${rule.style.cssText}`)
        }
      }
    }
  })
})

因为外部样式表可能在CDN上,js访问css时chrome对其有跨域限制 相关问题 https://stackoverflow.com/questions/49993633/uncaught-domexception-failed-to-read-the-cssrules-property

https://stackoverflow.com/questions/71327187/cannot-access-cssrules-for-stylesheet-cors/71327476#71327476

通过以上方式偶尔还是会存在跨域问题,在强制刷新后又可以了,目前没找到解决办法,不过上方提供的下载样式到自己的服务器再获取本人倒没尝试

拿到样式表后通过规则去匹配className然后获取cssText通过getComputedStyle对比得到最终的元素样式

通过浏览器插件 拦截请求支持跨域,完美解决上面遗留问题

相关核心代码

js
const DEFAULT_METHODS = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH', 'PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'LOCK']

const prefs = {
  'enabled': true,
  'overwrite-origin': true,
  'methods': DEFAULT_METHODS,
  'remove-x-frame': true,
  'allow-credentials': true,
  'allow-headers-value': '*',
  'allow-origin-value': '*',
  'expose-headers-value': '*',
  'allow-headers': true,
  'unblock-initiator': true,
}

const redirects = {}

const cors = {}

cors.onBeforeRedirect = (d) => {
  if (d.type === 'main_frame')
    return

  redirects[d.tabId] = redirects[d.tabId] || {}
  redirects[d.tabId][d.requestId] = true
}

cors.onHeadersReceived = (d) => {
  if (d.type === 'main_frame')
    return

  const { initiator, originUrl, responseHeaders, requestId, tabId } = d
  let origin = ''

  const redirect = redirects[tabId] ? redirects[tabId][requestId] : false
  if (prefs['unblock-initiator'] && redirect !== true) {
    try {
      const o = new URL(initiator || originUrl)
      origin = o.origin
    }
    catch (e) {
      console.warn('cannot extract origin for initiator', initiator)
    }
  }
  else {
    origin = '*'
  }
  if (redirects[tabId])
    delete redirects[tabId][requestId]

  if (prefs['overwrite-origin'] === true) {
    const o = responseHeaders.find(({ name }) => name.toLowerCase() === 'access-control-allow-origin')

    if (o) {
      if (o.value !== '*')
        o.value = origin || prefs['allow-origin-value']
    }
    else {
      responseHeaders.push({
        name: 'Access-Control-Allow-Origin',
        value: origin || prefs['allow-origin-value'],
      })
    }
  }
  if (prefs.methods.length > 3) { // GET, POST, HEAD are mandatory
    const o = responseHeaders.find(({ name }) => name.toLowerCase() === 'access-control-allow-methods')
    if (o) {
      // only append methods that are not in the supported list
      o.value = [...new Set([...prefs.methods, ...o.value.split(/\s*,\s*/).filter((a) => {
        return !DEFAULT_METHODS.includes(a)
      })])].join(', ')
    }
    else {
      responseHeaders.push({
        name: 'Access-Control-Allow-Methods',
        value: prefs.methods.join(', '),
      })
    }
  }
  // The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'
  // when the request's credentials mode is 'include'.
  if (prefs['allow-credentials'] === true) {
    const o = responseHeaders.find(({ name }) => name.toLowerCase() === 'access-control-allow-origin')
    if (!o || o.value !== '*') {
      const o = responseHeaders.find(({ name }) => name.toLowerCase() === 'access-control-allow-credentials')
      if (o) {
        o.value = 'true'
      }
      else {
        responseHeaders.push({
          name: 'Access-Control-Allow-Credentials',
          value: 'true',
        })
      }
    }
  }
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
  if (prefs['allow-headers'] === true) {
    const o = responseHeaders.find(({ name }) => name.toLowerCase() === 'access-control-allow-headers')
    if (o) {
      o.value = prefs['allow-headers-value']
    }
    else {
      responseHeaders.push({
        name: 'Access-Control-Allow-Headers',
        value: prefs['allow-headers-value'],
      })
    }
  }
  if (prefs['allow-headers'] === true) {
    const o = responseHeaders.find(({ name }) => name.toLowerCase() === 'access-control-expose-headers')
    if (!o) {
      responseHeaders.push({
        name: 'Access-Control-Expose-Headers',
        value: prefs['expose-headers-value'],
      })
    }
  }
  if (prefs['remove-x-frame'] === true) {
    const i = responseHeaders.findIndex(({ name }) => name.toLowerCase() === 'x-frame-options')
    if (i !== -1)
      responseHeaders.splice(i, 1)
  }
  return { responseHeaders }
}
cors.install = () => {
  cors.remove()
  const extra = ['blocking', 'responseHeaders']
  if (/Firefox/.test(navigator.userAgent) === false)
    extra.push('extraHeaders')

  chrome.webRequest.onHeadersReceived.addListener(cors.onHeadersReceived, {
    urls: ['<all_urls>'],
  }, extra)
  chrome.webRequest.onBeforeRedirect.addListener(cors.onBeforeRedirect, {
    urls: ['<all_urls>'],
  })
}
cors.remove = () => {
  chrome.webRequest.onHeadersReceived.removeListener(cors.onHeadersReceived)
  chrome.webRequest.onBeforeRedirect.removeListener(cors.onBeforeRedirect)
}

cors.onCommand = () => {
  cors.install()

  chrome.browserAction.setTitle({
    title: prefs.enabled ? 'Access-Control-Allow-Origin is unblocked' : 'Disabled: Default server behavior',
  })
}
cors.onCommand()

如上点击的时候就能输出样式表了

css
.sui-wraper: text-align: left;
index.global.js:9 .sui-draggable-proxy: visibility: hidden;
index.global.js:9 .sui-draggsort-collapse: visibility: hidden; width: 100%;
index.global.js:9 .sui-draggsort-holder: border: 1px dashed rgb(204, 204, 204); position: absolute;
index.global.js:9 .sui-dialog: position: absolute; z-index: 199999; width: 390px; border: 1px solid rgb(216, 216, 216); box-shadow: rgba(0, 0, 0, 0.07) 1px 2px 1px 0px; background: rgb(255, 255, 255); text-align: left;
index.global.js:9 .sui-dialog-body: min-height: 30px; padding: 10px; color: rgb(102, 102, 102); font-size: 13px;
index.global.js:9 .sui-dialog-close, .sui-dialog-tips em: background: url("../img/dialog_abeed671.png") left -218px no-repeat;

剩余的就是该如何正确的匹配className并获取他的值

如何匹配样式

主要的api是Element.prototype.webkitMatchesSelectorElement.prototype.matches

相关的核心代码

js
function computedStyle(element: Element) {
  const cssRules: CSSRule[] = []
  Array.from(document.styleSheets).forEach((v) => {
    Array.from(v.cssRules).forEach((v) => {
      cssRules.push(v)
    })
  })
  // 通过当前元素从所有样式规则中匹配到对应的样式规则
  const cssRules2 = cssRules.filter((v) => {
    const selectorText = v.selectorText
    if (selectorText) {
      const selectorTexts = selectorText.split(',')
      return selectorTexts.some((v: any) => {
        return currentEl.value?.matches(v)
      })
    }
    return false
  })
  // cssRules2 css规则转样式字符串
  const _cssText = cssRules2.map(v => v.cssText).join('')

  const beautycss = cssbeautify(_cssText)
  console.log(beautycss)
  cssText.value = beautycss
  csshtml.value = Prism.highlight(beautycss, Prism.languages.css, 'css')
}

我们以百度输入框为例取出的css如下

css
blockquote, body, button, dd, dl, dt, fieldset, form, h1, h2, h3, h4, h5, h6, hr, input, legend, li, ol, p, pre, td, textarea, th, ul {
    margin: 0px;
    padding: 0px;
}

body, button, input, select, textarea {
    font-size: 12px;
    font-family: Arial, sans-serif;
}

button, input, select, textarea {
    font-size: 100%;
}

#head_wrapper input {
    outline: 0px;
    appearance: none;
}

#head_wrapper .s_ipt_wr:hover #kw {
    border-color: rgb(167, 170, 181);
}

#head_wrapper #kw {
    width: 512px;
    height: 16px;
    padding: 12px 16px;
    font-size: 16px;
    margin: 0px;
    vertical-align: top;
    outline: 0px;
    box-shadow: none;
    border-radius: 10px 0px 0px 10px;
    border: 2px solid rgb(196, 199, 206);
    background: rgb(255, 255, 255);
    color: rgb(34, 34, 34);
    overflow: hidden;
    box-sizing: content-box;
    -webkit-tap-highlight-color: transparent;
}

#head_wrapper #kw:focus {
    opacity: 1;
    border-color: rgb(78, 110, 242) !important;
}

#head_wrapper .soutu-env-mac .has-voice #kw {
    width: 411px;
    padding-right: 119px;
}

#head_wrapper .soutu-env-mac #kw, #head_wrapper .soutu-env-nomac #kw {
    width: 443px;
    padding-right: 87px;
}

.s-skin-hasbg #head_wrapper #form #kw {
    border-color: rgb(69, 105, 255);
}

.s-skin-hasbg #head_wrapper #form #kw:hover {
    border-color: rgb(69, 105, 255);
    opacity: 1;
}

.s-skin-hasbg #head_wrapper #form #kw:focus {
    opacity: 1;
    border-color: rgb(69, 105, 255) !important;
}

发现他会把所有相关的都取出来,接下来还要再次确定哪些是真正有用的

值里是否有动画,是否包含伪类

媒体查询等

未完善项目地址 https://github.com/artiely/chrome-ext-css-scan

后续计划todoList

  • [x] 支持外链样式
  • [x] 支持外站CDN样式
  • [x] 支持行内样式
  • [x] 支持动画帧
  • [ ] 完美支持伪类
  • [x] 支持媒体查询
  • [ ] 丰富操作界面新增复制关闭等按钮
  • [ ] css变量+媒体查询+主题变量
  • [ ] 继承的样式
  • [ ] 获取html结构
  • [ ] 匹配html的嵌套样式+子元素及样式
  • [ ] 代码直接一键到codepen预览
  • [ ] 展示源码+代码来源统计
  • [ ] 代码可编辑
  • [ ] 新增配置选项
  • [ ] 新增快捷键
  • [ ] 新增样式转原子className(如: tailwind.css/uno.css)
  • [ ] 获取iframe里css