小程序原理系列一之 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 整理一下:

來一段一段看一下:

設備信息

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 ,像素相關的知識很久很久以前寫過一篇文章 分辨率是什麼?。

https://zhuanlan.zhihu.com/p/55819582

這裏再補充一下,這裏的 deviceWidth 是設備獨立像素 (邏輯像素),是操作系統爲了方便開發者而提供的一種抽象。看一下開發者工具預設的設備:

如上圖,以 iphone6 爲例,寬度是 375 ,事實上 iphone6 寬度的物理像素是 750

所以就有了 Dpr 的含義, iphone6dpr21px 相當於渲染在兩個物理像素上。

rpx 轉換

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 問題及解決方案。

https://zhuanlan.zhihu.com/p/535456539

接下來一行 number = Math.floor(number + eps); 是爲了解決浮點數精度問題,比如除下來等於 3.9999999998 ,實際上應該等於 4 ,只是浮點數的問題導致沒有算出來 4 ,加個 eps ,然後向下 floor 去整,就可以正常得到 4 了,關於浮點數可以看 一直迷糊的浮點數

接着往下看:

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

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 函數:

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 中了。

...
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;
}

注入的全部代碼

這裏貼一下注入的全部代碼:

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" }
)();

編譯

剩下一個問題,我們寫的代碼是:

.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],
    "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 進行了編譯,編譯工具可以在微信開發者工具目錄搜索 wcsc

我們把這個 wcsc 文件拷貝到 index.wxss 的所在目錄,然後將我們的 wxss 手動編譯一下:

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

此時會發現生成的 wxss.js 就是我們上邊分析的全部代碼了:

因此對於代碼 wxss 到顯示到頁面中就是三步了,第一步是編譯爲 js,第二步將 js 通過 eval 注入到頁面,第三步就是 js 執行過程中把 rpx 轉爲 px,並且把 css 注入到 style  標籤中。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Yvp6reXftZ9yAFkxPrZlcQ