我的项目背景
随着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
// Pcx2remconst 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 "#'()/;[\\\]{}]/gconst RE_WORD_END = /[\t\n\f\r !"#'():;@[\\\]{}]|\/(?=\*)/gconst RE_BAD_BRACKET = /.[\n"'(/\\]/const RE_HEX_ESCAPE = /[\da-f]/ifunction 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)开发体验,做到降本增效,毕竟咱们是前端工程师,而不仅仅是一个前端开发者,共勉!
参考
- 术与道 挪动利用UI设计必修课
- PostCSS 是个什么鬼货色?
- 如果你不会Postcss,那么你就真的不会Postcss
- postcss源码
- 谈谈PostCSS
- 深刻PostCSS Web设计