小程序原理系列一之wxss

平常小程序写的多一些,简单总结一下原理。但因为小程序也没开源,只能参考相关文档以及开发者工具慢慢理解了。

理解小程序原理的突破口就是开发者工具了,开发者工具是基于 NW.js,一个基于 Chromiumnode.js 的应用运行时。同时暴漏了 debug 的入口。

点开后就是一个新的 devTools 的窗口,这里我们可以找到预览界面的 dom

小程序界面是一个独立的 webview,也就是常说的视图层,可以在命令行执行 document.getElementsByTagName('webview') ,可以看到很多 webview

我这边第 0 个就是 pages/index/index 的视图层,再通过 document.getElementsByTagName('webview')[0].showDevTools(true) 命令单独打开这个 webview

熟悉的感觉回来了,其实就是普通的 html/css ,小程序的原理的突破口也就在这里了。

这篇文章简单看一下页面的样式是怎么来的,也就是 wxss 做了什么事情。

源码中 data1 的样式:

开发中工具中对应的样式:

rpx 的单位转成了 px ,同时保留网页不认识的属性名,大概就是为了方便的看到当前类本身的属性和一些文件信息。

这个样式是定义在 <style> 中,

让我们展开 <head> 找一下:

data1 确实在 <style> 中,继续搜索,可以看到这里 <style> 中的内容是通过在 <script> 执行 eval 插入进来的。

把这一段代码丢给 chatGPT 整理一下:

来一段一段看一下:

设备信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var BASE_DEVICE_WIDTH = 750;
var isIOS = navigator.userAgent.match("iPhone");
var deviceWidth = window.screen.width || 375;
var deviceDPR = window.devicePixelRatio || 2;
var checkDeviceWidth = window.__checkDeviceWidth__ || function() {
var newDeviceWidth = window.screen.width || 375;
var newDeviceDPR = window.devicePixelRatio || 2;
var newDeviceHeight = window.screen.height || 375;
if (window.screen.orientation && /^landscape/.test(window.screen.orientation.type || '')) {
newDeviceWidth = newDeviceHeight;
}
if (newDeviceWidth !== deviceWidth || newDeviceDPR !== deviceDPR) {
deviceWidth = newDeviceWidth;
deviceDPR = newDeviceDPR;
}
};
checkDeviceWidth();

主要更新了几个变量,deviceWidthdeviceDPR ,像素相关的知识很久很久以前写过一篇文章 分辨率是什么?

这里再补充一下,这里的 deviceWidth 是设备独立像素(逻辑像素),是操作系统为了方便开发者而提供的一种抽象。看一下开发者工具预设的设备:

如上图,以 iphone6 为例,宽度是 375 ,事实上 iphone6 宽度的物理像素是 750

所以就有了 Dpr 的含义, iphone6dpr21px 相当于渲染在两个物理像素上。

rpx 转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var eps = 1e-4;
var transformRPX = window.__transformRpx__ || function(number, newDeviceWidth) {
if (number === 0) return 0;
number = number / BASE_DEVICE_WIDTH * (newDeviceWidth || deviceWidth);
number = Math.floor(number + eps);
if (number === 0) {
if (deviceDPR === 1 || !isIOS) {
return 1;
} else {
return 0.5;
}
}
return number;
};

核心就是这一行 number = number / BASE_DEVICE_WIDTH * (newDeviceWidth || deviceWidth); ,其中 BASE_DEVICE_WIDTH750 ,也就是微信把屏幕宽度先强行规定为了 750 ,先用用户设定的 rpx 值除以 750 算出一个比例,最后乘上设备的逻辑像素。

如果设备是 iphone6 ,那么这里设备的逻辑像素就是 350,所以如果是 2rpx2/750*375=1 最后算出来就是 1px ,实际上在 iphone6 渲染的是两个物理像素,也就是常常遇到的 1px 过粗的问题,解决方案可以参考这篇 前端移动端1px问题及解决方案

接下来一行 number = Math.floor(number + eps); 是为了解决浮点数精度问题,比如除下来等于 3.9999999998 ,实际上应该等于 4 ,只是浮点数的问题导致没有算出来 4 ,加个 eps ,然后向下 floor 去整,就可以正常得到 4 了,关于浮点数可以看 一直迷糊的浮点数

接着往下看:

1
2
3
4
5
6
7
if (number === 0) {
if (deviceDPR === 1 || !isIOS) {
return 1;
} else {
return 0.5;
}
}

transformRPX 函数整个代码里第一行 if (number === 0) return 0;number 等于 0 已经提前结束了,所以这里 number 得到 0 就是因为除的时候得到了一个小数。

如果 deviceDPR === 1,说明逻辑像素和物理像素是一比一的,不可能展示半个像素,直接 return 1

如果不是 iOS 也直接返回 1 ,这是因为安卓手机厂商众多,即使 deviceDPR 大于 1 ,也不一定支持像素传小数,传小数可能导致变 0 或者变 1 ,为了最大可能的保证兼容性,就直接返回 1

对于苹果手机,据说是从 iOS 8 开始支持 0.5px 的,但没找到当时的官方说明:

因此上边的代码中,对于 deviceDPR 大于 1 ,并且是苹果手机的就直接返回 0.5 了。

生成 css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
setCssToHead(
[
".",
[1],
"container { display: flex; align-items: center; justify-content: center; ;wxcs_originclass: .container;;wxcs_fileinfo: ./pages/index/index.wxss 1 1; }\n",
".",
[1],
"data1{ color: red; font-size: ",
[0, 50],
"; ;wxcs_style_font-size : 50rpx; ;wxcs_originclass: .data1;;wxcs_fileinfo: ./pages/index/index.wxss 6 1; }\n",
".",
[1],
"data2{ color: blue; font-size: ",
[0, 100],
"; ;wxcs_style_font-size : 100rpx; ;wxcs_originclass: .data2;;wxcs_fileinfo: ./pages/index/index.wxss 11 1; }\n",
".",
[1],
"data3{ color: blue; font-size: ",
[0, 100],
"; ;wxcs_style_font-size : 100rpx; ;wxcs_originclass: .data3;;wxcs_fileinfo: ./pages/index/index.wxss 17 1; }\n",
],
undefined,
{ path: "./pages/index/index.wxss" }
)();

通过调用 setCssToHead 把上边传的数组拼接为最终的 css

核心逻辑就是循环上边的数组,如果数组元素是字符串直接相加就好,如果是数组 [1][0, 50] 这样,需要特殊处理下:

核心逻辑是 makeup 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function makeup(file, opt) {
var _n = typeof(file) === 'string';
if (_n && Ca.hasOwnProperty(file)) return '';
if (_n) Ca[file] = 1;
var ex = _n ? _C[file] : file;
var res = '';
for (var i = ex.length - 1; i >= 0; i--) {
var content = ex[i];
if (typeof(content) === 'object') {
var op = content[0];
if (op === 0) res = transformRPX(content[1], opt.deviceWidth) + 'px' + res;
else if (op === 1) res = opt.suffix + res;
else if (op === 2) res = makeup(content[1], opt) + res;
} else res = content + res;
}
return res;
}

如果遇到 content[1],也就是 op 等于 1 ,添加一个前缀 res = opt.suffix + res;

如果遇到 content[0, 50],也就是 op 等于 0 ,这里的 50 其实就是用户写的 50rpx50 ,因此需要调用 transformRPX50 转为 px 再相加 res = transformRPX(content[1], opt.deviceWidth) + 'px' + res;

通过 makeup 函数,生成 css 字符串后,剩下的工作就是生成一个 style 标签插入到 head 中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
css = makeup(file, opt);
if (!style) {
var head = document.head || document.getElementsByTagName('head')[0];
style = document.createElement('style');
style.type = 'text/css';
style.setAttribute("wxss:path", info.path);
head.appendChild(style);
...
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
if (style.childNodes.length === 0)
style.appendChild(document.createTextNode(css));
else
style.childNodes[0].nodeValue = css;
}

注入的全部代码

这里贴一下注入的全部代码:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
var BASE_DEVICE_WIDTH = 750;
var isIOS = navigator.userAgent.match("iPhone");
var deviceWidth = window.screen.width || 375;
var deviceDPR = window.devicePixelRatio || 2;
var checkDeviceWidth = window.__checkDeviceWidth__ || function() {
var newDeviceWidth = window.screen.width || 375;
var newDeviceDPR = window.devicePixelRatio || 2;
var newDeviceHeight = window.screen.height || 375;
if (window.screen.orientation && /^landscape/.test(window.screen.orientation.type || '')) {
newDeviceWidth = newDeviceHeight;
}
if (newDeviceWidth !== deviceWidth || newDeviceDPR !== deviceDPR) {
deviceWidth = newDeviceWidth;
deviceDPR = newDeviceDPR;
}
};
checkDeviceWidth();
var eps = 1e-4;
var transformRPX = window.__transformRpx__ || function(number, newDeviceWidth) {
if (number === 0) return 0;
number = number / BASE_DEVICE_WIDTH * (newDeviceWidth || deviceWidth);
number = Math.floor(number + eps);
if (number === 0) {
if (deviceDPR === 1 || !isIOS) {
return 1;
} else {
return 0.5;
}
}
return number;
};
window.__rpxRecalculatingFuncs__ = window.__rpxRecalculatingFuncs__ || [];
var __COMMON_STYLESHEETS__ = __COMMON_STYLESHEETS__ || {};

var setCssToHead = function(file, _xcInvalid, info) {
var Ca = {};
var css_id;
var info = info || {};
var _C = __COMMON_STYLESHEETS__;

function makeup(file, opt) {
var _n = typeof(file) === 'string';
if (_n && Ca.hasOwnProperty(file)) return '';
if (_n) Ca[file] = 1;
var ex = _n ? _C[file] : file;
var res = '';
for (var i = ex.length - 1; i >= 0; i--) {
var content = ex[i];
if (typeof(content) === 'object') {
var op = content[0];
if (op === 0) res = transformRPX(content[1], opt.deviceWidth) + 'px' + res;
else if (op === 1) res = opt.suffix + res;
else if (op === 2) res = makeup(content[1], opt) + res;
} else res = content + res;
}
return res;
}

var styleSheetManager = window.__styleSheetManager2__;
var rewritor = function(suffix, opt, style) {
opt = opt || {};
suffix = suffix || '';
opt.suffix = suffix;
if (opt.allowIllegalSelector !== undefined && _xcInvalid !== undefined) {
if (opt.allowIllegalSelector) console.warn("For developer:" + _xcInvalid);
else {
console.error(_xcInvalid);
}
}
Ca = {};
css = makeup(file, opt);
if (styleSheetManager) {
var key = (info.path || Math.random()) + ':' + suffix;
if (!style) {
styleSheetManager.addItem(key, info.path);
window.__rpxRecalculatingFuncs__.push(function(size) {
opt.deviceWidth = size.width;
rewritor(suffix, opt, true);
});
}
styleSheetManager.setCss(key, css);
return;
}
if (!style) {
var head = document.head || document.getElementsByTagName('head')[0];
style = document.createElement('style');
style.type = 'text/css';
style.setAttribute("wxss:path", info.path);
head.appendChild(style);
window.__rpxRecalculatingFuncs__.push(function(size) {
opt.deviceWidth = size.width;
rewritor(suffix, opt, style);
});
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
if (style.childNodes.length === 0)
style.appendChild(document.createTextNode(css));
else
style.childNodes[0].nodeValue = css;
}
}
return rewritor;
}

setCssToHead([])();
setCssToHead(
[
".",
[1],
"container { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: ",
[0, 200],
" 0; ;wxcs_style_padding : 200rpx 0; box-sizing: border-box; ;wxcs_originclass: .container;;wxcs_fileinfo: ./app.wxss 2 1; }\n",
],
undefined,
{ path: "./app.wxss" }
)();
setCssToHead(
[
".",
[1],
"container { display: flex; align-items: center; justify-content: center; ;wxcs_originclass: .container;;wxcs_fileinfo: ./pages/index/index.wxss 1 1; }\n",
".",
[1],
"data1{ color: red; font-size: ",
[0, 50],
"; ;wxcs_style_font-size : 50rpx; ;wxcs_originclass: .data1;;wxcs_fileinfo: ./pages/index/index.wxss 6 1; }\n",
".",
[1],
"data2{ color: blue; font-size: ",
[0, 100],
"; ;wxcs_style_font-size : 100rpx; ;wxcs_originclass: .data2;;wxcs_fileinfo: ./pages/index/index.wxss 11 1; }\n",
".",
[1],
"data3{ color: blue; font-size: ",
[0, 100],
"; ;wxcs_style_font-size : 100rpx; ;wxcs_originclass: .data3;;wxcs_fileinfo: ./pages/index/index.wxss 17 1; }\n",
],
undefined,
{ path: "./pages/index/index.wxss" }
)();

编译

剩下一个问题,我们写的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.container {
display: flex;
align-items: center;
justify-content: center;
}
.data1{
color: red;
font-size: 50rpx;
}

.data2{
color: blue;
font-size: 100rpx;
}

.data3{
color: blue;
font-size: 100rpx;
}

但上边分析的 <script> 生成 css 的数组是哪里来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
".",
[1],
"container { display: flex; align-items: center; justify-content: center; ;wxcs_originclass: .container;;wxcs_fileinfo: ./pages/index/index.wxss 1 1; }\n",
".",
[1],
"data1{ color: red; font-size: ",
[0, 50],
"; ;wxcs_style_font-size : 50rpx; ;wxcs_originclass: .data1;;wxcs_fileinfo: ./pages/index/index.wxss 6 1; }\n",
".",
[1],
"data2{ color: blue; font-size: ",
[0, 100],
"; ;wxcs_style_font-size : 100rpx; ;wxcs_originclass: .data2;;wxcs_fileinfo: ./pages/index/index.wxss 11 1; }\n",
".",
[1],
"data3{ color: blue; font-size: ",
[0, 100],
"; ;wxcs_style_font-size : 100rpx; ;wxcs_originclass: .data3;;wxcs_fileinfo: ./pages/index/index.wxss 17 1; }\n",
],

是微信帮我们把 wxss 进行了编译,编译工具可以在微信开发者工具目录搜索 wcscLibrary 是个隐藏目录。

我们把这个 wcsc 文件拷贝到 index.wxss 的所在目录,然后将我们的 wxss 手动编译一下:

1
./wcsc -js ./index.wxss >> wxss.js

image-20231125124432358

此时会发现生成的 wxss.js 就是我们上边分析的全部代码了:

因此对于代码 wxss 到显示到页面中就是三步了,第一步是编译为 js,第二步将 js 通过 eval 注入到页面,第三步就是 js 执行过程中把 rpx 转为 px,并且把 css 注入到 style 标签中。

windliang wechat