Taro 3.2 适配 React Native 之样式内幕
导读
Taro 3.2 三月底将迎来正式版的发布,技术讲解文章也在紧锣密鼓的进行着。Taro3.2 适配 React Native 文章系列上篇讲到运行时架构详解 ,本文将给大家带来样式的适配内幕。
背景
适配上的问题
import React from "react";
import { StyleSheet, Text, View } from "react-native";
const App = () => (
<View style={styles.container}>
<Text style={styles.title}>React Native</Text>
</View>
);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#eaeaea"
},
title: {
fontSize: 30,
fontWeight: "bold"
}
});
export default App;
// app.js
import React from "react";
import { Text, View } from "@tarojs/components";
import "./app.css";
const App = () => (
<View className="container">
<Text className="title">React Native</Text>
</View>
);
export default App;
// app.css
.container {
flex: 1;
background-color: "#eaeaea";
}
.title {
font-size: 30;
font-weight: "bold";
}
其他端比如小程序端既可以使用声明式 style ,又可以通过 class 读取导入的样式进行布局
2.通用的样式文件怎么转换为对象
3.组件样式怎么传递
React Native 是用 style 进行传递,其他端使用 class。理论上从写法上约束,使三端更好的适配,CSS In JS 样式解决方案更适合。
React Native 支持的样式有限,不支持的样式怎么处理
4.平台型的特殊逻辑以及特殊样式怎么灵活处理
Taro 在处理跨端文件上有做过处理,但是只是针对脚本文件,我们希望能够对样式文件也能够进行跨端,并且希望在 ios 和 android 两个端的差异文件匹配。比如 Shadow 类样式,只能在 IOS 上生效,Android 则需使用 elevation 替代。当然可以通过重写,但有的时候工程化需求能更明确的区分这两端。
总体设计
流程图
样式代码处理成对象
CSS 样式解析成对象导出
引入样式文件处理成 JS 模块
引入多个样式文件处理
4. 多种预编译语言的适配
5. 更灵活的跨平台工程适配
设计实现
样式代码处理成对象
CSS 样式解析成对象导出
body {
background: #eee;
color: #888;
}
import { StyleSheet } from 'react-native'
const cssObject = {/**/}
export default StyleSheet.create(cssObject)
引入样式文件处理成 JS 模块 Metro 是Facebook用来支持React Native的打包工具。Metro 打包有三个阶段,Resolution(模块解析器),Transformation(模块转换),Serialization(模块序列化),直译过来比较容易理解,大概能猜到要用它们做什么事。接下我们需将样式文件进行模块化解析和模块转换,对应的 Metro 配置里面的 resolver 和 transformer 配置项。 resolver 配置处理模块解析,sourceExts 用来配置需要处理文件后缀,通过 js 引入进来的模块将会当作 JS 模块处理,这个机制让我们可以将样式文件依赖引入。 接下来把导入进来样式文件,在 transformer 这层将样式内容转成 JS 语法,导出 JS 模块,那就可以愉快在文件中引入样式对象了。 // metro.config.js
const { getDefaultConfig } = require("metro-config")
module.exports = (async () => {
const {
resolver: { sourceExts }
} = await getDefaultConfig()
return {
resolver: {
sourceExts: [...sourceExts, "scss", "sass"]
},
transformer: {
// 样式 transformer, 配置忽略了对其他文件的处理,实际上根据不同后缀使用不同的 transformer
babelTransformerPath: require.resolve("@tarojs/rn-style-transformer")
},
}
})()将原来的样式代码字符串转成 JS 的代码字符串: import { StyleSheet } from 'react-native'
export default StyleSheet.create(cssObject)然后可以从页面上引入样式模块: import a from 'a.css'
const _stylesheet = a引入多个样式文件处理 引入一个样式文件时,我们不需要处理,只需将导出的对象赋值 _stylesheet 对象,然后在 style 属性进行引用。当引入多个样式文件时,要实现整个页面引入使用的样式生效,需要把多个样式合并成一个样式对象: import a from 'a.css'
import b from 'b.css'
const _stylesheet = mergeStyles(a, b)要实现上面的功能,需要对 JS 代码处理,我们在 Babel 编译的时候定义 visitor 访问 importDeclaration 修改 AST 就可以了。
下面是一段代码演示:traverse(codeAst, { // visitor
Program: {
exit(ast) {
const lastImportAst = findLastImport(ast.get('body'))
if (styleList.length) {
// 插入到最后一个 import 后面
lastImportAst.insertAfter(template.ast(`
function mergeStyle() {
// 省略代码,实现 arguments 对象的合并,返回合并的对象
}
const _styleSheet = mergeStyle(${styleList.join(',')})
`))
}
}
},
// 访问 importDeclaration ast
ImportDeclaration(ast) {
const { source } = path.node
if (!isStyle(source)) return
ast.node.specifiers.forEach(node => {
// 导出的 default
if (t.isImportDefaultSpecifier(node)) {
styleList.push(node.local.name)
}
})
},
})
标签属性 className 处理成 style
className 是一个普通字符串 这一步比较好办,直接把 <View className='red' /> 转化成 <View style={_styleSheet['red']} />。 className 是一个表达式 className 如果是复杂表达式(比如函数调用等非 JS 基本类型)的话,在 Babel 编译时是无法处理这种结果的,所以得借助运行时方法去处理。这里 我们实现了一个 getStyle 函数,参数传入 className 表达式,在运行时得到表达式的值再去 _styleSheet 里面取出样式对象。代码层将 <View className={'red'} /> 转化成 <View className={getStyle('red')} />。 className 和 style 属性同时存在 同时存在这两种属性时,将两者的值进行合并。React Native 标签 style 属性支持数组对象,所以可以把 <View className='red' style={{ width: 100 }} /> 处理成 <View style=[{color: 'red'}, { width: 100 }] /> 。 实现下面核心逻辑的代码演示: JSXOpeningElement(jsxPath) {
const { attributes } = jsxPath.node
const styleNode = attributes.find(node => node.name.name === 'style')
const classNode = attributes.find(node => node.name.name === 'className')
// 存在 className 属性
if (classNode) {
// 删除 className 属性
attributes.splice(attributes.indexOf(classNode), 1)
let class2StyleExpression = []
// className 表达式
if (t.isJSXExpressionContainer(classNode.value)) {
const { value } = classNode.value.expression
if (typeof value.expression.value === 'string') {
// className={'red black'} => style={[_stylesheet['red'], _stylesheet['black']]}
class2StyleExpression = value.expression.value === '' ? [] : getMap(value.expression.value) // getMap() 返回数组表达式,自行实现
}
// 标记需要在运行时动态计算 className 值
else {
// className={expression} => style={getStyle(expression)}
class2StyleExpression = [t.callExpression(t.identifier('getStyle'), [value.value])]
// TODO: 标记最后一个 import 后面插入 getStyle 函数,在 Program exit 时处理
}
}
// className 是字符串
else if (t.isStringLiteral(classNode.value)) {
class2StyleExpression = classNode.value === '' ? [] : getMap(classNode.value.value) // getMap() 返回数组表达式
}
// 存在 style
if (styleNode && t.isJSXExpressionContainer(styleNode.value)) {
const { expression } = styleNode.value
// style={[{ }]} 表达式
if (expression.type === 'ArrayExpression') {
expression.elements = class2StyleExpression.concat(expression.elements)
}
// style={{ }} 表达式
else if (expression.type === 'ObjectExpression') {
styleNode.value = t.jSXExpressionContainer(t.arrayExpression(class2StyleExpression.concat(expression)))
}
}
// 不存在则新增 style 属性
else {
attributes.push(t.jSXAttribute(t.jSXIdentifier('style'), t.jSXExpressionContainer(t.arrayExpression(class2StyleExpression))))
}
}
}还有其他场景感兴趣的同学请查看 babel-plugin-transform-react-jsx-to-rn-stylesheet 测试用例[4]。 即使实现这种方式的转换,但仍然可能跟 Web 的样式呈现不一样: /* app.css */
.black {
color: red;
}
.red {
color: red;
}<View className='red black' />
在 Web 端: <div class='red black' />
而 React Native 端处理成了: <View style={[stylesheet.red, stylesheet.black]} />
细心的同学可能发现问题了,在 Web 端的,class 跟样式文件的定义样式的顺序有关,跟标签的 class 属性书写顺序无关,而在 React Native,却跟 class(处理成 style)的顺序有关。这个问题,现阶段只有在代码层去规避,还没有想到很好的方法去解决。
多种预编译语言的适配
Sass 这个版本的做法是基于 node-sass 解析 Sass 语法,内部调用 node-sass 提供的 API render 函数,并且提供一些 options 配置。跟 sass-loader[5] 类似,但只暴露了 options 和 additionalData 两个配置项。 additionalData是一个全局配置,插入一段代码到引入的 Sass 文件中。而 config.sass 也是一个全局配置,他们之间是有关联的。addtionalData 在编译时注入的每个 Sass 文件头部,在 config.sass 配置的全局样式之前,就是说 config.sass 可以把 addtionalData 样式重写掉,或者可以复用 addtionalData 的变量。 sass-loader 配置里面的有一个 implementation 配置,该配置能选择使用 dart-sass 实现 Sass 的解析,但是目前这个版本还没有支持到,所以目前还不能启用 dart-sass 解析。node-sass[6] 依赖 node-gyp 和 python2,官方不建议使用,不会有新特性的更新,取而代之的是使用 dart-sass[7]。这个库完全兼容 node-sass,官方介绍的更易安装,更容易集成到现代 Web 开发工作流程中。所以 Taro React Native 后面版本会支持到 implementation 配置。 Less,Stylus 像 less-loader 一样,Less 处理需要封装 less.js 提供的 api render 函数,以及封装自定义 options 和 addtionalData 处理。
Stylus 支持也跟 Sass Less 类似,提供 options 和 additionalData 两个配置项,将 Stylus 语法解析成标准的 CSS。
less.render(src, {
...options,
filename,
plugins: plugins.concat(options.plugins || []),
paths: paths.concat(options.paths || [])
}, (err, output) => {
if (err) {
return reject(err.message)
}
resolve(output.css)
})
PostCSS PostCSS 不是类似上述预处理器,而是一种允许用 JS 插件来转变样式的工具。在源码里面我们对内置默认插件进行了封装,最后使用 postcss 库进行解析。
o
时:更灵活的跨平台工程适配
跨平台文件 在文章开始的是提到过,我们需要更灵活的去匹配不同平台的文件(包括样式文件),并且希望区分 IOS 和 Android 两端的文件。 实现上面最先想到的是用 Babel 插件去实现,因为 Babel 去修改 import 声非常简单。但是在实现的一两个版本里面,发现他有很严重的副作用,就是缓存问题。因为 Metro 对所有源文件进行了编译缓存,如果没有改动的话将读缓存的内容而不会默认重新编译,而 Babel 本质上是对代码的转化。当新增一个 跨平台优先级高的文件时,文件没有改动,并且没有打破文件依赖关系,所以不会重新编译的问题。但正是这个缓存机制,Metro 才有快速启动的优势。 后面我们决定在 Resolution(模块解析器)在一层 去实现不同跨平台文件引入,将对样式文件和脚本文件都做跨平台处理。 function handleFile (context, realModuleName, platform, moduleName) {
// 返回新的模块名,根据平台,文件路径和引入的模块
moduleName = resolveExtFile(context, moduleName, platform)
}跨平台样式 在一段样式代码内,希望区分小程序,H5 和 其他平台的场景时, 则需使用条件编译。条件编译是根据编译时的环境变量,对 CSS AST 内容的进行解析,决定对条件内的代码采用或者忽略,使用的是已有 PostCSS 插件 postcss-pxtransform[10] 实现。 /* #ifndef %PLATFORM% */
/* 平台特有样式 */
/* #endif */
总结
在 JSX 这一层,只对了 className 进行 style 的转换,这个限制造成了在选择器上只能使用类选择器。 在 CSS 转成对象这一层,直接将类选择器转化成对象,没有对组合选择器支持,限制了组合选择器的使用。 在预处理语言嵌套写法中,也不能使用会编译成组合选择器的写法,但是可以使用能编译成 BEM[11] 的嵌套写法。 2.关于组合选择器的一点思考 i.Atomic CSS Atomic CSS[12] 是今年5月 Facebook 提出来的对样式管理的方案,官方号称使用了 Atomic CSS 将主页 CSS 代码量减少了80%,感兴趣的同学可以访问 Facebook 主页查看。 Atomic CSS 是指给每一个样式属性设置都对应一个类选择器去控制,避免重复的样式代码。比如使用了一个公共样式,只想使用其部分样式,则将增加一个层级(后代选择器)去重写不想使用的样式,在一定程度上增加了代码量。如果项目的样式都用 Atomic CSS ,那么组合选择器的支持就显得没那么必要了。 ii.CSS in JS CSS in JS[13] 是使用语法糖,在 JS 里面定义样式,本质上是用内联的方式写样式,这跟 React Native 样式管理理念一致,不失为 CSS in JS 爱好者推崇。但在小程序官方有提到, style 接收动态的样式,在运行时会进行解析,会影响渲染速度。
[0] https://github.com/NervJS/taro-rfcs/pull/8
[1] https://www.npmjs.com/package/css
[2] https://github.com/styled-components/css-to-react-native
[3] https://csstox.surge.sh/
[4]https://github.com/NervJS/taro/blob/feat%2Freact-native/packages/babel-plugin-transform-react-jsx-to-rn-stylesheet/tests/index.spec.js
[5] https://github.com/webpack-contrib/sass-loader
[6] https://github.com/sass/node-sass
[7] https://github.com/sass/dart-sass
[8] https://www.npmjs.com/package/postcss-pxtransform
[9] https://www.npmjs.com/package/stylelint-config-taro-rn
[10] https://www.npmjs.com/package/postcss-pxtransform
[11] http://getbem.com/naming/
[12] https://engineering.fb.com/2020/05/08/web/facebook-redesign
[13] https://en.wikipedia.org/wiki/CSS-in-JS