最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 前端组件化实战之 Button

    正文概述 掘金(洛竹)   2021-07-30   586

    洛言

    在 《每个前端都应该拥有自己的组件库,就像每个夏天都有西瓜?》 一文中,洛竹带领小黑从零搭建了一个组件库项目,完成了项目结构、构建、测试、文档等基础工程化工作并完成了第一个组件 Icon。本期延续上期的组件工程化的主题,夏日炎热,点上一杯杨枝甘露,和洛竹赴一场 Button 开发之约吧。赴约后,你将会收获以下的内容:

    前端组件化实战之 Button

    Button 与设计心理学

    作为前端工程师,入行至今接触最多的就是设计师了。耳濡目染下虽说没学会什么设计工具,但是对设计与人的心理有了一定认识。

    洛竹认为任何事物都不可能凭空出现,自有其传承。使用广泛的基础界面元素 Button 也不例外,我们生活中就有随处可见的按钮。举个栗子?,每天上班下班必然要按的电梯按钮、手机音量按钮、小米 9 鸡肋的小爱同学唤起按钮。要搞清楚为什么需要按钮,我们有必要探究下生活中这些按钮的作用。

    点一下的快感

    想象一下把键盘按键换成触摸屏,你最在乎的一定是完美还原物理键的敲击感,像洛竹用手机虚拟键盘就喜欢设置按键震动和音效。通过打击(点击)获得快感是较为普遍的人性。按钮在按下、松开时有丰富的质感和交互感,完美满足了人们点一下的快感。

    前端组件化实战之 Button

    现实的实用性

    从 BB 机到诺基亚再到如今的智能机,实体按钮削减到只剩下音量键和开关机键。按键虽然光秃秃没有任何标识,但我们就是知道它的功能。试想一下没有这个来自远古时代的开关键,你手里的手机就是一块板砖。

    疯狂暗示用户,达到不可告人目的

    小米 9 单独唤起小爱同学的按键经常会被误按,之前我还不理解这么蠢的设计的目的。在简单研究了点设计心理学我明白了。小爱的设计者为了 产品日活和 AI 训练就是故意这个设计的。

    小米 10 虽然移除了单独的唤起键,却把原来的电源键改成了一键多用。每次想要重启手机还得先唤起一下小爱同学。不得不说,小爱同学小米亲女儿。

    前端组件化实战之 Button

    吐槽归吐槽,小米这个按钮确实起到了培养用户习惯的任务。当用户知悉某个按钮能指向某个操作,或者获取某类信息后,长此以往用户就会形成使用习惯。如果某操作能够为用户和厂商持续带来价值,那就可以让按钮的位置更加醒目,持续培养用户点击习惯。

    指引用户操作

    这个在 Web 开发中是最常见的使用场景,每个可交互页面上都有这类按钮的出现,用来指引用户下一步该怎么做。比如表单的提交和重置。

    前端组件化实战之 Button

    虽然按钮也常作为表单元素,但是区别于其他表单元素,按钮因其天然地自说明性,不需要 Label 对其进行辅助说明,啰嗦这么多,掘友们应该在看到一个按钮时,应该也会有从设计上品鉴的意识了,欢迎将对下图的品鉴在评论区告诉洛竹。

    前端组件化实战之 Button

    组件主题化

    在开始开发具体组件之前,我们必须先约定好组件主题化的规范。之前 antd-mobile-rn 就因为设计问题,中途花费大力气重构。几乎所有的组件库都会将色彩、布局这些以 css 变量的形式提供给使用者和开发者为,React Native 不同的是样式基于 CSS in JS,不过道理相通,参照 vant 的设计资源,我们抽出了一套 JavaScript 常量:

    // packages/themes
    
    export interface Theme {
    
      'animation-duration-base': string;
    
      'animation-duration-fast': string;
    
      'animation-timing-function-enter': string;
    
      'animation-timing-function-leave': string;
    
      'font-size-xs': number;
    
      'font-size-sm': number;
    
      'font-size-md': number;
    
      'font-size-lg': number;
    
      'font-weight-bold': number;
    
      // 变量过多,这里仅展示部分变量
    
    }
    

    有了这些 JS 常量,我们就可以设计主题系统。基于 CSS in JS 的主题化设计一般是基于 React Context 实现,需要提供 ThemeProvider 传入主题上下文,ThemeConsumer、WithTheme(高阶类组件)、withTheme(高阶函数组件) 或 useTheme(React Hooks)作为消费者获取上下文。自己实现也不难,不过更文任务比较紧急,我们先基于 cssinjs/theming 实现功能,后期有需要再回来造轮子也不迟。下面?就是我们基于 theming 的 createTheming 函数创建自定义主题上下文。

    import { createTheming } from 'theming';
    
    const context = React.createContext(defaultTheme);
    
    const theming = createTheming(context);
    
    
    
    export const { ThemeProvider, withTheme, useTheme } = theming;
    

    Button 的实现

    React Native 内置的 Button 组件的样式是固定的,只能进行一些简单的设置。且内置的 Button 组件在 Android 和 ios 两个平台上的表现并不一致。所以我们需要根据更底层的组件进行封装。我们对比 ant-design-mobile-rn 和 react-native-elements 后采用了前者使用的 TouchableHighlight 组件。由于继承自 TouchableHighlight,所以我们组件的 Props 类型如下:

    import { TouchableHighlightProps } from 'react-native';
    
    interface ButtonProps extends TouchableHighlightProps {
    
    }
    

    按钮类型

    vant 的 Button 支持 defaultprimaryinfowarningdanger 五种类型,默认为 default。现在,组件的基本定义如下:

    // ...
    
    import React, { FunctionComponent } from 'react';
    import { Text, View } from 'react-native';
    
    interface ButtonProps {
      type?: 'default' | 'primary' | 'info' | 'warning' | 'danger';
    }
    
    const Button: FunctionComponent<ButtonProps> = props => {
      // ...
    };
    // ...
    

    我们的组件为了适应主题化需求,样式不能是写死在组件里的,而是要通过上下文获取样式常量。我们思路是首先使用 useTheme 从上下文中获取主题,然后由于样式定义较多,我们为每个组件编写一个 useStyle hook 放在单独的 style.ts 文件中:

    import { StyleSheet } from 'react-native';
    import { Theme, useTheme } from '@vant-react-native/theme';
    
    export const useStyle = props => {
    
      const theme = useTheme<Theme>();
    
      const getBackgroundColor = () => {
        switch (props.type) {
          case 'primary':
            return theme['success-color'];
          case 'info':
            return theme['primary-color'];
          case 'warning':
            return theme['warning-color'];
          case 'danger':
            return theme['danger-color'];
          default:
            return theme.white;
        }
      };
    
      const getTextColor = () => {
        if (props.type === 'default') {
          return theme.black;
        } else {
          return theme.white;
        }
      };
    
      const getBorderRadius = () => {
        if (props.round) {
          return theme['border-radius-max'];
        }
        if (props.square) {
          return 0;
        }
        return theme['border-radius-sm'];
      };
    
      const styles = StyleSheet.create({
        container: {
          alignItems: 'center',
          backgroundColor: getBackgroundColor(),
          borderColor: getBorderColor(),
          borderRadius: theme['border-radius-sm'],
          borderWidth: theme['border-width-base'],
          flexDirection: 'row',
          flex: 1,
          justifyContent: 'center',
          opacity: 1,
          paddingHorizontal: 15,
        },
        indicator: {
          marginRight: theme['padding-xs'],
        },
        textStyle: {
          color: getTextColor(),
          fontSize: 14,
        },
        wrapper: {
          borderRadius: theme['border-radius-sm'],
          height: 44,
        },
      });
      return styles;
    };
    

    基于 useStyle 我们便可完成一个支持多类型的 Button 组件:

    const Button: FunctionComponent<ButtonProps> = props => {
      const styles = useStyle(props);
      const { style, ...restProps } = props;
    
      return (
        <TouchableHighlight style={[styles.wrapper, style]} {...restProps}>
          <View style={styles.container}>
            {props.loading ? (
               <>
                 <ActivityIndicator
                   size="small"
                   animating
                   color={indicatorColor}
                   style={styles.indicator}
                 />
                   {props.loadingText ? <Text style={styles.textStyle}>{props.loadingText}</Text> : null}
               </>
    
            ) : null}
            {typeof props.children === 'string' ? (
              <Text style={styles.textStyle}>{props.children}</Text>
            ):(props.children)}
          </View>
        </TouchableHighlight>
      );
    };
    

    实现效果如下:

    前端组件化实战之 Button

    按钮形状

    默认的按钮有值为 2 的圆角,vant 中通过 square 设置方形按钮,通过 round 设置圆形按钮。按例,我们通过判断设置样式:

    const getBorderRadius = () => {
      if (props.round) {
        return theme['border-radius-max'];
      }
      if (props.square) {
        return 0;
      }
      return theme['border-radius-sm'];
    };
    
    const styles = StyleSheet.create({
      container: {
        borderColor: getBorderColor(),
      },
      wrapper: {
        borderRadius: getBorderRadius(),
      },
    });
    

    实现效果如下:

    前端组件化实战之 Button

    按钮尺寸

    Antd RN 只提供了 large、small 两个尺寸,而在 vant 中支持 large、normal、small、mini 四种尺寸,默认为 normal。虽然写到这里已经很疲倦了,杨枝甘露也早喝完了,但是为了完整复原,还是续上一杯咖啡继续肝。根据 vant 设计稿我们新增三个样式获取函数并动态化指定样式:

    const getSizeHeight = () => {
      switch (props.size) {
        case 'large':
          return 50;
        case 'small':
          return 32;
        case 'mini':
          return 24;
        default:
          return 44;
      }
    };
    
    const getSizePadding = () => {
      switch (props.size) {
        case 'small':
          return 8;
        case 'mini':
          return 4;
        default:
          return 15;
      }
    };
    
    const getSizeFontSize = () => {
      switch (props.size) {
        case 'large':
          return 16;
        case 'small':
          return 12;
        case 'mini':
          return 10;
        default:
          return 14;
      }
    };
    
    
    const styles = StyleSheet.create({
      container: {
        paddingHorizontal: getSizePadding(),
      },
    
      textStyle: {
        fontSize: getSizeFontSize(),
      },
      wrapper: {
        height: getSizeHeight(),
      },
    });
    

    实现效果如下:

    前端组件化实战之 Button

    自定义颜色

    如果不是自己亲自复刻 Vant,是没想到一个 Button 能玩出这么多花,支持特性这么多耐心和代码管理都是一个挑战。当然了,洛竹采取的样式管理方式比较偏激,大家有好的方式也可以在评论区讨论。

    通过 color 属性自定义按钮的颜色。我们可以得出需求,不管 type 是什么,color 属性需始终覆盖原有样式,color 能影响的就是背景色、字体颜色和边框颜色,所以我们修改 getBackgroundColorgetTextColorgetBorderColor 样式函数在合适的地方加上以下代码即可:

    if (props.color) {
      return props.color;
    }
    

    实现效果如下:

    前端组件化实战之 Button

    前端组件化实战之 Button

    按钮双击事件

    我们从 React Native 内置的 TouchableHighlight 组件继承了很多事件,其中 onPress、onLongPress 分别代表单击和长按。但唯独“双击 666”的双击事件没有姓名。之前在实际业务曾经封装过双击事件,这次我们就直接就内置了。

    实现思路是延时执行单击事件(默认 200 毫秒),然后记录点击次数和两次时间间隔,当识别为第二次点击且时间间隔小于单击延时时间。那么就取消单击事件延时,并立即执行双击事件。完整代码如下:

    let lastTime = 0;
    let clickCount = 1;
    let timeout = null;
    const _onPress = (event: GestureResponderEvent) => {
      const now = Date.now();
      if (timeout) {
        clearTimeout(timeout);
      }
    
      timeout = setTimeout(() => {
        props.onPress(event);
        clickCount = 1;
        lastTime = 0;
      }, props.delayDoublePress);
      if (clickCount === 2 && now - lastTime <= props.delayDoublePress) {
        clearTimeout(timeout);
        clickCount = 1;
        lastTime = 0;
        props.onDoublePress(event);
      } else {
        clickCount++;
        lastTime = now;
      }
    };
    

    大家会发现这里的实现糅合了函数防抖、节流以及计数器的原理,有兴趣的小伙伴可以自行复习下原理,这里就不展开了。

    API 文档

    一个组件的文档,除了 Demo,还需要展示出来可用的 Props,Dumi 内置的 <API></API> 组件可以根据组件自动生成 API 文档。首先我们像下面一样编写 Props 注释:

    interface ButtonProps extends TouchableHighlightProps {
      /**
       * @description       Can be set to primary、info、warning、danger
       * @description.zh-CN 类型,可选值为 primary、info、warning、danger
       */
      type?: 'default' | 'primary' | 'info' | 'warning' | 'danger';
      /**
       * @description       Can be set to large、small、mini
       * @description.zh-CN 尺寸,可选值为
       */
      size?: 'large' | 'normal' | 'small' | 'mini';
    }
    

    然后在 Markdown 中引入 API 组件即可:

    <API src="./index.tsx"></API>
    

    内置组件 API 没有处理继承的情况,我们后续会自定义一个 API 组件,这里就不展开了,浏览 Button 文档 可以查看现在的效果:

    前端组件化实战之 Button

    工程化串讲

    由于很难在一篇文章中将组件开发相关的工程化讲完,在本篇开始 Button 之旅前,我们还是有一些工程化相关的事情要介绍一下。

    组件创建脚手架

    lerna 使用起来是有不少痛点的,lerna create 命令没办法指定模板,考虑到之后的几十上百个组件每次创建都要进行项目结构、Typescript 配置、单元测试配置、Babel 配置等等工作步骤,我们有必要写一个脚手架。

    模板解析

    说到模板解析,相信大家和我一样想到的是 vue-cli 的 template 解析。通过阅读 vue-cli@2.9.6 generate.js 源码,我们可以分析出尤大是基于 metalsmith、handlebars、consolidate 这三个包来实现模板解析能力的。让人不安的是其中 metalsmith 库有长达 5 年没有维护了,洛竹挑选开源项目一般对维护度很敏感,本着轮子要用自己造的原则,我翻看了 Metalsmith 的 Readme 发现这个插件无非是通过递归读文件的方式渲染模板,并且它的静态网站生成的能力对我们模板解析的需求也是多余的。

    说干就干,在和 @林小帅 同学简单沟通后,我动手造了 handlebars-template-compiler 这个轮子,其主要原理如下:

    1. 使用 recursive-readdir 递归获取所有文件路径
    const files = await recursive(rootDir);
    
    1. 使用 handlebars.compile 方法使用元数据对模板进行渲染
    const content = fs.readFileSync(file).toString();
    
    const result = handlebars.compile(content)(meta);
    
    1. 使用 fs.writeFileSync API 重写文件

    另外,通过引入 glob 模式匹配实现了 exclude 配置以及只处理指定后缀(默认 **/*.tpl.*)的文件来避免不必要的渲染。(PS:NPM 一周有了 300 多下载,有需要的掘友值得一试?)

    Node CLI(@vant-react-native/scripts)搭建

    这里洛竹尝试用最简洁的语言为大家描述一个脚手架的诞生,源码在 packages/scripts 目录下,没有接触过 CLI 的掘友请相信我,Node CLI 很容易上手的。接触过的同学也可以查漏补缺借鉴一二。

    1. package.json 文件的 bin 字段是我们脚手架的入口
    // 指定可执行文件的位置以及别名
    
    "bin": {
    
      "vant": "./bin/cli.js"
    
    },
    
    1. 定义 ./bin/cli.js 为可执行文件并调用 init 方法。
    // 由于我们的脚本是 Node 编写的,所以需要指定 node 所在位置
    #!/usr/bin/env node
    const { init } = require('../lib');
    // 这个地方参考了 create-react-native 的设计
    // 本文点赞过 300,下一篇洛竹带小黑为大家带来《基于 TypeScript 重构 create-react-native》
    init();
    
    1. 然后在 src/index.ts 中初始化 commander 这个久负盛名的命令行框架
    const init = (): void => {
      const packageJson = require('../package.json');
      program.version(packageJson.version).description(packageJson.description);
      // ...
      program.parse(process.argv);
    };
    
    1. 为了方便管理命令,我们将命令都放置在 src/commands 目录下并通过 fs.readdirSync API 动态扫描注册。
    const init = (): void => {
      // 这段代码借鉴自 NeteaseCloudMusicApi 项目,作者的代码很有设计感,推荐阅读。
      fs.readdirSync(path.join(__dirname, 'commands')).forEach((file: string) => {
        if (!file.endsWith('.js')) return;
        require(path.join(__dirname, 'commands', file));
      });
      // ...
    };
    
    1. 最后在 commands 目录下新建一个 create.ts 文件编写命令
    import { program } from 'commander';
    
    program
      .command('create <name> [loc]')
      .description('Create a new vant-react-native package')
      .action((name,loc) => {
        console.log('Hello Luozhu');
      })
    

    脚手架实现

    上一小结,我们初始化了 CLI 并添加了 create 命令,这一小节我们就来实现一下脚手架功能。

    我们首先在 packages/scripts 目录下创建组件模板

    .
    
    ├── README.tpl.md # tpl 后缀在生成组件模板的时候会被 handlebars-template-compiler 自动去掉。
    
    ├── package.tpl.json
    
    ├── src
    
    │   └── index.ts # 没有 tpl 后缀则不会被编译,模板很大时可以节省时间。
    
    └── tsconfig.json
    

    然后我们明确我们的模板元数据的数据结构,我这里的数据结构是:

    interface IMeta {
      name: string;
      version: string;
      description: string;
      author: string;
      email: string;
      url: string;
      directory: string;
    }
    

    有了数据结构,我们就可以使用 inquirer 模块引导用户输入信息。

    import inquirer from 'inquirer';
    // ...
    // getQuestions 过长,感兴趣的同学可以查看:http://tny.im/UFbg
    const answer: IMeta = await inquirer.prompt(getQuestions(name));
    // ...
    

    下一步,我们使用 tmp-promise 模块创建一个系统临时文件夹,并将前文提到的 template 文件夹的内容拷贝进去:

    import tmp from 'tmp-promise';
    import fs from 'fs-extra';
    import path from 'path';
    // ...
    const tmpdir = await tmp.dir({ unsafeCleanup: true });
    fs.copySync(path.join(__dirname, '../../template'), tmpdir.path);
    

    最后,我们对临时文件夹的内容进行编译再拷贝到指定位置即可:

    import htc from 'handlebars-template-compiler';
    // ...
    await htc<IMeta>(answer, tmpdir.path);
    fs.copySync(tmpdir.path, `${process.cwd()}/packages/${locPath}`);
    // ...
    

    折腾这一顿,让我们来看下成果吧:

    前端组件化实战之 Button

    Github CODEOWENERS

    大型的开源项目最难的不是技术问题,技术大咖永远不会缺。最难的其实是协作和后期维护。试想一下一个成百上千人参与的项目当有新的 pr 时,正常人根本无力去快速检索出需要谁去 review 代码。我们的 vant-react-native 由于是将每个组件单独发包维护,当参与的小伙伴多了也会产生这个困扰。

    而 GitHub CODEOWNERS(代码所有者)就是为了解决这个问题的,在 5000+ 贡献者参与的 DefinitelyTyped 项目中我们就可以看到它的身影。官方对代码所有者定义如下:

    CODEOWNERS 文件使用遵循 gitignore 文件中所用大多数规则的模式,CODEOWNERS 文件位置一般位于 .github/ 目录下。

    在 vant-react-native,洛竹是仓库的最终负责人,所以是期望每个 pr 都可以分配给自己审查一下的。那么我们这就来实验一下吧,新建一个 .github/CODEOWNERS 文件并写入以下内容:

    # This is a comment.
    # Each line is a file pattern followed by one or more owners.
    # These owners will be the default owners for everything in
    # the repo. Unless a later match takes precedence,
    # @youngjuning will be requested for review when someone opens a pull request.
    *       @youngjuning
    
    # In this example, @doctocat owns any files in the build/logs
    # directory at the root of the repository and any of its
    # subdirectories.
    /packages/ @luozhu1994
    

    一般如果文件具有代码所有者,则在打开拉取请求之前可以看到代码所有者是谁。在仓库中,你可以找到文件并悬停于一个锁图标上,悬浮之后会告诉你该文件所有者是谁:

    然后我们提交一个 pr 看看效果:

    前端组件化实战之 Button

    NPM 发包自动化

    发包权限一般只有仓库所有者一个人拥有,但是 owner 同时维护好几个 NPM 账号,或者是 owner 忽然很忙将发布权限交给其他人管理员但是不便告知 NPM 账号该怎么办呢?答案是将 NPM 发包 CD(持续部署)化,公司一般会基于 Gitlab 或自建平台实现该功能。作为开源项目,我们当然是使用 GitHub Action。

    正常的单包项目,使用 npm-publish 或 npm-publish-action 这两个 GitHub Action,这并没有好讲的。但是基于 lerna 的多包单体仓库并没有现成的插件可以用,照例,我们来看下自己实现的步骤:

    1. 判断 commit message 是否以 chore(release): 开头
    1. 通过 NPM publish token 认证登录
    1. 执行 lerna publish from-package --yes 发布

    完整 GitHub Action 实现如下:

    name: npm-publish
    on:
      push:
        branches:
          - main
    
    jobs:
      npm-publish:
        runs-on: ubuntu-latest
        if: startsWith(github.event.head_commit.message, 'chore(release):')
        steps:
          - uses: actions/checkout@v2
          - uses: c-hive/gha-yarn-cache@v2 # 缓存 node_modules 加快构建速度
          - name: Install Packages
            run: yarn install --registry=https://registry.npmjs.org/
          - name: Authenticate with Registry
            run: |
              npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN}
            env:
              NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          - name: Publish package
            run: lerna publish from-package --yes
    

    为了在发布后及时获取通知,洛竹使用了 peter-evans/commit-comment 插件在发布失败或成功后对相应 commit 进行评论,这样我们就可以收到邮件和站内通知。

    - name: Create commit comment after publish successfully
      if: ${{ success() }}
      uses: peter-evans/commit-comment@v1
      with:
        body: |
          Hello Dear @youngjuning. This commit has been publish to NPM successfully.
          > Created by [commit-comment][1]
          [1]: https://github.com/peter-evans/commit-comment
    - name: Create commit comment after publish unsuccessfully
      if: ${{ failure() }}
      uses: peter-evans/commit-comment@v1
      with:
        body: |
          Hello Dear @youngjuning. This commit has been publish to NPM unsuccessfully.
          > Created by [commit-comment][1]
          [1]: https://github.com/peter-evans/commit-comment
    

    近期好文

    • 每个前端都值得拥有自己的组件库,就像每个夏天都拥有西瓜?
    • 基于 lerna 的多包 JavaScript 项目搭建维护
    • 一文搞定 Conventional Commits
    • 2021 年最值得使用的 Node.js 框架
    • React 面试必知必会系列
    • Go 语言教程系列

    致谢

    截止发稿时,每个前端都值得拥有自己的组件库,就像每个夏天都拥有西瓜? 已获得近 1600 赞、超 4 万阅读?,再次再次感谢掘友的支持、编辑 Zoe 的鞭策,月影大佬的转载、朋友的转发以及自己的坚持。


    起源地下载网 » 前端组件化实战之 Button

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元