PC端高倍屏适配方案实践

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

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

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

PC端高倍屏适配方案实践
文章图片

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

我们使用PostCSS来对CSS代码进行转化,为了灵活配置及项目使用,参考px2rem实现了一个pc端px2rem的类,然后实现一个自定义的postcss的插件
PC端高倍屏适配方案实践
文章图片

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 =https://www.it610.com/article/=="0px") { declaration.value = "https://www.it610.com/article/0"; declarations.splice(j + 1, 1); continue; } declaration.value = https://www.it610.com/article/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 = https://www.it610.com/article/self._getCalcValue("rem", declaration.value ); } } else { declaration.value = https://www.it610.com/article/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" }) ] } } } ... }

源码解析 PC端高倍屏适配方案实践
文章图片

对于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 = inputthis.root = new Root() this.current = this.root this.spaces = '' this.semicolon = false this.customProperty = falsethis.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] breakcase '; ': this.freeSemicolon(token) breakcase '}': this.end(token) breakcase 'comment': this.comment(token) breakcase 'at-word': this.atrule(token) breakcase '{': this.emptyRule(token) breakdefault: 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 = falsethis.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 valuehttps://www.it610.com/article/= '' let clean = true let next, prev let pattern = /^([#.|])?(\w)+/ifor (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 = https://www.it610.com/article/node.raws[own] if (typeof value !=='undefined') return value }let parent = node.parentif (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 = https://www.it610.com/article/this[method](root, node) } else { root.walk(i => { value = https://www.it610.com/article/i.raws[own] if (typeof value !=='undefined') return false }) } }if (typeof value =https://www.it610.com/article/=='undefined') value = https://www.it610.com/article/DEFAULT_RAW[detect]root.rawCache[detect] = value return value }beforeAfter(node, detect) { let value if (node.type ==='decl') { value = https://www.it610.com/article/this.raw(node, null,'beforeDecl') } else if (node.type === 'comment') { value = https://www.it610.com/article/this.raw(node, null,'beforeComment') } else if (detect === 'before') { value = https://www.it610.com/article/this.raw(node, null,'beforeRule') } else { value = https://www.it610.com/article/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]/ifunction tokenizer(input, options = {}) { let css = input.css.valueOf() let ignore = options.ignoreErrorslet code, next, quote, content, escape let escaped, escapePos, prev, n, currentTokenlet 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) returnlet ignoreUnclosed = opts ? opts.ignoreUnclosed : falsecode = 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
【PC端高倍屏适配方案实践】插件处理机制
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] = truefor (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 = 0for (let name in this) { if (!Object.prototype.hasOwnProperty.call(this, name)) { // istanbul ignore next continue } if (name === 'parent' || name === 'proxyCache') continue let value = https://www.it610.com/article/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 =https://www.it610.com/article/=='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.linefor (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设计

    推荐阅读