Skip to content

Latest commit

 

History

History
521 lines (326 loc) · 30 KB

Sugard换肤实现.md

File metadata and controls

521 lines (326 loc) · 30 KB

前言

近三个月未见,这三个月里也有一些文章积累,只是真的没时间去写文章,都是以笔记的方式去做记录,恰好这两天在做 2020 个人总结,我想还是组织一下语言,输出几篇文章~

上一次主要是研究 Redux 相关技术调研,还存在许多需要去学的知识点,本来只是想了解一下 redux 相关知识,然后写了一篇【KT】查缺补漏 React 状态管理探索,之后去看了 Redux 的源码,发现优秀代码原来是这么写的,于是就有了【KT】轻松搞定 Redux 源码解读与编程艺术,紧接着与阿杰交流中,听到了 hox 库,于是就去看了它的源码并写了篇文章,到最后受阿磊的启发,写了个 Redux 中间件,并且写了篇文章记录许多坑,【KT】rc-redux-model 你还在 redux 中写重复啰嗦的样板代码吗

好像有点偏离本文章的主题,不要慌,阿宽又把话题拉回来了,这一次的主题是:在 sugard 中,我如何实现自定义主题皮肤,中间又遇到了什么坑,如何解决?(当然解决方案不一定是最优的,如果有更好建议可以提出~)

背景

在今年 3 月,我们研发团队决定联合视觉团队,共同打造一套符合自身业务的 UI 组件库,毕竟迭代需求过程中,视觉对于 UI 组件的也是有规范的,当研发与视觉统一规范之后,是否可以整一波 UI 组件库呢?于是项目从立项到落地再到文档的补全上线,前后花了近半年的时间。有幸参与此 UI 库的建设,我也负责了几个组件,我还记得期间发过前端渣渣开发 UI 公共组件的新认识前端渣渣的我再也不敢说我会写 Button 组件了相关的文章,此次这篇文章是对于组件库要落地到项目中,遇到的问题和自定义皮肤主题过程中,遇到的困难点。感兴趣的可以在评论区讨论~~

问题总结

1. 本地开发卡顿

在我接到需要自定义主题皮肤色需求时,屁颠屁颠的去拉了项目,本地运行,然后发现有个很恶心的问题,那就是开发卡顿,怎么理解呢?在开发过程中,发现开启 Chrome devtools 去获取元素样式,特别卡顿,存在延迟问题。具体有多卡顿呢?打开开发者工具,定位一个元素,想知道该元素的 style,会发现,Styles 处会 loading 大概 2s 才会加载出来...

当加载出来之后,从一个元素切换到另一个元素,也是会存在延迟,并不会马上更新 Styles,需要等待近 2s ~ 5s 左右的时间,才会更新 Styles,也就是,2s ~ 5s 之后,Styles 处的才是我想要的样式。如果等待过程,你不断的切样式,那么等待时间会更久。

打开活动监视器,发现 CPU 占用率极其异常

很奇怪啊,怎么回事,于是我去跑了一下 performance

蒙蔽了,😭 没有很红,甚至绿的不行,看起来没啥问题,没有特别红的地方,如果特别红,说明帧数已经下降到影响用户体验的程度。 FPS 在 13,比较低(fps = 60 性能最佳,fps < 24 会让用户感觉到卡顿,人眼识别主要是 24 帧)再去看看 Memory 会不会有所收获,让人失望的是,我也没发现什么奇怪的现象。

那就是可能某些组件的问题了,会不会是组件不断的 render ,或者是因为组件在一个页面中全展示,数量导致的问题?

采用了二分法,先只展示一半组件,将另一半注释,发现快了一些,不断二分法,到最后只展示一个组件,发现确实快了

再慢慢的添加展示的组件数量,2 个、3 个、4 个,发现确实随着数量的增多,慢慢变卡顿了。

规避方案:拆!但是拆完之后,一个页面显示一个组件,发现还是卡顿,于是看了一下 Styles,发现了一个问题,为什么同一段代码,加载了这么多次

发现此段代码,是因为每个组件都引入了文件夹中的 style/index.less

下面是 Badge 组件和 Checkbox 组件的 less 文件

那么问题就在于:如何解决重复 css 的问题,最后使用 optimize-css-assets-webpack-plugin 插件进行处理

这个插件主要是支持我们自定义一些优化手段,具体使用可以自行去看文档,内部对于 cssProcessor 默认采用 cssnano 进行处理

(下面来自源码: https://github.com/NMFR/optimize-css-assets-webpack-plugin/blob/master/src/index.js)

this.options.cssProcessor =
  !options || !options.cssProcessor ? require('cssnano') : options.cssProcessor;

然后通过 last-call-webpack-plugin 去做拦截,通过 cssnano 去做优化

下面引用 cssnano 文档的一段话解释吧 ~

cssnano uses PostCSS to process the CSS under the hood. Because a lot of modern CSS tools use PostCSS, you can compose them together to work on a single abstract syntax tree (AST). This means that the overall processing time is reduced because the CSS does not have to be parsed multiple times.

我们可以从文档中看到,所有最佳的优化在这里 :https://cssnano.co/docs/optimisations/

uniqueSelectors

Naturally sorts selectors for every rule, and removes duplicates.

2. 项目引用报错

按照 README 文档说明,npm install 之后,引入对应组件即可,但事实证明,路没有那么顺畅

当真的接入之后,发现会抱各种问题,如下图所示

去搜了一下,原来是 sugard 里边 Carousel 组件依赖了 react-slick 库,而这个库中存在一些文件格式,如 .gif 等,需要通过 url-loaderfile-loader 处理

在 issues 中找到此问题的解决方案:why is the css invalid?

通过在自己项目中添加 webpack 配置

module.exports = {
  module: {
    rules: [
      ...{
        test: /\.(eot|woff|woff2|ttf|svg|png|jpg|gif)$/,
        loader: 'url-loader?limit=30000&name=[name]-[hash].[ext]',
      },
    ],
  },
};

3. 自定义主题色

终于到了最重要的环节,首先去看了一下 antd 自定义主题色到处理方式:定制主题,大致思路就是 :

antd 的实现方式是通过 less-loader,在 less 编译为 css 时,通过配置的 lessOptions 进行替换

那么我们也按照它这样做,行不行?于是在 sugard 的 webpack 里边配置了一下 Button 组件的重写自定义样式。

{
    test: /\.less$/,
    include: /node_modules/,
    use: [
        {
          loader: 'less-loader',
          options: {
            lessOptions: {
              modifyVars: {
                // normal 类型
                "btn-normal-color": "#ccc", // 灰色字
                "btn-normal-bg": "#ffff00", // 黄色底
                // orange 类型
                "btn-orange-color": "#1890ff", // 蓝色字
                "btn-orange-bg": "#52c41a", // 绿色底
                // danger 类型
                "btn-danger-color": "#f74f56", // 红色字
                "btn-danger-bg": "#faad14" // 屎黄色底
              },
              javascriptEnabled: true
            }
          }
        }
    ]
}

3.1 换肤失败,配置不生效

发现并不生效,为什么呢?我们再看看 antd 的自定义配置说明

我们从代码中去看,其实 antd 是有两套样式,一套是 css ,一套是 less,默认是采用的 css,但用户想要自定义,那么就采用 less,然后通过 less-loader 处理

而我们的 sugard,打包出来的 lib 和 es 只有一套 css,并不会有什么用,因为根本无 less 文件可操作

目前先验证是否真的可以自定义话,所以在 sugard,配置 webpack,在打包之前通过 less-loader 处理一波

于是去 sugard 的 webpack 中配置,然后运行,又出问题了

再次排查,具体内容可以看这个 issues:ant-design/ant-design#7927

看下来最佳的解决方案是降低 less 版本和 less-loader 版本 ,相关 issues 的评论

暂时降低版本,验证一波,版本选择:[email protected][email protected]

当我降低版本之后,满怀兴致的跑项目,没想到....还是报错了

排查发现,原来是配置重复,感兴趣可以看这个 Error: Didn't get a result from child compiler

最佳回复是:两个 loader 匹配一样的规则,就会出错

当我把问题解决了之后,兴致勃勃的准备换肤,发现又有问题了。配置的 less-loader → lessOptions 居然不生效

通过排查 package.json ,发现一些问题,下面针对这些问题,逐步进行定位:

项目跑的是 npm run pub 和 npm run start

  • npm run pub 是编译和打包 dist 与 lib/es
  • npm run start 跑的是组件实时预览 demo 例子,其中 demo 组件引用的是打包 lib 目录

主要是看打包的时候,最后输出的 css 文件数据到底是什么。以修改 sugar-button-orange 为例

修改前 :

.sugar-button-orange { color : '#fff', background: '#ffab30' }

期望的修改后 :

.sugar-button-orange { color: '#1890ff', background: '#52c41a' }

3.2 webpack.base.js 配置无效

在 webpack.base.js 中配置 less-loader,但是当编译之后,发现好像没被替换。于是看打包,发现 lib-build 和 dist-build 两个打包机制不一样(这就涉及到业务具体代码了...)

总之说一下思路就是: 编译成 es 和 lib 的方式为获取 components 所有组件下的 less 文件,配置 lessOptions,通过 less.render 方式,得到处理后的 result

如上,我们打印 result.css ,可以看到,编译后的 sugar-button-orange 是被替换过的,是我们所期望的。

得到编译后的 result.css,将组件下的 less 文件 contents 内容替换,同时重写 replace 文件名,这里我定义成 css1,不出意外的话,此时的 es 目录和 lib 目录下,每个组件的 css 文件后缀都应为 .css1

在进入到 index.css1 文件,可以看到,sugar-button-orange 确实是改变了;

OK,这时候我们把 .css1 换成 .css 就可以了,最后打包出来的换肤 css 就大功告成了~

再次打包,前往组件 demo 验证是否能成功换肤

(下面这图是 dist 目录中的 sugard.min.css,实际上对于 es/lib 是通过 gulp 进行打包的,而 dist 是通过 gulp 打包的)

至此,换肤终于可以实现~ 下面看看默认主题皮肤和自定义之后的主题皮肤

【默认主题皮肤】

【自定义主题皮肤】

4. 换肤方案坑不少

基于自定义换肤,进行了一轮评审,主要讨论的换肤方案有:

方案一:多个包仓库(一个主题色就是一个包)

  • 每种主题色就是打成一个包,如 sugard-themeA、sugard-themeB、sugard-themeC
  • 优点:开发成本低,视觉给一份皮肤配置,开发只要塞进 lessOptions.modifyVars ,然后打个包就好
  • 缺点:仓库量大,不切实际

方案二:一个包,但是按照主题皮肤分目录

  • 皮肤配置文件下,n 个皮肤文件就打出来多个文件夹目录 例如 themeA、themeB 两种皮肤,那么打出来的 lib 目录下,就是两个文件夹,每个文件夹下,是该皮肤配色的组件,换言之,Button 组件就有两份
  • 优点:只有一个 sugard 独立包
  • 缺点:文件夹多,多一套主题皮肤,就多一倍的组件数量文件夹。

方案三:一个包,对应组件下多几套皮肤 css

  • 与方案二类似,不同的是 lib 下的每个组件,都有 n 套皮肤 css,例如:Button 组件下,有 index.defalut.css、index.themeA.css、index.themeB.css
  • 优点
    • 与目前 sugard 打包的方式差不多,更加符合设计规则,同时在业务端,可以自行选择多套皮肤组件,比如我 A 页面的 Button 想用 index.themeA.css,B 页面想用 index.themeB.css ,也是可行的
    • 这样更加灵活,使得 sugard 与皮肤之间关系弱化,并不是说我使用 teacher 主题皮肤,就都是该皮肤主题,不能使用其他皮肤的主题了。
  • 缺点:相对其他方案,缺点可忽略

相比之下,选择方案三,如果小伙伴有更好的方案/建议,也可以提出,虚心请教一波~

5. 拆分打包文件

【在初期项目技术选型上,采用 gulp 和 webpack 进行编译打包,后续我们会进行复盘然后优化】

由于所有的打包逻辑都放在 gulpfile.js 文件中,目前逻辑比较多,es 模块和 commonJS 模块的编译以及 dist 打包都放在了一起,所以根据文档,打算拆分,根据文档进行分割

下面说一下现在 sugard 的 es/lib 编译流程和 dist 打包流程

es/lib 编译流程:

  1. 通过 gulp 打包,首先通过 gulp.src 获取我们想要处理的文件流,也就是读取 components/ 所有组件中的 .less 文件,然后把文件流通过 pipe 方法倒入插件中,这里我们用的 through2 插件
  2. 通过 through2 进行处理流,关于 throught2 的部分理解,可看:npm 里的 through2 这个模块是什么功能through2 原理解析
  3. 通过 through2.obj 对文件流进行处理,内部自行实现 transorm 方法,这里实现了将 less 转成 css ,源码代码看这里
  4. 通过得到编译转换后的 result.css,将组件下的.less 文件内容替换成 result.css ,并同时将 .less 后缀改为 .css 后缀
  5. 再将通过处理后的流,通过 pipe 方法倒入到 gulp.dest() 中,这里就是将处理后的文件流导出到 es/lib 中
// 编译组件为es和commonJS,每次编译前,都将旧的文件删除
function compile(modules) {
  rimraf.sync(modules !== false ? libDir : esDir);
  const lessStreamToCss = gulp
    .src(['../components/**/*.less'], ignoreConfig)
    .pipe(
      through2.obj(function (file, encoding, next) {
        // 只对入口index.less进行编译
        if (file.path.match(/(\/|\\)index\.less$/)) {
          transformLess(file.path)
            .then((css) => {
              file.contents = Buffer.from(css);
              file.path = file.path.replace(/\.less$/, '.css');
              this.push(file);
              next();
            })
            .catch((e) => {
              console.error('[less error]:', e);
              next();
            });
        } else {
          next();
        }
      })
    )
    .pipe(gulp.dest(modules === false ? esDir : libDir));

  const assets = gulp
    .src(['../components/**/*.@(jpg|jpeg|png|svg)'], ignoreConfig)
    .pipe(gulp.dest(modules === false ? esDir : libDir));

  const source = [
    '../components/**/*.tsx',
    '../components/**/*.ts',
    '../typings/**/*.d.ts',
    '../components/**/*.jsx',
    '../components/**/*.js',
  ];
  const tsProject = ts.createProject('../tsconfig.json');
  const tsResult = gulp.src(source, ignoreConfig).pipe(tsProject());
  const tsFilesStream = babelify(tsResult.js, modules);
  const tsd = tsResult.dts.pipe(gulp.dest(modules === false ? esDir : libDir));
  return merge2([lessStreamToCss, tsFilesStream, tsd, assets]);
}

6. 自定义皮肤 json 处理

定义 theme 文件夹,存放所有皮肤配置,现在需要对 esModule、commonJS、unpkg 三种方式进行编译打包

6.1 编译成 es 和 commonJS

  • 对所有 components/\*\*/.less 文件进行编译处理,剔除 style 文件夹下的文件,只对组件的 less,进行皮肤处理。
  • 每个组件文件夹下,都生成对应都 [themeName].css 文件(缺点是: 共用样式都打多遍)

✋ 梦想很美好,现实很残酷!!

仔细阅读一下这段代码!!!这是生成主题文件.css ,那么会有什么问题的,就是我们通过 through2 插件对文件流进行多次处理,使得 Write callback called multiple times !!!

于是我在 node 官网上看到同一个 issues : NodeJS streams callback called multiple times,说是 remove the cb() in every loop ,当我把 next 移除之后,又报了问题

再回过头看,我们的逻辑是错误的,正确的流程应该是:在 gulp 编译打包 es 和 lib 时,对 theme 文件的数量,进行多个任务处理。比如 10 个主题皮肤,就跑 10 个任务,则不是在一个任务中,进行多任务处理。

将代码流程改写为以下方式即可 :

只需要在 compileTheme 函数中实现生成主题皮肤文件即可~ 最终打包编译出来的 es 和 lib 文件下,组件都带有主题皮肤 css 啦~

7. 使用编译后的主题皮肤文件

编译之后,每个业务组件都需要手动引入主题文件,如

import React from 'react';
import { Button } from '@sugard/lib';
// 手动引入需要的主题皮肤css
import '@sugard/lib/button/index.themeA.css'; // 引入主题A的css
// import '@sugard/lib/button/index.themeB.css'; // 引入主题B的css

事实证明,是可行的,但会有个问题,就是每次开发的人,都需要手动 import 需要的主题色,对于一个应用/系统来说,不会存在多套皮肤,也就是确定了一套皮肤就会在项目中一直使用此皮肤了。

所以在想,如何解决这个问题?

7.1 解决业务组件手动引入主题皮肤 css 问题

我们可以默认主题皮肤 css 已经打包好存在于文件夹了。比如 Button 组件,它存在 index.themeA.css、index.themeB.css 皮肤,那么我们认为它已经存在了,然后在 Button/index.js 中引入皮肤 css 即可

第一步:我们在 components 文件下,找到 Button 组件,然后在 index.tsx 中添加这段代码

import './index.less';
if (window.THEME === 'themeA') {
  require('./index.themeA.css');
} else if (window.THEME === 'themeB') {
  require('./index.themeB.css');
} else {
  // ...
}

意思就是,当我们定义主题是 themeA,那么就会加载 index.themeA.css,但是通过 window.THEME 实在是太粗暴了,通过翻阅文档,知道 webpack.DefinePlugin 支持自定义变量。那么我们只需要业务端在运行项目的时候,在自己的 webpack 配置中定义变量就好啦~

在业务端的 webpack 配置:

plugins: [
  new webpack.DefinePlugin({
    THEME: JSON.stringify('themeA'),
  }),
];

然后对打包之后的 es / lib 组件进行添加这段代码

⚠️ 就是说:我们在打包好组件之后,把主题皮肤文件也打好,然后对打包之后的组件代码中,去添加这段代码(我们默认已经存在这些主题代码了)】

这样就解决了在业务端每次都需要手动 import [themeName.css] 的问题了。

7.2 解决编译打包前,都需要往 components 中每个组件都添加判断条件的代码

虽然我们解决了后期业务端引用时,对主题皮肤的手动引入问题,但是付出的代价就是我们需要改编译打包前的所有 components 组件,都需要手动添加判断条件

那么有没有什么插件,能够不需要修改我原先代码,但是在编译打包的时候,手动插入我想要的一段代码呢?找来找去....好像并没有现成的插件,至此,我有两种解决方案:

  1. 在编译打包之后,gulp.dest() 输出文件流之前,读取文件,然后手动注入
  2. 写一个 babel 插件,通过插件 transform 插入想要的代码块

要挑战困难点的,所以想着:写一个 babel 插件,去处理这块~

【现在想想,自己真的作死!!!】

8. babel-plugin-submit-code 自定义插件

我们期望:在 babel 转码的三个阶段(分析、转换、生成)中,能够在转换阶段,通过自定义插件,将我们的一段代码插入~

如果想了解 babel 自定义插件,可以网上搜一搜相关文章,感兴趣的可以看看这几篇:

建议可以先细心看看文档、文章,然后再去开发一个 babel 插件...

8.1 插入一段源代码,但给我的是字符串

简单做法就是:对于文件中 import './index.less' ,在其后面插入同级节点

然后发现....插入的确实是一段字符串代码,可以看到 lib 文件夹下的 Button,代码如下

但这不是我想要的,于是我试着在插入的时候,把前面的双引号去掉,直接报错,后面通过字符串源码替换节点,才能搞定

最后我们再看看,打包之后的样式是怎样的?

我们插入了一段立即执行函数,可以看到我们的 bundle.js ,在 webpack 编译的时候,会一行一行的执行,然后 require 我们需要的皮肤 css

8.2 对于每个 符合.less 后面的都插入,极其不符

我们只想在 component/button/index.tsx 中,对于此文件。进行插入,但是可能 A.tsx 也 import './index.less' ,这时候 A 文件也插入了这段代码,但这并不是我们所期望的

我们只想对于每个组件的入口文件 index.tsx 进行此操作。

通过看 babel 插件官方文档,打印 path ,发现无从下手,更多的都是对文件内容文本的展示,为了验证,我写了个 demo 组件,然后进行打印

这时候我们打印 babel 转换后的 path

可以在框框中看到,我们插入了 import './index.less;' 这行代码

好像真的没什么可操作的,无法得知该文件的路径,个人理解它就像链条一样,有个 parent Node,我们总不能自己拼接拼接得到整条节点链路。

这里留个悬念,有小伙伴有想法如何解决吗?

结尾

不知不觉,又是给大家撸了一篇【实践+踩坑】的文章,文章略长,但确实我某段时间内的“成长”,特别感谢你能看到这里,希望这篇文章能对你有一丝丝对帮助。

再说说从中的一些收获和感想吧~

【基建很重要】对于我来讲,我本来觉得开发一个公共的组件很简单,但真的投入去做时,竟无从下手,如何设计?怎么写代码更加优雅?所以才会有我前边的前端渣渣的我再也不敢说我会写 Button 组件了文章,当然也从中去阅读了我认为优秀的 antd 源码,也见识到了原来代码可以这么写!

【保持好奇心】其实我对于 babel 真的是不了解,属于“只知其名,未见其状态的境界,不瞒大家,对于 webpack 我也是简单了解,但是通过这次机会,去把项目中的 webpack 配置、打包方式,以及去了解 babel、写一个 babel 插件,这些都是之前我的知识盲区,虽然现在也还是不太熟悉,但最起码我不再停留于知道 babel 是什么,做了什么工作,而是真的去看它工作机制并且基于文档等去撸了一个简易版的 babel 插件。【纸上得来终觉浅,绝知此事要躬行】

【少逼逼,多思考,多沉淀】还是想得少,对于问题需要静下心去研究去排查,耐住性子去插 issues,去找文章解决方案,但我还是有一点缺点,就是解决问题完了就完了,透过表面去看本质,得更深层次的去研究!

相关链接