PC端高倍屏适配方案实践

2,088 阅读8分钟

项目背景

随着PC端屏幕的发展,PC端也逐步出现了更高倍数的屏幕,相对于手机端的Retina屏,PC端也出现了多倍数适配的要求,本文主要是PC端高倍屏幕适配方案的一个实践总结,希望能给对PC端有适配高倍屏幕需求的同学有一些思路的启发和借鉴

原理分析

随着屏幕技术的发展,越来越多的PC设备配备了大尺寸高清屏幕,对于之前只需要在PC端实现的web应用就需要考虑类似手机端的移动应用相关的适配原则了,我们先来看一下手机端的高清屏幕的一个原理,对于纸媒时代来说,我们常用DPI(Dot Per Inch)即网点密度来描述打印品的打印精度,而对于手机移动设备,在iPhone4s时,苹果提出了一个所谓Retina屏幕的概念,即通过单位屏幕上像素密度的不同来实现更高密度的图像信息描述,即相同尺寸的屏幕但像素密度却不相同,通过逻辑像素与物理像素进行比例换算从而达到高清屏的显示,也就是PPI(Pixcels Per Inch)不同,如上图所示,对于同一个细节描述通过更多的像素点来进行刻画,就可以使信息呈现更多细节,画面也就更加细腻,基于此,我们来看一下手机端常见的一个适配方案

对于UI设计来说,在移动端设计过程中,我们常常需要考虑iOS和Android的设计,除了基本的交互操作的区别外,这两者的设计适配方案也是UI面试中常常被问及的问题,对于UI设计来说,我们对于同一个应用来说总希望同一面对用户触达的感知应该是基本一致,除了系统特定的交互及展示风格,应尽可能抹平平台的差异,因而一般来说我们通常会在750x1334(iOS @2x)和720X1280(Android 和720X1280(Android ) @2x)进行适配,对于PC端的Web来说只需要设计一个尺寸然后模拟实现Retina的需求即可,基于此,我们需要调研一下所需考虑的PC端适配策略

通过百度流量研究院,我们可以得出所需适配的分辨率为:

分辨率份额倍数
1920x108044.46%@1x
1366x7689.37%@1x
1536x8648.24%@1x
1440x9007.85%@1x
1600x9007.85%@1x
2560x1440--@2x
3840x2160--@4x
4096x2160--@4x

最终通过产品的调研方案,我们决定以1366x768作为主屏设计,接着我们通过栅格化的布局对各屏幕的兼容性做处理

方案选型

对于多终端分辨率的适配我们常用的方案有

方案优点缺点
媒体查询基于媒体的screen进行配置对于每套屏幕都需要写一套样式
rem+媒体查询只需要变化根字体,收敛控制范围需要对设计稿进行单位转换
vw/vh基于视窗的变化而变化需要转化设计稿单位,并且浏览器兼容性不如rem

最终考虑到兼容性,我们决定使用rem+媒体查询的方案来进行高倍屏的适配,但是如果完全基于rem进行单位改写,对于设计稿向开发视图改变需要有一定的计算量,这时,我们就想到了使用前端工程化进行统一的魔改来提升DX(Develop Experience)

案例实践

我们使用PostCSS来对CSS代码进行转化,为了灵活配置及项目使用,参考px2rem实现了一个pc端px2rem的类,然后实现一个自定义的postcss的插件

Pcx2rem

// Pcx2rem
const css = require("css");
const extend = require("extend");

const pxRegExp = /\b(\d+(\.\d+)?)px\b/;

class Pcx2rem {
  constructor(config) {
    this.config = {};
    this.config = extend(
      this.config,
      {
        baseDpr: 1, // 设备像素比
        remUnit: 10, // 自定义rem单位
        remPrecision: 6, // 精度
        forcePxComment: "px", // 只换算px
        keepComment: "no", // 是否保留单位
        ignoreEntry: null, //  忽略规则实例载体
      },
      config
    );
  }
  generateRem(cssText) {
    const self = this;
    const config = self.config;
    const astObj = css.parse(cssText);

    function processRules(rules, noDealPx) {
      for (let i = 0; i < rules.length; i++) {
        let rule = rules[i];
        if (rule.type === "media") {
          processRules(rule.rules);
          continue;
        } else if (rule.type === "keyframes") {
          processRules(rule.keyframes, true);
          continue;
        } else if (rule.type !== "rule" && rule.type !== "keyframe") {
          continue;
        }

        // 处理 px 到 rem 的转化
        let declarations = rule.declarations;
        for (let j = 0; j < declarations.length; j++) {
          let declaration = declarations[j];
          // 转化px
          if (
            declaration.type === "declaration" &&
            pxRegExp.test(declaration.value)
          ) {
            let nextDeclaration = declarations[j + 1];
            if (nextDeclaration && nextDeclaration.type === "comment") {
              if (nextDeclaration.comment.trim() === config.forcePxComment) {
                // 不转化`0px`
                if (declaration.value === "0px") {
                  declaration.value = "0";
                  declarations.splice(j + 1, 1);
                  continue;
                }
                declaration.value = self._getCalcValue(
                  "rem",
                  declaration.value
                );
                declarations.splice(j + 1, 1);
              } else if (
                nextDeclaration.comment.trim() === config.keepComment
              ) {
                declarations.splice(j + 1, 1);
              } else {
                declaration.value = self._getCalcValue(
                  "rem",
                  declaration.value
                );
              }
            } else {
              declaration.value = self._getCalcValue("rem", declaration.value);
            }
          }
        }

        if (!rules[i].declarations.length) {
          rules.splice(i, 1);
          i--;
        }
      }
    }

    processRules(astObj.stylesheet.rules);

    return css.stringify(astObj);
  }
  _getCalcValue(type, value, dpr) {
    const config = this.config;

    // 验证是否符合  忽略规则
    if (config.ignoreEntry && config.ignoreEntry.test(value)) {
      return config.ignoreEntry.getRealPx(value);
    }

    const pxGlobalRegExp = new RegExp(pxRegExp.source, "g");

    function getValue(val) {
      val = parseFloat(val.toFixed(config.remPrecision)); // 精度控制
      return val === 0 ? val : val + type;
    }

    return value.replace(pxGlobalRegExp, function ($0, $1) {
      return type === "px"
        ? getValue(($1 * dpr) / config.baseDpr)
        : getValue($1 / config.remUnit);
    });
  }
}

module.exports = Pcx2rem;

postCssPlugins

const postcss = require("postcss");
const Pcx2rem = require("./libs/Pcx2rem");
const PxIgnore = require("./libs/PxIgnore");

const postcss_pcx2rem = postcss.plugin("postcss-pcx2rem", function (options) {
  return function (css, result) {
    // 配置参数 合入 忽略策略方法
    options.ignoreEntry = new PxIgnore();
    // new 一个Pcx2rem的实例
    const pcx2rem = new Pcx2rem(options);
    const oldCssText = css.toString();
    const newCssText = pcx2rem.generateRem(oldCssText);
    result.root = postcss.parse(newCssText);
  };
});

module.exports = {
  "postcss-pcx2rem": postcss_pcx2rem,
};

vue.config.js

// vue-cli3 内嵌了postcss,只需要在对应config出进行书写即可
const {postCssPlugins} = require('./build');

module.exports = {
    ...
    css: {
        loaderOptions: {
            postcss: {
                plugins: [
                    postCssPlugins['postcss-pcx2rem']({
                        baseDpr: 1,
                        // html基础fontSize 设计稿尺寸 屏幕尺寸
                        remUnit: (10 * 1366) / 1920,
                        remPrecision: 6,
                        forcePxComment: "px",
                        keepComment: "no"
                    })
                ]
            }
        }
    }
    ...
}

源码解析

对于PostCSS而言,有很多人分析为后处理器,其本质其实是一个CSS语法转换器,或者说是编译器的前端,不同于scss/less等预处理器,其并不是将自定义语言DSL转换过来的。从上图中可以看出PostCss的处理方式是通过Parser将 CSS 解析,然后经过插件,最后Stringifier后输出新的CSS,其采用流式处理的方法,提供nextToken(),及back方法等,下面我们来逐一看一下其中的核心模块

parser

parser的实现大体可以分为两种:一种是通过写文件的方式进行ast转换,常见的如Rework analyzer;另外一种便是postcss使用的方法,词法分析后进行分词转ast,babel以及csstree等都是这种处理方案

class Parser {
  constructor(input) {
    this.input = input

    this.root = new Root()
    this.current = this.root
    this.spaces = ''
    this.semicolon = false
    this.customProperty = false

    this.createTokenizer()
    this.root.source = { input, start: { offset: 0, line: 1, column: 1 } }
  }

  createTokenizer() {
    this.tokenizer = tokenizer(this.input)
  }

  parse() {
    let token
    while (!this.tokenizer.endOfFile()) {
      token = this.tokenizer.nextToken()

      switch (token[0]) {
        case 'space':
          this.spaces += token[1]
          break

        case ';':
          this.freeSemicolon(token)
          break

        case '}':
          this.end(token)
          break

        case 'comment':
          this.comment(token)
          break

        case 'at-word':
          this.atrule(token)
          break

        case '{':
          this.emptyRule(token)
          break

        default:
          this.other(token)
          break
      }
    }
    this.endFile()
  }

  comment(token) {
    // 注释
  }

  emptyRule(token) {
    // 清空token
  }

  other(start) {
    // 其余情况处理
  }

  rule(tokens) {
    // 匹配token
  }

  decl(tokens, customProperty) {
    // 对token描述
  }

  atrule(token) {
    // 规则校验
  }

  end(token) {
    if (this.current.nodes && this.current.nodes.length) {
      this.current.raws.semicolon = this.semicolon
    }
    this.semicolon = false

    this.current.raws.after = (this.current.raws.after || '') + this.spaces
    this.spaces = ''

    if (this.current.parent) {
      this.current.source.end = this.getPosition(token[2])
      this.current = this.current.parent
    } else {
      this.unexpectedClose(token)
    }
  }

  endFile() {
    if (this.current.parent) this.unclosedBlock()
    if (this.current.nodes && this.current.nodes.length) {
      this.current.raws.semicolon = this.semicolon
    }
    this.current.raws.after = (this.current.raws.after || '') + this.spaces
  }

  init(node, offset) {
    this.current.push(node)
    node.source = {
      start: this.getPosition(offset),
      input: this.input
    }
    node.raws.before = this.spaces
    this.spaces = ''
    if (node.type !== 'comment') this.semicolon = false
  }

  raw(node, prop, tokens) {
    let token, type
    let length = tokens.length
    let value = ''
    let clean = true
    let next, prev
    let pattern = /^([#.|])?(\w)+/i

    for (let i = 0; i < length; i += 1) {
      token = tokens[i]
      type = token[0]

      if (type === 'comment' && node.type === 'rule') {
        prev = tokens[i - 1]
        next = tokens[i + 1]

        if (
          prev[0] !== 'space' &&
          next[0] !== 'space' &&
          pattern.test(prev[1]) &&
          pattern.test(next[1])
        ) {
          value += token[1]
        } else {
          clean = false
        }

        continue
      }

      if (type === 'comment' || (type === 'space' && i === length - 1)) {
        clean = false
      } else {
        value += token[1]
      }
    }
    if (!clean) {
      let raw = tokens.reduce((all, i) => all + i[1], '')
      node.raws[prop] = { value, raw }
    }
    node[prop] = value
  }
}

stringifier

用于格式化输出CSS文本

const DEFAULT_RAW = {
  colon: ': ',
  indent: '    ',
  beforeDecl: '\n',
  beforeRule: '\n',
  beforeOpen: ' ',
  beforeClose: '\n',
  beforeComment: '\n',
  after: '\n',
  emptyBody: '',
  commentLeft: ' ',
  commentRight: ' ',
  semicolon: false
}

function capitalize(str) {
  return str[0].toUpperCase() + str.slice(1)
}

class Stringifier {
  constructor(builder) {
    this.builder = builder
  }

  stringify(node, semicolon) {
    /* istanbul ignore if */
    if (!this[node.type]) {
      throw new Error(
        'Unknown AST node type ' +
          node.type +
          '. ' +
          'Maybe you need to change PostCSS stringifier.'
      )
    }
    this[node.type](node, semicolon)
  }

  raw(node, own, detect) {
    let value
    if (!detect) detect = own

    // Already had
    if (own) {
      value = node.raws[own]
      if (typeof value !== 'undefined') return value
    }

    let parent = node.parent

    if (detect === 'before') {
      // Hack for first rule in CSS
      if (!parent || (parent.type === 'root' && parent.first === node)) {
        return ''
      }

      // `root` nodes in `document` should use only their own raws
      if (parent && parent.type === 'document') {
        return ''
      }
    }

    // Floating child without parent
    if (!parent) return DEFAULT_RAW[detect]

    // Detect style by other nodes
    let root = node.root()
    if (!root.rawCache) root.rawCache = {}
    if (typeof root.rawCache[detect] !== 'undefined') {
      return root.rawCache[detect]
    }

    if (detect === 'before' || detect === 'after') {
      return this.beforeAfter(node, detect)
    } else {
      let method = 'raw' + capitalize(detect)
      if (this[method]) {
        value = this[method](root, node)
      } else {
        root.walk(i => {
          value = i.raws[own]
          if (typeof value !== 'undefined') return false
        })
      }
    }

    if (typeof value === 'undefined') value = DEFAULT_RAW[detect]

    root.rawCache[detect] = value
    return value
  }

  beforeAfter(node, detect) {
    let value
    if (node.type === 'decl') {
      value = this.raw(node, null, 'beforeDecl')
    } else if (node.type === 'comment') {
      value = this.raw(node, null, 'beforeComment')
    } else if (detect === 'before') {
      value = this.raw(node, null, 'beforeRule')
    } else {
      value = this.raw(node, null, 'beforeClose')
    }

    let buf = node.parent
    let depth = 0
    while (buf && buf.type !== 'root') {
      depth += 1
      buf = buf.parent
    }

    if (value.includes('\n')) {
      let indent = this.raw(node, null, 'indent')
      if (indent.length) {
        for (let step = 0; step < depth; step++) value += indent
      }
    }

    return value
  }
}

tokenize

postcss定义的转换格式如下

.className {
    color: #fff;
}

会被token为如下的格式

[
    ["word", ".className", 1, 1, 1, 10]
    ["space", " "]
    ["{", "{", 1, 12]
    ["space", " "]
    ["word", "color", 1, 14, 1, 18]
    [":", ":", 1, 19]
    ["space", " "]
    ["word", "#FFF" , 1, 21, 1, 23]
    [";", ";", 1, 24]
    ["space", " "]
    ["}", "}", 1, 26]
]
const SINGLE_QUOTE = "'".charCodeAt(0)
const DOUBLE_QUOTE = '"'.charCodeAt(0)
const BACKSLASH = '\\'.charCodeAt(0)
const SLASH = '/'.charCodeAt(0)
const NEWLINE = '\n'.charCodeAt(0)
const SPACE = ' '.charCodeAt(0)
const FEED = '\f'.charCodeAt(0)
const TAB = '\t'.charCodeAt(0)
const CR = '\r'.charCodeAt(0)
const OPEN_SQUARE = '['.charCodeAt(0)
const CLOSE_SQUARE = ']'.charCodeAt(0)
const OPEN_PARENTHESES = '('.charCodeAt(0)
const CLOSE_PARENTHESES = ')'.charCodeAt(0)
const OPEN_CURLY = '{'.charCodeAt(0)
const CLOSE_CURLY = '}'.charCodeAt(0)
const SEMICOLON = ';'.charCodeAt(0)
const ASTERISK = '*'.charCodeAt(0)
const COLON = ':'.charCodeAt(0)
const AT = '@'.charCodeAt(0)

const RE_AT_END = /[\t\n\f\r "#'()/;[\\\]{}]/g
const RE_WORD_END = /[\t\n\f\r !"#'():;@[\\\]{}]|\/(?=\*)/g
const RE_BAD_BRACKET = /.[\n"'(/\\]/
const RE_HEX_ESCAPE = /[\da-f]/i

function tokenizer(input, options = {}) {
  let css = input.css.valueOf()
  let ignore = options.ignoreErrors

  let code, next, quote, content, escape
  let escaped, escapePos, prev, n, currentToken

  let length = css.length
  let pos = 0
  let buffer = []
  let returned = []

  function position() {
    return pos
  }

  function unclosed(what) {
    throw input.error('Unclosed ' + what, pos)
  }

  function endOfFile() {
    return returned.length === 0 && pos >= length
  }

  function nextToken(opts) {
    if (returned.length) return returned.pop()
    if (pos >= length) return

    let ignoreUnclosed = opts ? opts.ignoreUnclosed : false

    code = css.charCodeAt(pos)

    switch (code) {
      case NEWLINE:
      case SPACE:
      case TAB:
      case CR:
      case FEED: {
        next = pos
        do {
          next += 1
          code = css.charCodeAt(next)
        } while (
          code === SPACE ||
          code === NEWLINE ||
          code === TAB ||
          code === CR ||
          code === FEED
        )

        currentToken = ['space', css.slice(pos, next)]
        pos = next - 1
        break
      }

      case OPEN_SQUARE:
      case CLOSE_SQUARE:
      case OPEN_CURLY:
      case CLOSE_CURLY:
      case COLON:
      case SEMICOLON:
      case CLOSE_PARENTHESES: {
        let controlChar = String.fromCharCode(code)
        currentToken = [controlChar, controlChar, pos]
        break
      }

      case OPEN_PARENTHESES: {
        prev = buffer.length ? buffer.pop()[1] : ''
        n = css.charCodeAt(pos + 1)
        if (
          prev === 'url' &&
          n !== SINGLE_QUOTE &&
          n !== DOUBLE_QUOTE &&
          n !== SPACE &&
          n !== NEWLINE &&
          n !== TAB &&
          n !== FEED &&
          n !== CR
        ) {
          next = pos
          do {
            escaped = false
            next = css.indexOf(')', next + 1)
            if (next === -1) {
              if (ignore || ignoreUnclosed) {
                next = pos
                break
              } else {
                unclosed('bracket')
              }
            }
            escapePos = next
            while (css.charCodeAt(escapePos - 1) === BACKSLASH) {
              escapePos -= 1
              escaped = !escaped
            }
          } while (escaped)

          currentToken = ['brackets', css.slice(pos, next + 1), pos, next]

          pos = next
        } else {
          next = css.indexOf(')', pos + 1)
          content = css.slice(pos, next + 1)

          if (next === -1 || RE_BAD_BRACKET.test(content)) {
            currentToken = ['(', '(', pos]
          } else {
            currentToken = ['brackets', content, pos, next]
            pos = next
          }
        }

        break
      }

      case SINGLE_QUOTE:
      case DOUBLE_QUOTE: {
        quote = code === SINGLE_QUOTE ? "'" : '"'
        next = pos
        do {
          escaped = false
          next = css.indexOf(quote, next + 1)
          if (next === -1) {
            if (ignore || ignoreUnclosed) {
              next = pos + 1
              break
            } else {
              unclosed('string')
            }
          }
          escapePos = next
          while (css.charCodeAt(escapePos - 1) === BACKSLASH) {
            escapePos -= 1
            escaped = !escaped
          }
        } while (escaped)

        currentToken = ['string', css.slice(pos, next + 1), pos, next]
        pos = next
        break
      }

      case AT: {
        RE_AT_END.lastIndex = pos + 1
        RE_AT_END.test(css)
        if (RE_AT_END.lastIndex === 0) {
          next = css.length - 1
        } else {
          next = RE_AT_END.lastIndex - 2
        }

        currentToken = ['at-word', css.slice(pos, next + 1), pos, next]

        pos = next
        break
      }

      case BACKSLASH: {
        next = pos
        escape = true
        while (css.charCodeAt(next + 1) === BACKSLASH) {
          next += 1
          escape = !escape
        }
        code = css.charCodeAt(next + 1)
        if (
          escape &&
          code !== SLASH &&
          code !== SPACE &&
          code !== NEWLINE &&
          code !== TAB &&
          code !== CR &&
          code !== FEED
        ) {
          next += 1
          if (RE_HEX_ESCAPE.test(css.charAt(next))) {
            while (RE_HEX_ESCAPE.test(css.charAt(next + 1))) {
              next += 1
            }
            if (css.charCodeAt(next + 1) === SPACE) {
              next += 1
            }
          }
        }

        currentToken = ['word', css.slice(pos, next + 1), pos, next]

        pos = next
        break
      }

      default: {
        if (code === SLASH && css.charCodeAt(pos + 1) === ASTERISK) {
          next = css.indexOf('*/', pos + 2) + 1
          if (next === 0) {
            if (ignore || ignoreUnclosed) {
              next = css.length
            } else {
              unclosed('comment')
            }
          }

          currentToken = ['comment', css.slice(pos, next + 1), pos, next]
          pos = next
        } else {
          RE_WORD_END.lastIndex = pos + 1
          RE_WORD_END.test(css)
          if (RE_WORD_END.lastIndex === 0) {
            next = css.length - 1
          } else {
            next = RE_WORD_END.lastIndex - 2
          }

          currentToken = ['word', css.slice(pos, next + 1), pos, next]
          buffer.push(currentToken)
          pos = next
        }

        break
      }
    }

    pos++
    return currentToken
  }

  function back(token) {
    returned.push(token)
  }

  return {
    back,
    nextToken,
    endOfFile,
    position
  }
}

processor

插件处理机制

class Processor {
    constructor(plugins = []) {
        this.plugins = this.normalize(plugins)
    }
    use(plugin) {

    }
    process(css, opts = {}) {

    }
    normalize(plugins) {
        // 格式化插件
    }
}

node

对转换的ast节点的处理

class Node {
  constructor(defaults = {}) {
    this.raws = {}
    this[isClean] = false
    this[my] = true

    for (let name in defaults) {
      if (name === 'nodes') {
        this.nodes = []
        for (let node of defaults[name]) {
          if (typeof node.clone === 'function') {
            this.append(node.clone())
          } else {
            this.append(node)
          }
        }
      } else {
        this[name] = defaults[name]
      }
    }
  }

  remove() {
    if (this.parent) {
      this.parent.removeChild(this)
    }
    this.parent = undefined
    return this
  }

  toString(stringifier = stringify) {
    if (stringifier.stringify) stringifier = stringifier.stringify
    let result = ''
    stringifier(this, i => {
      result += i
    })
    return result
  }

  assign(overrides = {}) {
    for (let name in overrides) {
      this[name] = overrides[name]
    }
    return this
  }

  clone(overrides = {}) {
    let cloned = cloneNode(this)
    for (let name in overrides) {
      cloned[name] = overrides[name]
    }
    return cloned
  }

  cloneBefore(overrides = {}) {
    let cloned = this.clone(overrides)
    this.parent.insertBefore(this, cloned)
    return cloned
  }

  cloneAfter(overrides = {}) {
    let cloned = this.clone(overrides)
    this.parent.insertAfter(this, cloned)
    return cloned
  }

  replaceWith(...nodes) {
    if (this.parent) {
      let bookmark = this
      let foundSelf = false
      for (let node of nodes) {
        if (node === this) {
          foundSelf = true
        } else if (foundSelf) {
          this.parent.insertAfter(bookmark, node)
          bookmark = node
        } else {
          this.parent.insertBefore(bookmark, node)
        }
      }

      if (!foundSelf) {
        this.remove()
      }
    }

    return this
  }

  next() {
    if (!this.parent) return undefined
    let index = this.parent.index(this)
    return this.parent.nodes[index + 1]
  }

  prev() {
    if (!this.parent) return undefined
    let index = this.parent.index(this)
    return this.parent.nodes[index - 1]
  }

  before(add) {
    this.parent.insertBefore(this, add)
    return this
  }

  after(add) {
    this.parent.insertAfter(this, add)
    return this
  }

  root() {
    let result = this
    while (result.parent && result.parent.type !== 'document') {
      result = result.parent
    }
    return result
  }

  raw(prop, defaultType) {
    let str = new Stringifier()
    return str.raw(this, prop, defaultType)
  }

  cleanRaws(keepBetween) {
    delete this.raws.before
    delete this.raws.after
    if (!keepBetween) delete this.raws.between
  }

  toJSON(_, inputs) {
    let fixed = {}
    let emitInputs = inputs == null
    inputs = inputs || new Map()
    let inputsNextIndex = 0

    for (let name in this) {
      if (!Object.prototype.hasOwnProperty.call(this, name)) {
        // istanbul ignore next
        continue
      }
      if (name === 'parent' || name === 'proxyCache') continue
      let value = this[name]

      if (Array.isArray(value)) {
        fixed[name] = value.map(i => {
          if (typeof i === 'object' && i.toJSON) {
            return i.toJSON(null, inputs)
          } else {
            return i
          }
        })
      } else if (typeof value === 'object' && value.toJSON) {
        fixed[name] = value.toJSON(null, inputs)
      } else if (name === 'source') {
        let inputId = inputs.get(value.input)
        if (inputId == null) {
          inputId = inputsNextIndex
          inputs.set(value.input, inputsNextIndex)
          inputsNextIndex++
        }
        fixed[name] = {
          inputId,
          start: value.start,
          end: value.end
        }
      } else {
        fixed[name] = value
      }
    }

    if (emitInputs) {
      fixed.inputs = [...inputs.keys()].map(input => input.toJSON())
    }

    return fixed
  }

  positionInside(index) {
    let string = this.toString()
    let column = this.source.start.column
    let line = this.source.start.line

    for (let i = 0; i < index; i++) {
      if (string[i] === '\n') {
        column = 1
        line += 1
      } else {
        column += 1
      }
    }

    return { line, column }
  }

  positionBy(opts) {
    let pos = this.source.start
    if (opts.index) {
      pos = this.positionInside(opts.index)
    } else if (opts.word) {
      let index = this.toString().indexOf(opts.word)
      if (index !== -1) pos = this.positionInside(index)
    }
    return pos
  }

  getProxyProcessor() {
    return {
      set(node, prop, value) {
        if (node[prop] === value) return true
        node[prop] = value
        if (
          prop === 'prop' ||
          prop === 'value' ||
          prop === 'name' ||
          prop === 'params' ||
          prop === 'important' ||
          prop === 'text'
        ) {
          node.markDirty()
        }
        return true
      },

      get(node, prop) {
        if (prop === 'proxyOf') {
          return node
        } else if (prop === 'root') {
          return () => node.root().toProxy()
        } else {
          return node[prop]
        }
      }
    }
  }

  toProxy() {
    if (!this.proxyCache) {
      this.proxyCache = new Proxy(this, this.getProxyProcessor())
    }
    return this.proxyCache
  }

  addToError(error) {
    error.postcssNode = this
    if (error.stack && this.source && /\n\s{4}at /.test(error.stack)) {
      let s = this.source
      error.stack = error.stack.replace(
        /\n\s{4}at /,
        `$&${s.input.from}:${s.start.line}:${s.start.column}$&`
      )
    }
    return error
  }

  markDirty() {
    if (this[isClean]) {
      this[isClean] = false
      let next = this
      while ((next = next.parent)) {
        next[isClean] = false
      }
    }
  }

  get proxyOf() {
    return this
  }
}

总结

对于UI设计稿的高保真还原是作为前端工程师最最基本的基本功,但对于现代前端而言,我们不只要考虑到解决方案,还要具备工程化的思维,提升DX(Develop Experience)开发体验,做到降本增效,毕竟我们是前端工程师,而不仅仅是一个前端开发者,共勉!

参考