px2rem/px2vw响应式方案探究

响应式是一个很基础的概念,大家写前端的一定都很熟了,就不再过多赘述。这里主要是想整理一下如何实现从设计稿的 px 自动转换到 rem/vw 这种单位。

引例

这是一段简单的实现响应式的 demo,非常清晰的展示了如何根据设计稿上的元素单位,然后计算出一个最终的 rem 单位。我们要做的就是如何把这一部分自动化。

1
2
// entry-task-1/src/index.tsx
setRemUnit();

入口文件调用方法。

1
2
3
4
5
6
7
8
// entry-task-1/src/helpers/index.ts
export const setRemUnit: () => void = () => {
//将页面的十分之一作为1rem。
const docEl = document.documentElement;
const rem = (docEl.clientWidth / 320) * 100;
docEl.style.fontSize = rem + "px";
return rem;
};

然后通过 JS 获取到当前设备的宽度,除以设计稿宽度 320,乘了一个 100。这样设置htmlfont-size主要是为了后面写 CSS 的时候,直接根据设计稿的元素尺寸除以 100 就可以了。

1
2
3
4
5
6
7
8
9
10
// entry-task-1/src/container/List/Styled/Header.tsx
export const HeaderWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.16rem;
background-color: ${Primary1};
height: 40px;
font-size: 0.18rem;
`;

比如这里的padding: 0 0.16rem就是还原了设计稿中的padding: 0 16px。如果在不同的设备上展示,自然会设置不同的根元素font-size,然后根据 rem 的特性自然能实现一个简单的响应式。

rem 和 vw

rem 我们大家都比较熟悉了,是指相对于根元素的字体大小的单位,但是似乎有一个问题。举个例子:如果窗口 resize,你还要做额外的逻辑处理来监听这部分,究其原因看起来本身就不太适合做响应式这件事情,因为中间需要根元素的font-size转一层才能最终取到结果,

rem 方案应该是前几年手淘的 winter 推广的lib-flexible弹性布局方案,里面用的 rem 来做弹性布局这件事推起来的。前几年手淘的lib-flexible是一些历史兼容原因产生的,目前viewport单位的兼容性已经很好了,在我看来已经可以替换掉了。那么我们可以用vw来替换掉 rem 单位。

实现自动转换

那么上面阐述完响应式的基本原理之后,可以发现手动计算的过程还是太过于繁琐。我么可以尝试将这一部分自动化,自动计算出对应的结果。我们最终的目标是实现下面的这一个过程。

自动转换的方案还是有挺多的,可以使用 gulp/webpack 等打包工具,引入一些 px2vw/px2rem 等方案即可实现这一部分的转换。里面一般都是使用 PostCSS 来对 CSS 文件内容进行解析,vue 也提供了vue-loader来针对单文件提供 CSS 输出。PostCSS 的最终产物都是一个 AST 结构,然后就可以根据这个 AST 结构做进一步处理。

但是针对使用 styled-components 技术栈的项目会有所不同,因为根据编译出的最终产物来看,styled-components 编译出的最终产物是 js 后缀的文件,这部分往往不能使用一些现有的库来进行处理,在 github 上找了一下。搜了一个babel-plugin-styled-components-px2vw 的 babel 插件,star 数量很少只有 9 个。

我们来看一下这个 babel 插件的具体的实现思路。

主流程

主流程如上图所示,我们现在只是先看一下大致的实现流程,后面会分析四个部分的实现逻辑。

babel 部分

babel 解析

对于 babel,我们大家肯定都不陌生,babel 的用途非常的广泛,现在的每一个项目几乎都离不开 babel 的处理。最常见的比如 ES6 -> ES5 等等。babel 提供的能力也非常强大,能把提供的代码解析生成 AST 结构的语法树。然后我们就可以根据遍历出来的这棵语法树修改对应的节点。其实就是大家非常熟悉的编译原理的部分,词法分析,语法分析,生成代码等等的一个过程,编译原理还是比较复杂的一个领域,这里了解不深就不做展开。

1
2
3
4
5
export const StyledButton = styled.button`
width: 120px;
height: 32px;
font-size: 14px;
`;

我们先来看上面这一段代码转换成 AST 之后的结果。

是一棵很长的树,可以看到最关键的红框部分,就是我们想要去处理的 CSS 部分逻辑。styled-components 利用了 ES6 的 标签模板字符串,因此这里解析出来的type就是taggedTemplateExpression,那么我们的 babel 插件就可以使用 babel 提供的遍历整个树的方法去拿到对应的节点,并且取到对应的数据。我们来看一看对应的代码实现。

babel 插件代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
export default declare((api: ConfigAPI, options?: IConfiguration) => {
// ...
const templateVisitor: Visitor = {
TemplateElement(path: NodePath<TemplateElement>) {
const it = path.node;
if (it.value && it.value.raw) {
it.value.raw = replace(it.value.raw);
}
if (it.value && it.value.cooked) {
it.value.cooked = replace(it.value.cooked);
}
},
// ...
};

const visitor: Visitor = {
Program: {
exit(programPath: NodePath<Program>) {
// ...
},
enter(programPath: NodePath<Program>, pluginPass: Record<string, any>) {
// ...
programPath.traverse({
// 模板字符串
TaggedTemplateExpression(path: NodePath<TaggedTemplateExpression>) {
if (isStyledTagged(path.node)) {
path.traverse(templateVisitor);
}
},
});
},
},
};

return {
name: "styled-components-px2vw",
visitor,
};
});

因为针对 styled-components 并不只是我们上面的 demo 那么简单的写法,还有一些比如传入 props 等等的函数写法,因此还需要对应的处理逻辑。我们只看其中的核心部分,也就是上面 demo 的处理逻辑。首先可以看到一个 babel 插件的定义结构,babel 整体的设计思想才用了访问者模式,将操作部分独立拆分出来。然后使用了 babel 提供的traverse遍历树的方法去查找TaggedTemplateExpression这个节点。拿到节点之后呢使用了自己封装的replace,我们来看一下对应的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function replaceWithRecord(cssText: string): string {
const { unitToConvert } = configuration.config;
try {
// 成对匹配: xxx: xxx
if (PAIR_REG.test(cssText)) {
const replaced = process(
`${FAKE_OPENING_WRAPPER}${cssText}${FAKE_CLOSING_WRAPPER}`
);
/* istanbul ignore next */
if (errorTokenMap.has(cssText)) {
errorTokenMap.delete(cssText);
}
return replaced
.replace(FAKE_OPENING_WRAPPER, "")
.replace(FAKE_CLOSING_WRAPPER, "");
} else if (PX_UNIT_REG.test(cssText)) {
// ...
} else {
// ...
}
} catch (ignored) {
// ...
}
}

上面的正则去匹配成对的结构。对于匹配到的内容会在外面包一层,即:

1
2
3
width: 120px;
height: 32px;
font-size: 14px;

转换成下面的这种结构,是为了后面的 PostCSS 能够方便处理。

1
2
3
4
5
styled-fake-wrapper/* start of styled-fake-wrapper */ {
width: 120px;
height: 32px;
font-size: 14px;
} /* end of styled-fake-wrapper */

对于处理后的逻辑我们可以看到是用了 PostCSS 里面的postcss-px-to-viewport这个插件来做的。

1
2
3
4
5
6
7
8
9
10
11
12
import px2vw from "postcss-px-to-viewport"; //TODO: upgrade to 8 version wait postcss-px-to-viewport support

function process(css: string): string {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tags, ...others } = configuration.config;
const options: IPx2VwOptions = {
...others,
};
return postcss([px2vw(options)]).process(css, {
// syntax: scss,
}).css;
}

那么到这里 babel 的部分就分析完了,我们来看一下后面的 postcss-px-to-viewport 实现。

PostCSS 部分

PostCSS 和 babel 很像,只不过 PostCSS 是针对 css 内容处理的,因此根据上面 babel 处理后的 css 内容,我们这里就可以让一些成熟的方案来接管后面的处理了。

postcss-px-to-viewport 插件代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = postcss.plugin("postcss-px-to-viewport", function (options) {
// ...
return function (css, result) {
css.walkRules(function (rule) {
// Add exclude option to ignore some files like 'node_modules'
// ...
rule.walkDecls(function (decl, i) {
// ...
var value = decl.value.replace(
pxRegex,
createPxReplace(opts, unit, size)
);
// ...
});
});
};
});

PostCSS 提供的遍历方法,会遍历每一条 css 规则,可以看到这里依然是用正则来匹配对应的规则,然后匹配到的规则用createPxReplace(opts, unit, size)这个方法来替换。看一下这个方法的实现。

1
2
3
4
5
6
7
8
9
10
function createPxReplace(opts, viewportUnit, viewportSize) {
return function (m, $1) {
if (!$1) return m;
var pixels = parseFloat($1);

if (pixels <= opts.minPixelValue) return m;
var parsedVal = toFixed((pixels / viewportSize) * 100, opts.unitPrecision);
return parsedVal === 0 ? "0" : parsedVal + viewportUnit;
};
}

最关键的就是这一行var parsedVal = toFixed((pixels / viewportSize * 100), opts.unitPrecision);其实就是根据书写的 css px 规则,来根据传入的配置做一个计算,来达到最终的 vw 单位。

那么到这里整个的过程就分析完了。你可以再返回主流程来看一下整个流程。核心思路就是使用 babel 处理 styled-components,来让处理后的结果能让目前比较广泛使用的库(比如 postcss-px-to-viewport)来处理。

最后

上面的方案虽然未必是最优解,但是提供了一个很好的思路,随着对 react 技术栈的深入探究,可以探索一下有没有更好地解决方案。