專案背景
隨著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 @2x)進行適配,對於PC端的Web來說只需要設計一個尺寸然後模擬實現Retina的需求即可,基於此,我們需要調研一下所需考慮的PC端適配策略
通過百度流量研究院,我們可以得出所需適配的解析度為:
解析度 | 份額 | 倍數 |
---|---|---|
1920x1080 | 44.46% | @1x |
1366x768 | 9.37% | @1x |
1536x864 | 8.24% | @1x |
1440x900 | 7.85% | @1x |
1600x900 | 7.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)開發體驗,做到降本增效,畢竟我們是前端工程師,而不僅僅是一個前端開發者,共勉!