提升組件庫通用能力 – NutUI 在線主題訂製功能探索

開發背景

NutUI 作為京東風格的組件庫,已具備 H5 和多端小程式開發能力。隨著業務的不斷發展,組件庫的應用場景越來越廣。在公司內外面臨諸如科技、金融、物流等各多個大型團隊使用時,單一的京東 APP 視覺雖可以一鍵進行換膚操作,但是對於更個性化的訂製需求(組件級樣式、規範、尺寸等)近千行的主題樣式變數對開發者來說工作量是非常大的。為提升開發體驗,提高開發者效率,加強換膚功能以及實現「組件級式訂製」功能迫在眉睫。

設計目標

允許用戶在開發階段切換不同主題風格的皮膚,也允許開發者對指定的組件直接進行樣式修改,以滿足不同設計風格的移動端業務場景。

效率提升

官網會提供多套主題供開發者選擇,同時開發者也可以在多套主題基礎上進行實時編輯修改,完成後下載配置變數,應用在項目中即可,非常易上手。完成一個全局樣式配置僅需1分鐘。
相對這種場景下的需求開發是比較快的,能夠降低開發成本。

組件粒度

主題訂製配置層分為全局基本變數、組件基本變數,開發者可以修改全局,比如組件庫的全局主題顏色,字體等樣式。組件層的配置可以更細緻,比如 Button 按鈕成功類型的圓角邊框尺寸

通用變數

組件變數

通用擴展能力

現階段官方會提供一些優質主題集成到官網的,對於社區開發者、開發團隊、如果您的團隊訂製的樣式主題文件受眾非常之廣,可以聯繫我們,將您的主題內置到官方 npm 包中,造福更多的開發者

官方主題

開發者如何使用

影片教程

NutUI 一分鐘快速在線主題訂製 //www.bilibili.com/video/BV1fi4y1D7qb

1、打開在線配置網站,按照下方圖片進行修改預覽下載

效果預覽

2、本地項目配置

修改本地項目 webpack 或者 vite 的配置文件將下載後的 custom_theme.sass 文件,集成到項目中比如assets/styles/custom_theme.sass

  • vite 構建工具使用示例 vite.config
// //vitejs.dev/config/
export default defineConfig({
  //...
  css: {
    preprocessorOptions: {
      scss: {
        // 默認京東 APP 10.0主題 > @import "@nutui/nutui/dist/styles/variables.scss";
        // 京東科技主題 > @import "@nutui/nutui/dist/styles/variables-jdt.scss";
        additionalData: `@import "./assets/styles/custom_theme.scss";@import "@nutui/nutui/dist/styles/variables.scss";`
      }
    }
  }
})
  • webpack 構建工具使用示例
{
    test: /\.(sa|sc)ss$/,
    use: [
        {
            loader: 'sass-loader',
            options: {
                // 默認京東 APP 10.0主題 > @import "@nutui/nutui/dist/styles/variables.scss";
                // 京東科技主題 > @import "@nutui/nutui/dist/styles/variables-jdt.scss";
                data: `@import "./assets/styles/custom_theme.scss";@import "@nutui/nutui/dist/styles/variables.scss";`,
            }
        }
    ]
}
  • taro 小程式使用示例

修改 config/index.js 文件中配置 scss 文件全局覆蓋如:

const path = require('path');
const config = {
  deviceRatio: {
    640: 2.34 / 2,
    750: 1,
    828: 1.81 / 2,
    375: 2 / 1
  },
  sass: {
		resource: [
			path.resolve(__dirname, '..', 'src/assets/styles/custom_theme.scss')
		],
    // 默認京東 APP 10.0主題 > @import "@nutui/nutui-taro/dist/styles/variables.scss";
    // 京東科技主題 > @import "@nutui/nutui-taro/dist/styles/variables-jdt.scss";
    data: `@import "@nutui/nutui-taro/dist/styles/variables.scss";`
	},
  // ...

實現原理解析

整個組件庫主題訂製模組,實現可以分為兩個方向,一個是內部的組件庫設計(供開發者使用配置每個樣式變數),另一個是在線配置官網(供開發者便捷的修改),接下來依次按照設計圖來闡述。

設計圖

組件庫內部設計

首先源碼內部style文件夾下,分別存在variables.scssvariables-jdt.scss多個文件對應的不同的官方主題,每個主題的全局的variables.scss文件,內部其實按標準的規則存放存放通用樣式變數和每個組件的樣式變數,像下面一樣

// --------base begin-------
// 主色調
$primary-color: #fa2c19 !default;
$primary-color-end: #fa6419 !default;
// 輔助色
$help-color: #f5f5f5 !default;
// 標題常規文字
$title-color: #1a1a1a !default;
// 副標題
$title-color2: #666666 !default;
// 次內容
$text-color: #808080 !default;

//...

// Font
$font-size-0: 10px !default;
$font-size-1: 12px !default;
$font-size-2: 14px !default;
$font-size-3: 16px !default;
$font-size-4: 18px !default;
$font-weight-bold: 400 !default;

$font-size-small: $font-size-1 !default;
$font-size-base: $font-size-2 !default;
$font-size-large: $font-size-3 !default;
$line-height-base: 1.5 !default;
// --------base end-------

// button
$button-border-radius: 25px !default;
$button-border-width: 1px !default;
$button-default-bg-color: $white !default;
$button-default-border-color: rgba(204, 204, 204, 1) !default;
$button-default-color: rgba(102, 102, 102, 1) !default;
//...

// icon 
// ...

這裡啰嗦一句,可以看到每一行後面都有一個 !default,這個是必不可少的,如果不加,開發者本地項目是無法覆蓋這個變數的

//www.sass.hk/docs/#t6-9 Tips: 可以在變數的結尾添加 !default 給一個未通過 !default 聲明賦值的變數賦值,此時,如果變數已經被賦值,不會再被重新賦值,但是如果變數還沒有被賦值,則會被賦予新的值。

對於每一個組件的內部,例如button/index.scss下是這樣引用height: $button-default-height;

.nut-button {
  position: relative;
  display: inline-block;
  flex-shrink: 0;
  height: $button-default-height;
  // ...
}

其實最終組件庫構建成 npm 包時,將主題的全局的variables.scss等主題文件暴露給開發者,然後開發者根據需求替換其中的樣式變數,至此組件庫內部實現主題訂製就實現了

可視化配置官網

源碼搶先看://github.com/jdf2e/nutui/tree/theme/src/sites/doc/components/ThemeSetting

整體實現流程如下,接下來依次闡述

  • variables.scss 源文件,通過組件配置數據 + 正則匹配拆分,得到這樣的數據結構
// 主色調
$primary-color: #fa2c19 !default;
$primary-color-end: #fa6419 !default;
//...

// button
$button-border-radius: 25px !default;
$button-border-width: 1px !default;
//...

// icon 
// ...
[
  {name: 'Base', lowerCaseName: 'base', key: '$primary-color', rawValue: '#fa2c19', computedRawValue: ''}
  {name: 'Base', lowerCaseName: 'base', key: '$primary-color-end', rawValue: '#fa6419', computedRawValue: ''}
  // ...
  {name: 'Button', lowerCaseName: 'button', key: '$button-border-width', rawValue: '1px', computedRawValue: ''}
  {name: 'Button', lowerCaseName: 'button', key: '$button-border-radius', rawValue: '25px', computedRawValue: ''}
  //{name: 'components1', lowerCaseName: 'components1', key: '$components1-border-radius', rawValue: 'xx', computedRawValue: ''}
  //...
]
const findStyle = (componentName: string) => {
  // //raw.githubusercontent.com/jdf2e/nutui/next/src/packages/styles/variables.scss
  // var pattern = /\$button.*;/g;
  var p = new RegExp(`\\$${componentName}.*;`, 'g');
  let parray: any[] = varcss.match(p) || [];
  // 需要包含換行
  let commponetns = parray.map((item) => {
    let cArray = item.split(':');
    let name = cArray[0],
      value: string = cArray[1].replace(' !default;', '').trim();
      return {
        name: componentName, 
        key: name, 
        rawValue:value,
        computedRawValue: ''
      }
  });
}
components.map(item=>{ findStyle(item.name) });
  • 接下來根據組件不同展示該組件下所有變數,監聽組件切換切換或者編輯,進行實時編譯
const cssText = computed(() => {
  const variablesText = store.variables.map(({ key, value }) => `${key}:${value}`).join(';');
  cachedStyles = cachedStyles || extractStyle(store.rawStyles);
  return `${variablesText};${cachedStyles}`;
});
const formItems = computed(() => {
  const name = route.path.substring(1);
  return store.variables.filter(({ lowerCaseName }) => lowerCaseName === name);
});
watch(
    () => cssText.value,
    (css) => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        const Sass = (window as any).Sass;
        let beginTime = new Date().getTime();
        console.log('sass編譯開始', beginTime);
        Sass &&
          Sass.compile(css, async (res: Obj) => {
            await awaitIframe();
            const iframe = window.frames[0] as any;
            if (res.text && iframe) {
              console.log('sass編譯成功', new Date().getTime() - beginTime);
              if (!iframe.__styleEl) {
                const style = iframe.document.createElement('style');
                style.id = 'theme';
                iframe.__styleEl = style;
              }
              iframe.__styleEl.innerHTML = res.text;
              iframe.document.head.appendChild(iframe.__styleEl);
            } else {
              console.log('sass編譯失敗', new Date().getTime() - beginTime);
              console.error(res);
            }

            if (res.status !== 0 && res.message) {
              console.log(res.message);
            }
          });
      }, 300);
    },
    { immediate: true }
  );
  • 下載配置變數操作

由於變數文件近千行,以後可能還會更大,直接採用Blob文件流進行生成下載。

downloadScssVariables() {
  if (!store.variables.length) {
    return;
  }

  let temp = '';
  const variablesText = store.variables
    .map(({ name, key, value }) => {
      let comment = '';
      if (temp !== name) {
        temp = name;
        comment = `\n// ${name}\n`;
      }
      return comment + `${key}: ${value};`;
    })
    .join('\n');
  download(`// NutUI主題訂製\n${variablesText}`, 'custom_theme.scss');
}
function download(content: string, filename: string) {
  const eleLink = document.createElement('a');
  eleLink.download = filename;
  eleLink.style.display = 'none';

  const blob = new Blob([content]);
  eleLink.href = URL.createObjectURL(blob);

  document.body.appendChild(eleLink);
  eleLink.click();
  document.body.removeChild(eleLink);
}

總結

文章詳細介紹了 NutUI 的「主題訂製」和「組件級樣式訂製」功能實現機制。「主題訂製」能實現簡單的顏色切換,「組件級樣式訂製」功能更強大,通過將組件的樣式變數暴露出來開發者幾乎可以任意修改自己想要的設計風格(組件尺寸、字體、邊距)。通過強大的主題訂製可以讓組件庫的使用不局限於原設計者的設計範疇,可靈活擴展組件,讓組件庫的應用範圍更廣,能滿足更廣泛的業務場景。

期待您的使用與回饋 ❤️~