「RaxJs」 实操入门

前言

最近在学习 Rax 官方文档,本人自打前端以来就紧跟尤大大的脚本,对于 react 是零基础。本篇记录个人阅读 Rax 官方文档需要学习的地方,以及记录实战过程遇到的情况。

在此之前跟着 写给跨端玩家:支撑淘宝上亿日活的跨端框架—— Rax 的入门教程(附 TODO Demo) 文章(这文章名字是真的长)过了一次入门案例。

基础指南

目录结构

.
├── .rax/                          # 运行时生成的临时目录
├── build/                         # 构建产物目录
├── public                         # 本地静态资源
│   └── favicon.png
├── src
│   ├── app.json                   # 路由及页面配置
│   ├── app.ts                     # [小程序|SPA]应用入口
│   ├── miniapp-native/            # [小程序]小程序原生代码
│   ├── components/                # 自定义业务组件
│   ├── pages/                     # 页面
│   ├── models/                    # 应用级数据状态
├── build.json                     # 工程配置
├── package.json
└── tsconfig.json

复制代码
  • git 提交时,应忽略 .rax/ 临时目录

  • pages:项目的页面文件目录,推荐的目录形式如下。

    Home/                    # Home 页面
      ├── model.ts           # 页面级数据状态
      ├── index.tsx          # 页面入口
      └── index.module.scss  # 页面样式文件
    复制代码
    • 注意页面样式文件为 index.module.scss 而非 index.scss
    • 在当前页面中创建 components 目录存放当前页面级的组件。
  • /src/components:目录存放当前项目级的公共组件,供其他页面或其他组件使用。

  • models/:项目的全局数据模型目录,通常包含多个 model 文件。

    • models/
        ├── foo.ts
        └── bar.ts
      复制代码
  • app.json:负责应用的运行时相关配置,如 routes、小程序 window 配置等。

  • build.json:是整个应用构建相关的配置文件,相关配置可参考工程配置。

  • tsconfig.json:TypeScript 编译所需的配置文件。

调试

package.json 会配置以下命令

{
  "scripts": {
    "start": "rax-app start",
		...
  }
}
复制代码
  • 执行 npm run start 即可进行项目开发

  • 正常情况下执行命令后默认自动打开浏览器 http://localhost:3333 进行页面预览

  • 热更新:修改源码后浏览器会自动刷新页面。

  • start 命令支持的完整参数如下:

    • $ rax-app start --help
      
      Usage: rax-app start [options]
      
      Options:
        -p, --port <port>      服务端口号
        -h, --host <host>      服务主机名
        --config <config>      指定配置文件
        --https                支持开启 https
        --analyzer-target      指定需要进行构建分析的任务,例如 `--analyzer-target=web`
        --analyzer-port <port> 支持定制构建分析端口
        --disable-reload       禁用热更新模块
        --disable-mock         禁用 mock 服务
        --disable-open         禁止浏览器默认打开行为
        --enable-assets        开启命令行输出 webpack assets 信息
        --mpa-entry            指定多页应用需要编译的页面
        
      复制代码
    • 例子:修改 package.json 中的命令 "start": "rax-app start --port 3000"

      image-20210623110841441.png

    • 当端口占用冲突时,rax 会提示是否运行在下一个端口号。
      image-20210623111148807.png

构建

package.json 会配置以下命令

{
  "scripts": {
    "build": "rax-app build",
		...
  }
}
复制代码
  • 执行 npm run build 进行项目构建,构建产物默认输出到 ./build 目录下。

  • build 命令支持的完整参数如下:

    • $ rax-app build --help
      
      Usage: rax-app build [options]
      
      Options:
        --analyzer-target      指定需要进行构建分析的任务,例如 `--analyzer-target=web`
        --analyzer-port <port> 支持定制构建分析端口
        --config <config>      指定配置文件
      复制代码

问题

  • 首次执行 npm run build 遭遇报错:PostCSS plugin postcss-discard-comments requires PostCSS 8.:错误:postss插件postss discard comments需要postss 8。

这个错误就是工程化化插件的 postcss-discard-commentspostcss的版本不匹配,因此有两种解决方案:

image-20210623111638213.png

  1. 降低 postcss-discard-comments 版本
    • 需要知道该插件对应的低版本,不确定多低的版本可以打包构建
  2. 安装最新版本的 postcss
    • npm i postcss -S
  • 结果再次执行 npm run build,可以打包了,但是 node 插件构建 postcss 时报错。

image-20210623140245931.png

查询了好久的资料,在 github.com/mrnocreativ… 这里找到了答案。

大抵原因的意思是:PostCSS8 与 node 的 npm 不兼容,下面有个回答说 npm is still using postcss 6.0.1,npm 当时的版本才对标 postcss 6.0.1 版本

最后我去找 Rax 的钉钉社区询问了一下,很快得到了答案:cnpm 已经锁了版本,可以使用 cnpm 来进行安装插件。也就是说 cnpm 安装的插件对其 css 的插件进行了降级处理,就处理了兼容问题。

解决

  1. 删除 node_modules 文件夹。
  2. 使用 cnpm install 进行依赖安装。
  3. 安装成功后执行 npm run build

image-20210623141855617.png

小插曲

我在利用谷歌直接打开 build 打包好的 index.html 文件时,报错 from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, chrome, chrome-extension, chrome-untrusted, https. 跨域问题。

原因是谷歌 file 协议访问本地的文件时都会有跨域的问题

解决方法如下有以下几种。

  1. 搭建本地服务器,跑在服务器上访问本地文件。

  2. 利用 idea 插件打开,如 webStrom 中打开 html 文件时,会自动开启服务。

  3. anywhere 插件开启服务:

  4. 全局安装 anywhere 插件:npm i anywhere

  5. 进入对应文件夹目录下跑动服务:anywhere

    image-20210623144345020.png

  6. 将当前目录变成一个静态文件服务器的根目录,即可解决浏览器访问本地文件的跨域问题。

工程配置

rax-app 工程构建相关的配置默认都收敛在项目根目录的 build.json 文件中, 默认采用 JSON 格式配置。

详情于 官方文档

环境配置

Rax App 支持区分不同环境,开发者可根据环境区分运行时配置

设置环境

默认情况下支持 start/build 两个环境。对应的即 rax-app start/build 两个命令,开发者可以通过 --mode 参数来扩展环境:

{
  "scripts": {
    "start": "rax-app start --mode local",
    "build:daily": "rax-app build --mode daily",
    "build": "rax-app build --mode prod"
  }
}
复制代码

image-20210623211921884.png

区分工程配置

定义好环境,我们就要根据环境来区分配置。

build.json 通过 modeConfig 属性

{
  "modeConfig": {
    "prod": {
      "define": {},
      "minify": false
    },
    "daily": {
      "define": {},
      "minify": true
    }
  }
}
复制代码

同时在本地插件 build.plugin.js 也可以从 context 上获取到当前 mode:

module.exports = ({ context }) => {
  const { command, commandArgs } = context;
  const mode = commandArgs.mode || command;
}
复制代码

image-20210623213035771.png

区分运行时配置

在定义好环境之后,前端代码中即可通过 APP_MODE 拿到当前环境:

import { APP_MODE } from 'rax-app';

console.log('APP_MODE', APP_MODE);
复制代码

当然大多数时候你都不需要关心 APP_MODE 这个变量,只要按照约定的方式区分不同环境的配置即可。在 src/config.ts 中编写不同环境的配置:

// src/config.ts
export default {
  // 默认配置
  default: {
    appId: '123',
    baseURL: '/api'
  },
  local: {
    appId: '456',
  },
  daily: {
    appId: '789',
  },
  prod: {
    appId: '101',
  }
}
复制代码

配置之后框架会自动根据当前环境将配置进行合并覆盖,开发者只需要在代码中直接使用 config 即可:

import { config } from 'rax-app';
console.log(config.appId);
复制代码

image-20210623214313955.png

image-20210623214328190.png

在期间遇到了 eslint 报错 Unexpected console statement no-console,需要设置 eslint "no-console":"off"window.console.log(),不知道怎么配置 Rax 框架中的 .eslintrc.js 文件。

待有更好的方法学习记录

动态扩展运行时环境

应用有日常/预发两个运行环境,但实际上只能进行一次构建任务,此时则可以通过运行时扩展环境来支持不同配置。

src/config.ts 中通过域名来扩展环境:

// src/config.ts:动态扩展环境:两种方式

// 方式 1. 通过服务端输出到页面上的全局变量
window.__app_mode__ = window.g_config.faasEnv;  // window.g_config.faasEnv 也可能是 window.__env__,具体看服务端怎么约定
  
// 方式 2. 通过 url 地址动态判断 
if (/pre.example.com/.test(window.location.host)) {
  window.__app_mode__ = 'pre';
} else if (/daily.example.com/.test(window.location.host)) {
  window.__app_mode__ = 'daily';
} else if (/example.com/.test(window.location.host)) {
  window.__app_mode__ = 'prod';
} else {
  window.__app_mode__ = 'local';
}

export default {
  default: {},
  daily: {},
  pre: {},
  prod: {}
};
复制代码

无接口,暂时做不了实操,设置好全局变量 __app_mode__ 即可。

编写组件

Component

组件,在概念上类似于 Javascript 函数。它接受从父级元素传入的数据(即 Props),并返回用于描述页面展示内容的 Rax 元素。组件使得我们的UI界面分成独立的、可重用的部分,并且,每个部分可以单独维护。

定义组件有两种方式,分别为 Function Component, 和 Class Component

Function Component

是定义组件最简单的方式,这种方式会让代码变得更加简洁,并且可以使用 Hooks 编写更易维护和扩展的组件。我们推荐用 Function Component 来定义组件

import { createElement } from 'rax';

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}
复制代码

另外,我们也可以使用 ES6 的 class 来编写 Class Component:

import { createElement, Component } from 'rax';

class Welcome extends Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}
复制代码

需要注意的是,Rax 组件在命名时首字母必须大写。首字母为小写的组件会被视为原生 DOM 标签。

实操

两个组件定义方式

  • Function Component 实操案例参考: 写给跨端玩家:支撑淘宝上亿日活的跨端框架—— Rax 的入门教程(附 TODO Demo)

  • Class Component:自己封装 Class 的案例 ListItem2

    • ListItem2/index.tsx
    import { createElement, Component } from 'rax';
    import View from 'rax-view';
    import Text from 'rax-text';
    
    class ListItem2 extends Component<{ // eslint-disable-line
      id?: Number;
      done?: Boolean;
      content?: String;
      onClick: (id?: Number) => void;
    }> {
      style = {
        fontSize: '64rpx',
        lineHeight: '96rpx',
        textDecoration: this.props.done && 'line-through',
      };
      render() {
        return (
          <View className="list-item" onClick={() => this.props.onClick(this.props.id)}>
            <View className="list-dot" />
            <Text style={this.style}>{this.props.content}</Text>
          </View>
        );
      }
    }
    export default ListItem2;
    
    复制代码
    • List/index.tsx 调用
    ...
    import ListItem2 from '../ListItem2';
    
    ....
    <View className="list-item-wrapper">
    	{list.map((item) => (
    		<ListItem2
    			key={item.id}
    			id={item.id}
    			content={item.content}
    			done={item.done}
    			onClick={handleItemClick}
    		/>
      ))}
    </View>
    
    复制代码

实现效果:

image-20210624111853112.png

BUG

  • node 版本不匹配

image-20210624103822289.png

image-20210624103839778.png

需要 node 版本 >= 13.9.0

  • esLinst 严格规则警告

image-20210624111228983.png

因为 esLint 不推荐使用类组件,咱们只是一个实操,主要几个方法让 esLint 忽视文件中第五行的 class 声明即可。

Props

Props 为组件接受的入参,通过 Props, 组件可以接收来自父级节点的数据。Props 都是只读的,不能在当前组件中修改 Props 的值

State

State 是组件的私有变量,由组件自身控制,并与 Props 一起控制组件的渲染。我们使用 useState 这一 Hook 来进行定义和管理 State 。在下面的例子里,我们使用了 useState 来实现了一个计数器:

import { createElement, useState } from 'rax';

function Example() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
复制代码

useState 参数是初始 state,会返回一个数组。数组第一个值是 state,第二个值是改变 state 的函数。上述例子中 count 与 setCount 是一一配对的,count 的值只能通过 setCount 修改。

image-20210624113953072.png

Fragment

Fragment 是一个特殊组件,可以在不创建额外 DOM 元素的情况下,将多个元素组合在一起。在 rax 组件渲染时,若需要渲染多个元素,需要使用 Fragment 将元素包裹在内。

import { createElement, Fragment } from 'rax';

function Hello() {
  return (
    <Fragment>
      <h1>Hello</h1>
      <h1>World</h1>
    </Fragment>
  );
}
复制代码

Fragment 组件只支持 key 属性 和 部分 JSX+ 属性即 x-if x-for x-slot,不支持其他属性。当 Fragment 组件不需要填写属性时,我们也可以使用<></>来代替。

import { createElement } from 'rax';

function Hello() {
  return (
    <>
      <h1>Hello</h1>
      <h1>LinM</h1>
    </>
  );
}
复制代码

image-20210624114329379.png

<></> 标签需在最外面,不能被其他标签所包裹。否则如下返回:

image-20210624114459509.png

Hooks

Hooks 是 Rax 1.0 新增的特性,它可以让函数组件(Function Component)使用 状态生命周期。Rax 在实现上遵循了 React Hooks 的标准。

常用 Hooks

useState

useState 主要用来定义和管理本地状态。在上面的例子里,我们使用了 useState 来实现了一个计数器:

image-20210624113953072.png

  • useState 参数是初始 state,会返回一个数组。
    • 数组第一个值是 state,第二个值是改变 state 的函数。
  • 上述例子中 count 与 setCount 是一一配对的,count 的值只能通过 setCount 修改。
useEffect

useEffect 的代码在每次渲染后运行,包括第一次渲染:

import { createElement, useState, useEffect } from 'rax';

function Example() {
  const [count, setCount] = useState(0);

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
复制代码

useEffect 它跟类组件(Class Component)中的 componentDidMountcomponentDidUpdatecomponentWillUnmount 具有相同的用途,只不过被合并成了一个 API。

跟 Vue3 的 useEffect API 效果十分相似,我也就进行了类比。

Hooks 使用规则

Hooks 就是 JavaScript 函数,但是使用它们会有两个额外的规则:

  • 只在函数最顶层调用 Hooks,不要在循环、条件判断或者子函数中调用。
  • 只在 Rax 函数组件自定义 Hooks 中调用 Hooks,不要在其他 JavaScript 函数中调用。

JSX

const element = <p>Hello, world!</p>;
复制代码

这种类似于 HTML 标签的语法被称为 JSX。JSX 是一种 JavaScript 的语法扩展,在 Rax 中 我们使用 JSX 来描述页面结构。下面简单介绍 JSX 的基本使用方法。

JSX 的声明方式和普通 HTML 标签一样,用 <> 标签包裹,也可以嵌套:

const element = (
  <div>
    <h1>Hello!</h1>
    <h2>Good to see you here.</h2>
  </div>
);
复制代码

假如标签内没有子元素,可以使用 /> 来闭合标签,也就是单标签:

const element = <img src="url" />
复制代码

表达式

JSX 中可以插入任意 JavaScript 表达式。JSX中的表达式必须写在大括号 {} 中:

const element = <h1>{'Hello' + ',' + 'world!'}</h1>;
复制代码

表达式中不能使用 if else 语句,但可以使用三元运算符 a ? b : c 来实现条件选择。

const element = <h1>{true ? 'True!' : 'False!'}</h1>
复制代码

使用 JSX 数组

可以在一个 JSX 元素中直接嵌套包含多个元素的数组,数组内的 JSX 元素会被逐个渲染:

const arr = [
  <span>Hello, world!</span>,
  <span>Hello, Rax!</span>,
];
const element = <p>{arr}</p>;
复制代码

在 JSX 中注释

JSX 注释和表达式一样,必须写在大括号 {} 中:

const element = <p>{/*注释...*/} Hello, world!</p>;
复制代码

实操

import { createElement } from 'rax';
import View from 'rax-view';

function Jsx() {
  const element = <h1>{'Hello, world!'}</h1>;
  const arr = [
    <span>Hello, world! </span>,
    <span>Hello, Rax!</span>,
  ];
  const comment = <p> { /* 注释... */ } Hello, world!</p>;
  return (
    <View>
      普通声明:
      <div>
        <h1>Hello!</h1>
        <h2>Good to see you here.</h2>
      </div>
      表达式:
      <div>
        {element}
      </div>
      使用 JSX 数组:
      <div>
        {arr}
      </div>
      在 JSX 中注释:
      <div>
        {comment}
      </div>
    </View>
  );
}

export default Jsx;

复制代码

image-20210624145545258.png

JSX+ 语法

Rax 支持了一种 JSX 扩展语法 JSX+,它能帮助业务开发者更爽更快地书写 JSX。JSX+ 不是一种新的概念,它是 JSX 基础上的扩展指令概念。具体语法如下:

1. 条件判断

语法:

<View x-if={condition}>Hello</View>
<View x-elseif={anotherCondition}></View>
<View x-else>NothingElse</View>
复制代码

注: x-elseif 可以多次出现,但是顺序必须是 x-if -> x-elseif -> x-else,且这些节点是兄弟节点关系,如顺序错误则指令被忽略。

2. 循环列表

语法:

{/* Array or Plain Object*/}
<tag x-for={item in foo}>{item}</tag>
  
<tag x-for={(item, key) in foo}>{key}: {item}</tag>
复制代码

说明: 1. 若循环对象为数组,key 表示循环索引,其类型为 Number。 2. 当 x-forx-if 同时作用在同一节点上时,循环优先级大于条件,即循环的 itemindex 可以在子条件判断中使用。

3. 单次渲染

仅在首次渲染时会触发 createElement 并将其引用缓存,re-render 时直接复用缓存,用于提高不带绑定节点渲染效率和 Diff 性能。

语法:

<p x-memo>this paragragh {mesasge} content will not change.</p>
复制代码

4. 插槽指令

类似 WebComponents 的 slot 概念,并提供插槽作用域。

语法:

<tag x-slot:slotName="slotScope" />
复制代码

示例:

// Example
<Waterfall>
  <view x-slot:header>header</view>
  <view x-slot:item="props">{props.index}: {props.item}</view>
  <view x-slot:footer>footer</view>
</Waterfall>
<slot name="header" /> // 槽位
复制代码

5. Fragment 组件

使用 x-if x-for x-slot 指令时,若不希望产生无意义的元素,我们可以使用 Fragment 组件。

使用:

<Fragment x-if={condition}>
  <div />
</Fragment>
复制代码

6. 类名绑定

语法:

<div x-class={{ item: true, active: val }} />
复制代码

参考实现:

<div className={classnames({ item: true, active: val})} />
复制代码

classnames 方法能力参考同名 npm 包

自定义 Hooks

通过自定 Hooks 能够将组件逻辑提取到可重用的函数中。

import { createElement, useState, useEffect } from 'rax';

const useDocumentTitle = function(title) {
  useEffect(
    () => {
      document.title = title;
    },
    [title]
  );
};

function Example() {
  const [count, setCount] = useState(0);
    useDocumentTitle(`You clicked ${count} times`)
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
复制代码

上述示例中 useDocumentTitle 是一个自定义 Hooks,可以在其他组件中执行设定 title 的逻辑。如果函数的名字以 “use” 开头并调用其他 Hooks,我们就说这是一个自定义 Hooks

image-20210624151132209.png

Hooks 与类组件生命周期的对应关系

至此相信您已经对 Hooks 有了基本的了解。对照 类组件的生命周期,可通过以下 Hooks 实现对应的生命周期:

  • useEffect
    • componentWillUpdate
    • componentDidMount
    • componentWillUnmount
  • useLayoutEffect
    • 与 useEffect 一致,用于获取元素渲染结果
  • useMemo、useCallback
    • shouldComponentUpdate

使用 useEffect 完成 componentWillUpdate 的效果,示例:

function DemoComponent() {
  useEffect(() => {
    // ...
  });
  return <p>Hello hooks!</p>
}
复制代码

使用 useEffect 可以实现 componentDidMount 的功能,示例:

function DemoComponent() {
  useEffect(() => {
    // useEffect 的第二个参数为[]时,表示这个 effect 只会在 componentDidMount、componentWillUnMount 的时候调用。
  }, []);
  return <p>Hello hooks!</p>
}
复制代码

使用 useEffect 可以实现 componentWillUnmount 的功能,示例:

function DemoComponent({source}) {
  useEffect(() => {
    const subscription = source.subscribe();
    return () => {
      // componentWillUnMount 调用的是第一个参数返回的回调。
      subscription.unsubscribe();
    };
  }, [source]); // 表示 source 改变时就是执行一遍
  return <p>Hello hooks!</p>
}
复制代码

使用 memo 可以实现 shouldComponentUpdate 的功能,示例:

function DemoComponent(props) {
  return <h1>I am {props.name}. hi~</h1>;
}
memo(DemoComponent, (prevProps, nextProps) => prevProps.name === nextProps.name);
// 添加第二个参数来指定采用旧 props 和新 props 的自定义比较函数。如果返回 true,则跳过更新。
复制代码

更多

样式方案

非内联样式(CSS)

对于投放到 Web 或者小程序的应用,我们强烈推荐使用标准的 CSS 来编写样式。

全局样式

对于整个项目的全局样式,推荐统一定义在 src/global.[css|less|scss] 文件中,框架会自动引入该文件:

body {
  -webkit-font-smoothing: antialiased;
}
a {
  color: red;
}
复制代码

image-20210624152551424.png

组件样式

对于页面级和组件级的样式,我们推荐使用 CSS Modules 的方案,这能很好的解决样式开发中全局污染以及命名冲突的问题。

具体规范规则如下:

  • 文件名:约定文件名格式为 xxx.module.[css|less|scss]
  • 模块化:一个页面或者一个组件对应一个样式文件
Home
├── index.module.css
└── index.tsx
复制代码

首先编写样式文件内容:

/** ./pages/Home/index.module.css */
.container {
  background: #fff;
  width: 750rpx;
}

/* 也可通过 CSS Modules 的 :global 语法定义全局样式 */
:global {
  body {
    a {
      color: blue;
    }
  }
}
复制代码

image-20210624152634224.png

在文件中引入对应的样式文件,并将 className 与对应样式关联:

// ./pages/Home/index.tsx
import styles from './index.module.css';

function Home() {
  return (
    <View className={styles.container}>
      <View>CSS Modules</View>
    </View>
  );
}
复制代码

使用该方案之后,上文中的 className 都会被编译为唯一性的名字,避免因为重名 className 而产生样式冲突,如果在浏览器里查看这个示例的 dom 结构,你会发现实际渲染出来是这样的:

<View class="container--1DTudAN">title</View>
复制代码

image-20210624152706847.png

内联样式

如果在 build.json 里配置了 inlineStyle: true 则说明整个项目使用内联样式。对于 Weex、Kraken 这些暂不支持 CSS 的渲染引擎,只支持内联样式(即通过元素的 style 属性设置样式)。

const myStyle = {
  fontSize: '24px',
  color: '#FF0000'
};

const element = <View style={myStyle}>Hello Rax</View>;
复制代码

注意:内联样式无法支持 CSS 中 @keyframes 等相关能力

同时也支持在 CSS 文件中编写样式,然后通过编译工具最终生成内联的样式,具体使用方式如下:

image-20210624153154088.png

index.css

/** index.css */
.container {
  background: #f40;
  padding: 30rpx;
}
复制代码

index.tsx

import { createElement } from 'rax';
import './index.css';

export default function Title() {
  return (<View className="container">...</View>);
}
复制代码

内联场景下也可以使用 CSS Modules 的文件命名规范以及写法,这样可以保证两种模式下样式的写法是基本一致的。

自适应单位 rpx

Rax 采用 rpx(responsive pixel) 作为跨端的样式单位,它可以根据屏幕宽度进行自适应。我们规定屏幕宽度为 750rpx,以 iPhone6 为例,它的屏幕宽度为 375px,则 750rpx = 375px = 100vw,所以在 iPhone6 中,1rpx = 0.5px = 100/750vw。

设备 rpx 换算 px (屏幕宽度 / 750) px 换算 rpx (750 / 屏幕宽度)
iPhone5 1rpx = 0.42px = 100/750vw 1px = 2.34rpx = 234/750vw
iPhone6 1rpx = 0.5px = 100/750vw 1px = 2rpx = 200/750vw
iPhone7 Plus 1rpx = 0.552px = 100/750vw 1px = 1.81rpx = 181/750vw

建议开发 Rax 页面时设计师用 750 作为设计稿的标准

从内联样式迁移到非内联样式

某些 Web 应用可能因为一些历史原因导致使用了内联样式的方案,对于此类应用我们推荐迁移到非内联样式,这样可以使用更加强大的 CSS 能力。

移除 inlineStyle 属性或将其置为 false

// build.json
{
-  "inlineStyle": true
}
复制代码

确保原先的 className 是否有冲突的情况,如果有则需要修正或者迁移到上述 CSS Modules 的写法。

CSS 预处理器

Rax App 默认支持了 Less 和 Sass 预处理器,你只需要按照 .less.scss 的规则命名文件即可。如果使用 Sass 你还需要将 sass 的编译器比如社区推荐的 dart-sass:

$ npm i --save-dev sass
复制代码

Dark mode 适配

可通过 media 设置对应的主题色。

@media (prefers-color-scheme: dark) {
  .container {
    background-color: #111;
  }
}
复制代码

CSS Modules 文件类型报错

如果在编辑器中遇到 ts 错误 找不到模块“./index.module.css”或其相应的类型声明,需要在 src/ 下新建 typings.d.ts 文件:

// src/typings.d.ts
declare module '*.module.css' {
  const classes: { [key: string]: string };
  export default classes;
}
复制代码

CSS Modules 官方文档

进阶指南

框架 API

框架 API 通常情况下可以通过 rax-app 导入,如:import { runApp } from 'rax-app';

基础

runApp

对于 SPA 应用或者小程序应用,src/app.tsx 即整个应用的入口,通过 runApp() 运行应用:

import { runApp } from 'rax-app';

runApp({});
复制代码

配置的对象参数

当在 app.ts 中包含 JSX 元素的时候,需要将 app.ts 更名为 app.tsx

通过 app.getInitialData() 可以在应用启动前获取一些异步状态,比如登录态判断、用户信息获取等。

import { runApp } from 'rax-app';

const appConfig = {
+  app: {
+    getInitialData: async () => {
+      // const data = await fetch('/api/data');
+      return { userId: '123' };
+    }
+  },
};

runApp(appConfig);
复制代码

对于获取到的初始数据,可以通过 getInitialData API 在任何地方消费:

import { createElement } from 'rax';
+import { getInitialData } from 'rax-app';

export default = () => {
+  const initialData = getInitialData();
  console.log(initialData.userId);
};
复制代码

APP_MODE

获取应用环境。

ErrorBoundary

用于错误边界的组件。

IAppConfig

appConfig 的类型定义。

+import { runApp, IAppConfig } from 'rax-app';

+const appConfig: IAppConfig = {
  app: {}
+}

runApp(appConfig);
复制代码

状态管理

可以通过在 build.json 中设置 store: false 关闭状态管理。

store

应用级别的 store 实例。

定义类型

IStoreModels

定义模型的类型。

import { IStoreModels, IStoreDispatch, IStoreRootState } from 'rax-app';

// 定义模型的类型。
interface IAppStoreModels extends IStoreModels {
};

const models = {};
// 定义 Dispatch 的类型。
export type IRootDispatch = IStoreDispatch<typeof models>;
// 定义 RootState 的类型。
export type IRootState = IStoreRootState<typeof models>;
复制代码

路由

getHistory

getHistory 用于获取 history 实例。

import { getHistory } from 'rax-app';

function HomeButton() {
  const history = getHistory();

  function handleClick() {
    history.push('/home');
  }

  return (
    <button type='button' onClick={handleClick}>
      Go home
    </button>
  );
}
复制代码

getSearchParams

仅支持 SPA 和小程序,不支持 MPA 应用

用于解析 url 参数。假设当前 URL 为 https://example.com?foo=bar,解析查询参数如下:

// src/components/Example
import { getSearchParams } from 'rax-app';

function Example() {
  const searchParams = getSearchParams()
  // console.log(searchParams); => { foo: 'bar' }
}
复制代码

withRouter

通过在 Class 组件上添加 withRouter 装饰器,可以获取到路由的 historylocationmatch 对象。

import { createElement } from 'rax';
import { withRouter } from 'rax-app';

function ShowTheLocation(props) {
  const { history, location } = props;
  const handleHistoryPush = () => {
    history.push('/new-path');
  };

  return (
    <div>
      <div>当前路径: {location.pathname}</div>
      <button onClick={handleHistoryPush}>点击跳转新页面</button>
    </div>
  );
}

export default withRouter(ShowTheLocation);
复制代码

history

获取应用的路由实例。

import { history } from 'rax-app';

// 用于获取 history 栈里的实体个数
console.log(history.length);

// 用于获取 history 跳转的动作,包含 PUSH、REPLACE 和 POP 三种类型
console.log(history.action);

// 用于获取 location 对象,包含 pathname、search 和 hash
console.log(history.location);

// 用于路由跳转
history.push('/home');

// 用于路由替换
history.replace('/home');

// 用于跳转到上一个路由
history.goBack();
复制代码

安全区域适配

安全区域(Safe Area),指屏幕内不受圆角(corners)、刘海(sensor housing)、底部小黑条(Home Indicator)等元素影响的可视窗口。

WebKit 从 iOS11 起引入一系列 API,来获取安全区域的位置。比如通过环境变量 safe-area-inset-top 可以获取安全区域距离视口顶部的距离,即刘海区域的高度;通过 safe-area-inset-bottom 可以获取安全区域距离视口底部的距离,即小黑条区域的高度。

img

刘海屏适配

获取刘海高度,首先需要设置 viewport-fit ,调整可视窗口的布局方式。当且仅当 viewport-fit 设置为 cover 时,可以进一步设置页面的安全区域范围。

<meta name="viewport" content="width=device-width, viewport-fit=cover">
复制代码

然后,结合 env() 方法,可以获取 safe-area-inset-top 值,并将其作为容器节点的 padding-top 值。在 iOS 11.2 之前的版本,需使用 constant() 方法。

.root {
  padding-top: constant(safe-area-inset-top); /* 兼容  iOS < 11.2 */
  padding-top: env(safe-area-inset-top); // /* iOS > 11.2 */
}
复制代码

小黑条适配

和刘海屏适配的原理一致,小黑条适配可以通过调整 tabbar 的 padding-bottom 值,增加空白区域来实现。通过设置 tabbar 的 padding 值来调整安全区域:

.tabbar {
  padding-bottom: 0; /* 无小黑条的情况下,无需额外设置  */
  padding-bottom: constant(safe-area-inset-bottom); /* 兼容  iOS < 11.2 */
  padding-bottom: env(safe-area-inset-bottom); // /* iOS > 11.2 */
}
复制代码

动画方案

对于基础的动效,推荐的做法是结合 CSS 属性 transition 或 animation 实现。

Transitions

CSS transitions 提供了一种在更改CSS属性时控制动画速度的方法。它可以让属性变化成为一个持续一段时间的过程,而不是立即生效的。比如,将一个元素的颜色从白色改为黑色,通常这个改变是立即生效的,使用 CSS transitions 后该元素的颜色将逐渐从白色变为黑色,按照一定的曲线速率变化,这个过程可以自定义。

示例:

当 hover 到节点时,节点的宽高、背景色及 transform 值发生了渐变。

/* index.module.css */
.box {
  border-style: solid;
  border-width: 1px;
  width: 100rpx;
  height: 100rpx;
  background-color: #0000FF;
  -webkit-transition:width 2s, height 2s,
      background-color 2s, -webkit-transform 2s;
  transition:width 2s, height 2s, background-color 2s, transform 2s;
}

.box:hover {
  background-color: #FFCCCC;
  width:200rpx;
  height:200rpx;
  -webkit-transform:rotate(180deg);
  transform:rotate(180deg);
}
// index.jsx
import styles from './index.module.css';

export default () => {
  return <div className={styles.box} />;
};
复制代码

Animations

CSS animations 提供了通过 关键帧 创建简单动画的能力。通过使用 @keyframes 指定动画开始、结束以及中间点的关键帧,在每一帧上描述了动画元素在给定的时间点上应该如何渲染(CSS样式配置), 就能够非常容易地创建动画序列。

示例:通过 animation 实现节点显示时,从屏幕右侧划入的效果。

/* index.module.css */
.box {
  width: 100rpx;
  height: 100rpx;
  background-color: #0000FF;
  animation-duration: 3s;
  animation-name: slidein;
}

@keyframes slidein {
  from {
    margin-left: 100%;
  }

  to {
    margin-left: 0%;
  }
}
// index.jsx
import { createElement, useState } from 'rax';
import View from 'rax-view';

import styles from './index.module.css';

export default function Home() {
  const [showBox, setBox] = useState(false);

  function onClick() {
    setBox(true);
  }

  return (
    <View>
      <View className={styles.buttom} onClick={onClick}>show box</View>
      {
        showBox && <View className={styles.box}></View>
      }
    </View>
  );
}
复制代码

运行时静态配置

src/app.json 用于对应用进行全局配置,设置路由、窗口表现、渲染方式等。默认配置示例:

{
  "routes": [
    {
      "path": "/",
      "source": "pages/Home/index"
    }
  ],
  "window": {
    "title": "Rax App 1.0"
  }
}
复制代码

单页应用配置路由

路由配置

src/app.tsx 中,我们可以配置路由的类型和基础路径等路由信息,具体配置如下:

import { runApp } from 'rax-app';

const appConfig = {
  router: {
    type: 'browser',
    basename: '/seller',
    fallback: <div>loading...</div>
  }
};

runApp(appConfig);
复制代码

页面路由

对于单页应用,通过 src/app.json 中的 routes 用于指定应用的页面:

  • path: 指定页面对应的路由地址
  • source: 指定页面组件地址,必须写成 pages/[PAGE_NAME]/index 格式,暂不支持嵌套式路由
  • targets 指定页面需要构建的端,默认为 build.json 所配置的 targets 的值
  • window: 指定该页面的窗体表现,可以覆盖全局的窗口设置
{
  "routes": [                    
    {
      "path": "/",
      "source": "pages/Home/index"
    },
    {
      "path": "/about",
      "source": "pages/About/index",
      "targets": ["web"]
    }
  ]
}
复制代码

框架默认开启路由切割,每个页面会打包出一个独立 bundle

window

可以设置应用的窗口表现,同时也支持针对每个页面设置窗口表现。目前已经支持的参数的有:

属性 描述
title 页面标题
{
  "routes": [                    
    {
      "path": "/",
      "source": "pages/Home/index"
    },
    {
      "path": "/about",
      "source": "pages/About/index",
      "window": {
        "title": "关于 Rax"
      }
    }
  ],
  "window": {
    "title": "应用默认 title"
  }
}
复制代码

tabBar

如果你的应用是一个多 tab 应用(底部栏可以切换页面),那么可以通过 tabBar 配置项指定 tab 栏的表现,以及 tab 切换时显示的对应页面。

{
  "routes": [
    {
      "path": "/",
      "source": "pages/Home/index"
    }
  ],
  "tabBar": {
    "textColor": "#999",
    "selectedColor": "#666",
    "backgroundColor": "#f8f8f8",
    "items": [
      {
        "text": "home",
        "pageName": "/",
        "icon": "https://gw.alicdn.com/tfs/TB1ypSMTcfpK1RjSZFOXXa6nFXa-144-144.png",
        "activeIcon": "https://gw.alicdn.com/tfs/TB1NBiCTgHqK1RjSZFPXXcwapXa-144-144.png"
      }
    ]
  }
}
复制代码
tabBar 配置项如下:
属性 类型 是否必填 描述
textColor HexColor 文字颜色
selectedColor HexColor 选中文字颜色
backgroundColor HexColor 背景色
items Array 每个 tab 配置
tab item 配置项如下:
属性 类型 必填 描述
pageName String 设置页面路径,值为 routes 中配置的路由
text String tab item 上显示的文本
icon String 非选中状态图标路径
activeIcon String 选中状态图标路径

路由跳转

可以通过 history 获取路由实例。

import { history } from 'rax-app';

// 用于获取 history 跳转的动作,包含 PUSH、REPLACE 和 POP 三种类型
console.log(history.action);

// 用于获取 location 对象,包含 pathname、search 和 hash
console.log(history.location);

// 用于路由跳转
history.push('/home');

// 用于路由替换
history.replace('/home');

// 用于跳转到上一个路由
history.goBack();
复制代码

更多 history API

路由参数获取

可以通过 getSearchParams 获取路由参数。假设当前 URL 为 https://example.com?foo=bar,解析查询参数如下:

// src/components/Example
import { getSearchParams } from 'rax-app';

function Example() {
  const searchParams = getSearchParams()
  // console.log(searchParams); => { foo: 'bar' }
}
复制代码

常见问题

HashHistory 与 BrowserHistory

前端路由通常有两种实现方式:HashHistory 和 BrowserHistory,路由都带着 # 说明使用的是 HashHistory。这两种方式优缺点:

特点\方案 HashRouter BrowserRouter
美观度 不好,有 # 号
易用性 简单 中等,需要 server 配合
依赖 server 不依赖 依赖
跟锚点功能冲突 冲突 不冲突
兼容性 IE8 IE10

开发者可以根据自己的实际情况选择对应方案。

如何使用 BrowserRouter

本地开发时,只需要在 src/app.tsx 中增加以下配置即可:

import { runApp } from 'rax-app';

const appConfig = {
  router: {
+   type: 'browser',
  }
};

runApp(appConfig);
复制代码

线上运行时需要服务端支持,否则会出现刷新 404 问题,具体方案请参考社区文档:

静态资源使用

让静态资源不经过构建

你可以将无需经过 webpack 构建的静态资源添加到 public 文件夹。

如果将文件放入 public 文件夹,webpack 将 不会 处理它。而是它将被复制到构建文件夹中。要引用 public 文件夹中的资源,需要使用名为 process.env.PUBLIC_URL 的特殊变量,这个值会根据工程配中的 publicPath 变化:

render() {
  // 注意:这是一个 escape hatch,应该谨慎使用!
  // 通常我们建议使用`import`来获取资源的 URL 
  return <img src={process.env.PUBLIC_URL + '/img/logo.png'} />;
}
复制代码

注意这种方法的缺点:

  • public 文件夹中的所有文件都不会进行后处理或压缩
  • 在编译时不会调用丢失的文件,并且会导致用户出现 404 错误
  • 构建产物的文件名不包含内容哈希值,因此你需要添加查询参数或在每次更改时重命名它们(以便清除浏览器缓存)

一般情况下,我们建议你在 JavaScript 文件中 import 资源,这种机制提供了许多好处:

  • 脚本和样式被压缩并打包在一起,以避免额外的网络请求
  • 缺少文件会导致编译错误,而不是给用户 404 错误
  • 构建产物的文件名包含内容哈希,因此你无需担心浏览器会缓存旧版本

何时使用 public 文件夹

通常我们建议从 JavaScript 导入 stylesheets,图片和字体。 public 文件夹可用作许多不常见情况的变通方法:

  • 你需要在构建输出中具有特定名称的文件,例如 manifest.json
  • 你有数千张图片,需要动态引用它们的路径
  • 你希望在打包代码之外包含一个无需走构建逻辑的小脚本
  • 在工程中,某些库可能与 webpack 不兼容,你没有其他选择,只能将其放在 public 中引入

代码质量保障

为了保证代码质量,我们推荐使用 lint 相关的工具对代码进行检测,同时为了降低常规 lint 工具的使用成本,我们封装了 @iceworks/spec 这个 npm 包,基础的 eslint 规则与阿里巴巴前端规范保持一致。

安装依赖

$ npm i --save-dev @iceworks/spec eslint stylelint @commitlint/cli
复制代码

引入配置文件

eslint

JavaScript 工程:

// .eslintrc.js
const { getESLintConfig } = require('@iceworks/spec');
module.exports = getESLintConfig('rax');
复制代码

TypeScript 工程:

// .eslintrc.js
const { getESLintConfig } = require('@iceworks/spec');
module.exports = getESLintConfig('rax-ts');
复制代码

stylint

stylelint 用来检测样式代码的风格,新建配置文件 .stylelintrc.js 引入 lint 规则:

// .stylelintrc.js
const { getStylelintConfig } = require('@iceworks/spec');
module.exports = getStylelintConfig('rax');
复制代码

commitlint

用于规范 commit message 的规范,防止全是 fix 这种无意义的 commit message 导致历史记录追溯比较麻烦,新建配置文件 .commitlintrc.js 引入规则:

// .commitlintrc.js
const { getCommitlintConfig } = require('@iceworks/spec');
module.exports = getCommitlintConfig('rax');
复制代码

配置命令行

通过 npm scripts 配置命令:

// package.json
"scripts": {
  "lint": "npm run eslint && npm run stylelint",
  "eslint": "eslint --cache --ext .js,.jsx,.ts,.tsx ./",
  "stylelint": "stylelint **/*.{css,scss,less}"
}
复制代码

这样通过 npm run lint 就可以运行 lint 任务了。

工具保证

推荐使用 VS Code 进行开发和调试,推荐安装 IceworksESLint 两款 VS Code 插件。 安装 VS Code 插件后相关 lint 错误便可在开发调试中实时反馈:

img

Typescript 无效问题

如果遇到 js 项目 ESLint 问题可以实时反馈而 ts 项目无效时,可以打开 VS Code setting ,在搜索框中输入 ‘ eslint validate ’ ,勾选相关选项。

迁移

从自定义配置迁移

  1. 移除项目中的各类 ESLint plugin config 及 parser。
  2. 安装 @iceworks/spec。
  3. 参考上述文档修改 lint 配置。

注:一定要清除项目之前的 ESLint 的 plugin config 及 parser 相关包

代码切割

代码切割(Code Splitting) 能够把代码切割到不同的 bundle 中,实现按需或并行加载。代码切割可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大减少首屏资源加载时间。

dynamic import

使用 import(),webpack 会在编译阶段对引入的资源进行代码切割,即只有当运行时逻辑执行到 import() 调用点时才会加载对应的资源,该函数返回值是 Promise,开发者可以在链式回调中获取到引用的模块资源。

示例:

import { isWeb } from '@uni/env';

if (isWeb) {
  import('./fetch').then(fetch => {
    fetch('m.taobao.com');
  }).catch(err => {
    console.error('模块引入失败!');
  });
}
复制代码

rax-use-import

函数式组件本身不能是一个异步函数,所以在使用 import() 动态加载子组件时会存在异步更新视图的处理,为了简化对应场景,我们提供了 rax-use-import 可以在函数组件中用同步的写法快速使用动态加载子组件的能力。

示例:

import { createElement } from 'rax';
+import useImport from 'rax-use-import';

export default function App() {
+  const [Bar, error] = useImport(() => import(/* webpackChunkName: "bar" */ './Bar'));
  if (error) {
    return <p>error</p>;
  } else if (Bar) {
    return <Bar />
  } else {
    return <p>loading</p>;  
  }
}
复制代码

唤起客户端

Web 页面中唤端

当我们的 Web 页面在 App 以外被用户访问时,我们希望在一定的场景或者在 App 外不支持某些功能的情况下,将用户带入 App 中,以提供更好的访问体验。为此我们需要调用 Web 唤端能力。而在 Android 和 iOS 下,均提供了多种唤端方式使得 Web 页面可以唤起 App ,Starlink.js 对这些唤端方式进行了包装,可以让 Web 页面快速简单的对接唤端能力。

引入 Starlink JS SDK

在你的 HTML 代码中,像这样引入 starlink sdk,其中 ${star_id} 为你在平台申请的 star_id ,一个 star_id 对应一个“海关”平台的唤端策略配置。

Rax App 里通过配置 app.json 里的 scripts 字段即可:

// src/app.json
{
  "routes": [],
  "scripts": [
+    "<script src="https://assets.taobao.com/app/starlink/core/index.js" id="${star_id}"></script>"
  ]
}
复制代码

配置页面唤端策略

默认 SDK 接入后就已经可以使用唤端了,不过有时候我们还需要做一些业务定制。

在“海关”平台中进行唤端配置

你可以在“海关”平台上进行唤端策略的配置,而如果“海关”平台的配置不能满足你的个性化特殊定制需求,可以看“通过 JS API 唤端”和“通过 DOM 属性配置唤端”

通过 JS API 进行唤端
$slk.callApp(config)
复制代码

Starlink JS 在 Web 环境下会在全局暴露一个 $slk 的对象,你可以通过调用 $slk.callApp(config) 来进行唤端调用,如果不传 config 对象,则默认使用海关平台返回的配置进行唤端。

config 参数说明:

参数名 是否必填 类型 可选值 参数说明
targetUrl String 页面 URL 地址 表示此次唤端在端内需要打开的页面,默认为服务端返回值
clipboardCnt String 任意文本 唤端的同时,如果设置了 clipboardCnt 参数,clipboardCnt的内容会被写入剪贴板中,如果不设置,不做剪切板写入,唤淘宝App的特殊逻辑:如果在唤起淘宝时设置 clipboardCnt,会覆盖原有默认的剪切板内容,如果不设置,使用默认淘宝的剪切板配置。
timeout Number 单位毫秒(ms),发起唤端后的等待时间,如设置了 1000 ,表示发起唤端后 1000ms 后触发唤端失败逻辑
extraProtocolParams Object { bc_fl_src: ‘xxx’ } 表示在唤端时,需要往唤端的 scheme 协议上透传的参数。
targetApp(不建议手动配置此值) String ‘taobao’、’ltao’、’eleme’ 表示此次唤端要唤起的 App,将覆盖服务端的目标 App 配置
通过设置 DOM 属性配置唤端

你也可以在页面 dom 节点(目前仅支持 A 链接)上增加 data-callapp 参数,效果为点击该 A 链接会直接在端内唤起该 A 链接的页面地址(如果 A 链接 href 为非链接地址则不唤起)。

参数 data-callapp 说明:

参数值 含义
无(默认) 点击后会拉起服务端star_id配置的 App 和 目标页
config(JSON对象) 通过 config JSON 控制具体的唤端配置

config JSON 配置字段说明:

字段名称 字段值示例 含义
targetUrl '?k1=v1&k2=v2' / 'https://pageurl.com/xxxx' 如果值为'?k1=v1&k2=v2'的格式,表示使用此参数覆盖当前页面 url的同名参数,如果格式为 'https://pageurl.com/xxxx' ,表示在端内打开此 url 的页面
targetApp(不建议手动配置此值) ‘taobao’ / ‘ltao’ / ‘eleme’ 指定要唤起的 app

data-callapp 示例:

默认

<a data-callapp href="https://xxx.taobao.com/xxxxx"></a> 
<!-- 点击 A 链接在 App 内打开 xxx.taobao.com/xxxxx -->
复制代码

自定义唤端配置

<a data-callapp='{"targetUrl":"https://pageurl.com/xxxx", "targetApp": "taobao"}'></a>
<!-- 点击 A 链接在 taobao 内打开 https://pageurl.com/xxxx -->
复制代码
全局唤端功能

用户可以在“海关”平台配置当前 star_id 开启“全局唤端功能”,此时,页面上的 A 链接点击都会变成唤端,效果为尝试在端内打开当前 A 链接页面(同时在当前浏览器也会跳转到该 A 链接地址)

注:如果你希望对部分链接关闭全局唤端,可以在A 链接上增加 ignore-callapp=”true” 参数

<a ignore-callapp="true" ...>这是一个链接,不会进行唤端</a>
<!-- 点击 A 链接不触发全局唤端 -->
复制代码

在唤端过程中插入业务自定义逻辑

starlink.js 也允许业务在唤端执行过程中,做一些自定义业务逻辑,业务侧可以通过绑定事件的方式,在唤端执行过程中执行自己的业务逻辑。

window.$slk.on('evoke', fn) // fn 里面为业务自定义逻辑
复制代码

目前支持的事件及其含义:

"evoke" – 发起唤端

更多信息请见《Starlink JS SDK 官方文档》

后言

自己只是简单熟悉下官网文档内容,还有很多很多不够的地方,后续计划:

  • Ract 官网 进行详细学习阅读,并且也做自己的学习笔记。
    • 将官网小案例手打一遍,记录实操过程的问题及解决。
  • 做个 Rax 小项目(吐槽 Rax 线上没有较好的免费实战项目,准备根据 Ract 项目来自己封装 Rax 框架。)

在线征求大佬指教学习路径!有不足或完善地方,望提出!

嘿嘿

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享