我的 wu-ui-react 项目

91 阅读6分钟

一、关于 Webpack 的部分配置

Webpack 的主要作用是当我运行 yarn start、yarn build 时可以对 ts、tsx、scss、svg 文件进行打包压缩或者转译,本项目的配置主要还是参考开源项目,并根据个人理解进行配置。

1、package.json

"scripts": {
  "start": "cross-env NODE_ENV=development webpack-dev-server --config webpack.config.dev.js",
  "build": "cross-env NODE_ENV=production webpack --config webpack.config.prod.js",
  "docs": "cross-env NODE_ENV=production webpack --config webpack.config.docs.js",
},

2、配置文件的入口和出口

// 导入 path 模块
const path = require('path')
// 打包成 npm 发布文件
entry: {
  index: './lib/index.tsx'
},
output: {
  path: path.resolve(__dirname, 'dist/lib'),
  library: 'wu',
  libraryTarget: 'umd',
},
  
// 打包成网站部署文件
entry: {
    example: './example.tsx',
},
output: {
    path: path.resolve(__dirname, 'docs'),
},

导出模块中用了 path.resolve() 方法,它的作用是将 __dirname(当前项目的根目录)和 dist/lib 连成一个绝对路径,输出的绝对路径会根据系统的不同而使用不同的斜杠( Linux 系统:\ , window 系统:/ )。

3、配置文件的解析顺序

resolve: {
  extensions: ['.ts', '.tsx', '.js', '.jsx'], // 先解析 .ts 和 .tsx 开头的文件(同名)
},

4、配置模块打包、转译规则

module: {
  rules: [  
    {
      test: /\.tsx?$/,
      loader: 'awesome-typescript-loader'  // 将 ts 文件编译成 js 文件
    },
    {
      test: /\.svg$/,
      loader: 'svg-sprite-loader' // 把 svg 文件变成成雪碧图
    },
    {
      test: /\.scss$/,
      /** sass-loader 把 scss 文件变成 css 文件
      css-loader 将 css 文件变成 js 字符串
      style-loader 将 js 字符串变成 style 标签 **/
      use: ['style-loader', 'css-loader', 'sass-loader']
    }  
  ]
},

5、配置生产环境时不打包的第三方依赖

// 目的主要是减小打包、压缩后文件的体积
mode: 'production',
externals: {
   react: {
      commonjs: 'react',
      commonjs2: 'react',
      amd: 'react',
      root: 'React',
    },
   'react-dom': {
      commonjs: 'react-dom',
      commonjs2: 'react-dom',
      amd: 'react-dom',
      root: 'ReactDOM',
   },
}

6、配置 HTMLWebpackPlugin 插件

// webpack.config.prod.js
const base = require('./webpack.config')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = Object.assign({}, base, {
    mode: 'production',
    plugins: [
        new HtmlWebpackPlugin({
            template: 'example.html',
            filename:'index.html'  // 若不配置,默认生成的也是 index.html
        })
    ],
    // ...some other config
})

HTMLWebpack 插件的主要作用还是在指定的目录生成一个 html 文件,并自动添加一个链接到 js 文件的脚本,如下:

// 原 html 文件
<body>
  <div id="root"></div>
</body>

// 生成后的 html 文件
<body>
  <div id="root"></div>
  <script type="text/javascript" src="example.js"></script>
</body>

二、Icon 组件

每个项目中都会用到很多 svg 图标,如果能把引入 svg 图表的方法封装成一个组件,那么开发效率会高很多,代码也会写起来更舒服。下面是实现代码:

// webpack 上述已配置

// 目录 icon.tsx。
import React from "react";
import "./importAllIcons"; // icons 目录文件一次性导入
interface IconProps extends React.SVGAttributes<SVGElement> {
  name: string;
}
const Icon: React.FunctionComponent<IconProps> = ({
  name,
}) => {
  return (
    <svg>
      <use xlinkHref={`#${name}`}/>
    </svg>
  );
};
export default Icon;

// 目录 importAllIcon.tsx
// require.context() 会一次性导入所有 Icon 文件
// importAll 函数会把将所有 Icon 文件变成模块导出
let importAll = (requireContext) => requireContext.keys().forEach(requireContext)
try {
  importAll(require.context('../icons/', true, /\.svg$/))
} catch (error) {
}

// 目录 test.tsx
import Icon from "**/icon.tsx"
<Icon name="svgName" />    // 只要将代码下载到 Icons 目录,给我文件名,即可直接使用。

了解更多:实现原理和细节

三、Form 组件

在做用户界面的时候,无论是登录界面、还是输入框等交互页面,form 表单都是必不可少的,而且它涉及到的知识点也很多,因此,在这里将它的一些常用功能在加以理解后封装成一个组件,现在对在这个过程中用到的一些知识点进行讲解。

1、使用 table 元素做对 form 表单的样式

<table>
  <tbody>
    <tr>
      <td>Username</td><td><input /></td>
    </tr>
    <tr>
      <td>Password</td><td><input /></td>
    </tr>
  </tbody>
</table>

image.png

2、区分 react 中的受控组件和非受控组件

(1)受控组件,表单中的内容受 react 控制(推荐)

const [name, setName] = useState('frank')

 // 无 onChange 属性:input 中的内容不能改变
<input value={name} />   

// 无 onChange 属性:input 中的内容可以改变
// 这种情况与 React 的函数式设计思想有关,
// 即 UI 中的参数即 state 中的 name 不改变时,不可以直接输入文字。
// 而 name 必须通过函数的方法 setName(xxx) 来改变,而不能在页面直接改变。
<input value={name} onChange={e=>setName(e.target.value) />   

(2)非受控组件,表单中的内容不受 react 控制,可通过 useRef 获取当前值。

const refInput = useRef<HTMLInputElement>(null)
const x = () => {
    console.log(refInput.current!.value)
}  

// 输入完且鼠标离开后打印当前值
<input defaultValue="frank"   ref={refInput}  type="text"  onBlur={x} /> 

3、配置校验器规则,对输入的内容进行检验

检验器是一个函数,并设置了检验规则,当 input 中的内容被提交时,它会对比提交的值是否符合要求,若不符合,则返回 error。

const onSubmit = () => {
  // 检验规则
  const rules = [
    { key: "username", required: true },
    { key: "username", validator },
    { key: "username", minLength: 6, maxLength: 16 },
    { key: "password", required: true },
    { key: "password", pattern: /^[A-Za-z0-9]+$/ },
  ];
  // 检验函数
  Validator(formData, rules, (errors) => {
    setErrors(errors);
    if (noError(errors)) {
      console.log("输入正确,noError。");
    }
  });
};

另外,上述检验过程是被假设成同步的,当检验完后,所有的错误会以一个数组的形式返回。但是,当出现异步操作时,比如说向服务器发送请示,看用户名是否重复时,返回的错误数组集就会出现问题,因为返回同步 error 的时候,异步操作很大可能还未结束,那么,应该怎么解决这个问题?答案是等异步操作结束后再一起返回错误集

// 假设同步返回的错误是一个字符串,而异步返回的是一个 Promise 对象。
// 如果可以把字符串都变 Promise 对象,并将状态都变成 fulfilled。然后,根据 Promise.all() 方法的原理并调用它,就可以解决这个问题。
// Promise.all() 方法会等所有 Promise 对象都执行完后(或者第一个失败)才会往下执行,且参数以数组的形式传递出去。 **/

const newPromises = flattenErrors.map(([key, promiseOrString]) => {
  // 把字符串变成 Promise 实例
  const promise =
    promiseOrString instanceof Promise
      ? promiseOrString
      : Promise.reject(promiseOrString)
     
  // 参数加上 key,它是对 input 的标记,以确定抛出的是哪一个 input 的 error。
  // 由于 then() 中的回调函数的返回值是一个数组,所以,newPromises 对象的状态是 fulfilled。
  return promise.then<[string, undefined], [string, string]>(
    () => [key, undefined],
    (reason) => [key, reason]
  )
})

// results 是一个数组,由所有 error 组成。
Promise.all(newPromises).then((results) => {
  callback(results)
})

四、Dialog 组件

alert() 在平时会经常用到,它属于 Dialog 三种样式的一种,它还包括 confirm 和 modal 两种样式,下面,来说说做这个组件时用到的知识点。

1、利用 react 传送门将对话框放到最顶层

在该组件中,对话框是被设计放到最顶层的,但这时候,如果只是用定位和 z-index 的方式将对话框浮起来,可能就会存在层叠上下文的问题,导致样式错误。因此,点击时,直接将它放到最顶层是一个比较好的方法。

const Dialog: React.FC<Props> = (pros) => {
  // ...some code
  const result = visible && (
    <Fragment>
      // Dialog 组件
    </Fragment>
  );
  return ReactDOM.createPortal(result, document.body); // React 传送门
};

export default Dialog

2、将 Dialog 组件封装成一个函数并导出

// 每次调用函数,会先创建一个 div,然后把 Dialog 组件放到该 div 上,并渲染到页面。
// 点击关闭时,设置组件的 visible 为 false 并重新渲染,然后把 div 删掉。

const modal = (
  title: string,
  content: string | ReactNode,
  maskClosable?: Boolean,
  buttons?: ReactElement[]
) => {
  const close = () => {
    // React.cloneElement(component, { visible: false }) :克隆该组件,并更改 visible 属性。
    ReactDOM.render(React.cloneElement(component, { visible: false }), div)
    ReactDOM.unmountComponentAtNode(div);
    div.remove();
  };
  const component = (
    <Dialog
      title={"Declarative"}
      visible={true}
      onClose={() => close()}
      buttons={buttons}
      maskClosable={maskClosable ? maskClosable : false}
    >
      {content}
    </Dialog>
  );
  const div = document.createElement("div");
  document.body.append(div);
  ReactDOM.render(component, div);
  return close;
}

const alert = (...)=>{...}
const confirm = (...)=>{...}

export { alert, confirm, modal };

五、Scroll 组件

由于系统自带的滚动条都比较“丑”,所以,在做项目时,有时候需要隐藏原生滚动条,并制作自定义滚动条。而获取屏幕数据、对鼠标点击或者触屏等事件进行监听是制作该组件的重点和难点。

1、隐藏原生滚动条

// HTML
<div class="wrapper">
  <div class="item">
    <div>1</div>
    <div>2</div>
       ......
    <div>19</div>
    <div>20</div>
  </div>
</div>

// css
.wrapper{
  border:1px solid red;
  position:relative;
  height:40vh;
  width:300px;
  overflow:hidden; // 溢出隐藏
}
.item{
  border:3px solid blue;
  position:absolute;
  left:0; 
  top:0;
  bottom:0;
  right:-width();  // 计算后的原生滚动条宽度,滚动条通过在父组件设置 overflow:hidden 隐藏
  height:40vh;
  overflow:auto;
}

// JS
let width= function scrollbarWidth() {
  const div = document.createElement('div');
  div.style.position = 'absolute';
  div.style.top = div.style.left = '-9999px'; // 把 div 放到屏幕之外,防止影响用户
  div.style.width = div.style.height = '100px';
  div.style.overflow = 'scroll';
  document.body.appendChild(div);
  const width = div.offsetWidth - div.clientWidth;
  document.body.removeChild(div);
  return width;
}

2、获取当前元素的 scrollHigth、topHigth,并计算自定义滚动条的 barHigth、barTop。

// 获取当前元素的 div 元素
const containerRef = useRef<HTMLDivElement>(null)
<div ref={containerRef} onScroll={onScroll}><div>

// 获取当前元素的滚动高度 scrollHeight | 滚动条顶部区域高度 scrollTop
const height = () => {
  const scrollHeight = containerRef.current!.scrollHeight;
  const scrollTop = containerRef.current!.scrollTop;
  const viewHeight = containerRef.current!.getBoundingClientRect().height; // 视口高度
  return { scrollHeight, scrollTop, viewHeight };
};


// 按比例计算自定义滚动条 barHeight、barTop 的动态高度。
const [barHeight, setBarHeight] = useState(0);
const [barTop, setBarTop] = useState(0);

useEffect(() => {  //  组件渲染到屏幕后执行
  setBarHeight((height().viewHeight * height().viewHeight) / height().scrollHeight)
});
const onScroll = () => {  // 滚动时执行
  setBarTop((height().scrollTop * height().viewHeight) / height().scrollHeight)
};

3、拉动自定义滚动条时,内容自动更新。

// 监听鼠标放到滚动条上、拉动滚动条、离开滚动条三个事件,并更新相关数据。
const onMouseDownBar = (e: React.MouseEvent) => {
  ...
};
const onMouseMoveBar = (e: MouseEvent) => {
  ...
};
const onMouseUpBar = (e: React.MouseEvent) => {
  ...
};

4、在移动端,下拉时,更新数据。

// 监听被触屏前、中、后三个事件,并更新数据。
const onTouchStart = (e: React.TouchEvent) => {
  ...
};
const onTouchMove = (e: React.TouchEvent) => {
  ...
};
const onTouchEnd = () => {
  ...
};

六、总结

该项目做了 6 个 ui 组件,用到的技术栈包括 React、TypeScript、React-dom、React-Router-dom 等。在做项目的过程中,通过配置部分 webpack,使得自己对 webpack 的基本运行、打包原理有了更进一步的认知和理解。另外,项目坚持使用 TypeScript,也减少了很多因为类型错误而导致的 bug,让代码结构变得更加严谨。当然,做各个组件的时候,也遇到了很多问题,比如一次性引入所有 svg 文件的方法、如何解决依赖包冲突问题、应该使用哪些事件类型、ts 类型声明出错、react 路由、form 组件数据更新检验等等,虽然最终都解决了,但这个过程所带给我的经验和知识却是宝贵的。话不多说,项目完成了,后期我也会继续完善和更新,一起期待吧!

项目源码链接:wgbcode/wu-ui-react-2 (github.com)

项目 gitee 网址:Webpack App (gitee.io)