者: 李松峰
轉發連接:https://mp.weixin.qq.com/s/dIG7QxHyP-EKWH0Fq98dGQ
自:coderwhy
前面說過,整個前端已經是組件化的天下,而CSS的設計就不是為組件化而生的,所以在目前組件化的框架中都在需要一種合適的CSS解決方案。
事實上,css一直是React的痛點,也是被很多開發者吐槽、詬病的一個點。
在組件化中選擇合適的CSS解決方案應該符合以下條件:
在這一點上,Vue做的要遠遠好于React:
Vue在CSS上雖然不能稱之為完美,但是已經足夠簡潔、自然、方便了,至少統一的樣式風格不會出現多個開發人員、多個項目采用不一樣的樣式風格。
相比而言,React官方并沒有給出在React中統一的樣式風格:
在這篇文章中,我會介紹挑選四種解決方案來介紹:
內聯樣式是官方推薦的一種css樣式的寫法:
export default class App extends PureComponent {
constructor(props) {
super(props);
this.state = {
titleColor: "red"
}
}
render() {
return (
<div>
<h2 style={{color: this.state.titleColor, fontSize: "20px"}}>我是App標題</h2>
<p style={{color: "green", textDecoration: "underline"}}>我是一段文字描述</p>
</div>
)
}
}
內聯樣式的優點:
內聯樣式的缺點:
所以官方依然是希望內聯合適和普通的css來結合編寫;
普通的css我們通常會編寫到一個單獨的文件。
App.js中編寫React邏輯代碼:
import React, { PureComponent } from 'react';
import Home from './Home';
import './App.css';
export default class App extends PureComponent {
render() {
return (
<div className="app">
<h2 className="title">我是App的標題</h2>
<p className="desc">我是App中的一段文字描述</p>
<Home/>
</div>
)
}
}
App.css中編寫React樣式代碼:
.title {
color: red;
font-size: 20px;
}
.desc {
color: green;
text-decoration: underline;
}
這樣的編寫方式和普通的網頁開發中編寫方式是一致的:
比如編寫Home.js的邏輯代碼:
import React, { PureComponent } from 'react';
import './Home.css';
export default class Home extends PureComponent {
render() {
return (
<div className="home">
<h2 className="title">我是Home標題</h2>
<span className="desc">我是Home中的span段落</span>
</div>
)
}
}
又編寫了Home.css的樣式代碼:
.title {
color: orange;
}
.desc {
color: purple;
}
最終樣式之間會相互層疊,只有一個樣式會生效;
css modules并不是React特有的解決方案,而是所有使用了類似于webpack配置的環境下都可以使用的。
但是,如果在其他項目中使用,那么我們需要自己來進行配置,比如配置webpack.config.js中的modules: true等。
但是React的腳手架已經內置了css modules的配置:
使用的方式如下:
css modules用法
這種css使用方式最終生成的class名稱會全局唯一:
生成的代碼結構
css modules確實解決了局部作用域的問題,也是很多人喜歡在React中使用的一種方案。
但是這種方案也有自己的缺陷:
如果你覺得上面的缺陷還算OK,那么你在開發中完全可以選擇使用css modules來編寫,并且也是在React中很受歡迎的一種方式。
實際上,官方文檔也有提到過CSS in JS這種方案:
在傳統的前端開發中,我們通常會將結構(HTML)、樣式(CSS)、邏輯(JavaScript)進行分離。
當然,這種開發的方式也受到了很多的批評:
批評聲音雖然有,但是在我們看來很多優秀的CSS-in-JS的庫依然非常強大、方便:
目前比較流行的CSS-in-JS的庫有哪些呢?
目前可以說styled-components依然是社區最流行的CSS-in-JS庫,所以我們以styled-components的講解為主;
安裝styled-components:
yarn add styled-components
ES6中增加了模板字符串的語法,這個對于很多人來說都會使用。
但是模板字符串還有另外一種用法:標簽模板字符串(Tagged Template Literals)。
我們一起來看一個普通的JavaScript的函數:
function foo(...args) {
console.log(args);
}
foo("Hello World");
正常情況下,我們都是通過 函數名() 方式來進行調用的,其實函數還有另外一種調用方式:
foo`Hello World`; // [["Hello World"]]
如果我們在調用的時候插入其他的變量:
foo`Hello ${name}`; // [["Hello ", ""], "kobe"];
在styled component中,就是通過這種方式來解析模塊字符串,最終生成我們想要的樣式的
styled-components的本質是通過函數的調用,最終創建出一個組件:
比如我們正常開發出來的Home組件是這樣的格式:
<div>
<h2>我是Home標題</h2>
<ul>
<li>我是列表1</li>
<li>我是列表2</li>
<li>我是列表3</li>
</ul>
</div>
我們希望給外層的div添加一個特殊的class,并且添加相關的樣式:
styled-components基本使用
另外,它支持類似于CSS預處理器一樣的樣式嵌套:
const HomeWrapper = styled.div`
color: purple;
h2 {
font-size: 50px;
}
ul > li {
color: orange;
&.active {
color: red;
}
&:hover {
background: #aaa;
}
&::after {
content: "abc"
}
}
`
最終效果如下
props可以穿透
定義一個styled組件:
const HYInput = styled.input`
border-color: red;
&:focus {
outline-color: orange;
}
`
使用styled的組件:
<HYInput type="password"/>
props可以被傳遞給styled組件
<HomeWrapper color="blue">
</HomeWrapper>
使用時可以獲取到傳入的color:
const HomeWrapper = styled.div`
color: ${props => props.color};
}
添加attrs屬性
const HYInput = styled.input.attrs({
placeholder: "請填寫密碼",
paddingLeft: props => props.left || "5px"
})`
border-color: red;
padding-left: ${props => props.paddingLeft};
&:focus {
outline-color: orange;
}
`
支持樣式的繼承
編寫styled組件
const HYButton = styled.button`
padding: 8px 30px;
border-radius: 5px;
`
const HYWarnButton = styled(HYButton)`
background-color: red;
color: #fff;
`
const HYPrimaryButton = styled(HYButton)`
background-color: green;
color: #fff;
`
按鈕的使用
<HYButton>我是普通按鈕</HYButton>
<HYWarnButton>我是警告按鈕</HYWarnButton>
<HYPrimaryButton>我是主要按鈕</HYPrimaryButton>
styled設置主題
在全局定制自己的主題,通過Provider進行共享:
import { ThemeProvider } from 'styled-components';
<ThemeProvider theme={{color: "red", fontSize: "30px"}}>
<Home />
<Profile />
</ThemeProvider>
在styled組件中可以獲取到主題的內容:
const ProfileWrapper = styled.div`
color: ${props => props.theme.color};
font-size: ${props => props.theme.fontSize};
`
vue中添加class
在vue中給一個元素添加動態的class是一件非常簡單的事情:
你可以通過傳入一個對象:
<div
class="static"
v-bind:class="{ active: isActive, 'text-danger': hasError }"
></div>
你也可以傳入一個數組:
<div v-bind:class="[activeClass, errorClass]"></div>
甚至是對象和數組混合使用:
<div v-bind:class="[{ active: isActive }, errorClass]"></div>
react中添加class
React在JSX給了我們開發者足夠多的靈活性,你可以像編寫JavaScript代碼一樣,通過一些邏輯來決定是否添加某些class:
import React, { PureComponent } from 'react'
export default class App extends PureComponent {
constructor(props) {
super(props);
this.state = {
isActive: true
}
}
render() {
const {isActive} = this.state;
return (
<div>
<h2 className={"title " + (isActive ? "active": "")}>我是標題</h2>
<h2 className={["title", (isActive ? "active": "")].join(" ")}>我是標題</h2>
</div>
)
}
}
這個時候我們可以借助于一個第三方的庫:classnames
我們來使用一下最常見的使用案例:
classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'
// lots of arguments of various types
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'
// other falsy values are just ignored
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'
我是@半糖學前端 ,專注前端技術領域分享,關注我和我一起學習,共同進步!
題中我們提出一個問題:react 代碼如何跑在小程序上?目前看來大致兩種思路:
1. 把 react 代碼編譯成小程序代碼,這樣我們可以開發用 react,然后跑起來還是小程序原生代碼,結果很完美,但是把 react 代碼編譯成各個端的小程序代碼是一個力氣活,而且如果想用 vue 來開發的話,那么還需要做一遍 vue 代碼的編譯,這是 taro 1/2 的思路。
2. 我們可以換個問題思考,react 代碼是如何跑在瀏覽器里的?
下面我們具體看看各自的實現。
Taro 1/2
Taro 1/2 的架構主要分為:編譯時 和 運行時。
其中編譯時主要是將 Taro 代碼通過 Babel 轉換成 小程序的代碼,如:JS、WXML、WXSS、JSON。
運行時主要是進行一些:生命周期、事件、data 等部分的處理和對接。
Taro 編譯時
Taro 的編譯,使用 babel-parser 將 Taro 代碼解析成抽象語法樹,然后通過 babel-types 對抽象語法樹進行一系列修改、轉換操作,最后再通過 babel-generate 生成對應的目標代碼。
整個編譯時最復雜的部分在于 JSX 編譯。
我們都知道 JSX 是一個 JavaScript 的語法擴展,它的寫法千變萬化,十分靈活。這里我們是采用 窮舉 的方式對 JSX 可能的寫法進行了一一適配,這一部分工作量很大,實際上 Taro 有大量的 Commit 都是為了更完善的支持 JSX 的各種寫法。
Taro 運行時
接下來,我們可以對比一下編譯后的代碼,可以發現,編譯后的代碼中,React 的核心 render 方法 沒有了。同時代碼里增加了 BaseComponent 和 createComponent ,它們是 Taro 運行時的核心。
// 編譯前
import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import './index.scss'
export default class Index extends Component {
config={
navigationBarTitleText: '首頁'
}
componentDidMount () { }
render () {
return (
<View className=‘index' onClick={this.onClick}>
<Text>Hello world!</Text>
</View>
)
}
}
// 編譯后
import {BaseComponent, createComponent} from '@tarojs/taro-weapp'
class Index extends BaseComponent {
// ...
_createDate(){
//process state and props
}
}
export default createComponent(Index)
BaseComponent 主要是對 React 的一些核心方法:setState、forceUpdate 等進行了替換和重寫,結合前面編譯后 render 方法被替換,大家不難猜出:Taro 當前架構只是在開發時遵循了 React 的語法,在代碼編譯之后實際運行時,和 React 并沒有關系。
而 createComponent 主要作用是調用 Component() 構建頁面;對接事件、生命周期等;進行 Diff Data 并調用 setData 方法更新數據。
這樣的實現過程有三?缺點:
Taro 3
Taro 3 則可以大致理解為解釋型架構(相對于 Taro 1/2 而言),主要通過在小程序端模擬實現 DOM、BOM API 來讓前端框架直接運行在小程序環境中,從而達到小程序和 H5 統一的目的。
而對于生命周期、組件庫、API、路由等差異,依然可以通過定義統一標準,各端負責各自實現的方式來進行抹平。
而正因為 Taro 3 的原理,在 Taro 3 中同時支持 React、Vue 等框架,甚至還支持了 jQuery,還能支持讓開發者自定義地去拓展其他框架的支持,比如 Angular,Taro 3 整體架構如下:
模擬實現 DOM、BOM API
Taro 3 創建了 taro-runtime 的包,然后在這個包中實現了 一套 高效、精簡版的 DOM/BOM API(下面的 UML 圖只是反映了幾個主要的類的結構和關系):
然后,我們通過 Webpack 的 ProvidePlugin 插件,注入到小程序的邏輯層。
Webpack ProvidePlugin 是一個 webpack 自帶的插件,用于在每個模塊中自動加載模塊,而無需使用 import/require 調用。該插件可以將全局變量注入到每個模塊中,避免在每個模塊中重復引用相同的依賴。
// trao-mini-runner/src/webpack/build.conf.ts
plugin.providerPlugin=getProviderPlugin({
window: ['@tarojs/runtime', 'window'],
document: ['@tarojs/runtime', 'document'],
navigator: ['@tarojs/runtime', 'navigator'],
requestAnimationFrame: ['@tarojs/runtime', 'requestAnimationFrame'],
cancelAnimationFrame: ['@tarojs/runtime', 'cancelAnimationFrame'],
Element: ['@tarojs/runtime', 'TaroElement'],
SVGElement: ['@tarojs/runtime', 'SVGElement'],
MutationObserver: ['@tarojs/runtime', 'MutationObserver'],
history: ['@tarojs/runtime', 'history'],
location: ['@tarojs/runtime', 'location'],
URLSearchParams: ['@tarojs/runtime', 'URLSearchParams'],
URL: ['@tarojs/runtime', 'URL'],
})
// trao-mini-runner/src/webpack/chain.ts
export const getProviderPlugin=args=> {
return partial(getPlugin, webpack.ProvidePlugin)([args])
}
這樣,在小程序的運行時,就有了 一套高效、精簡版的 DOM/BOM API。
taro-react:小程序版的 react-dom
在 DOM/BOM 注入之后,理論上來說,react 就可以直接運行了。
但是因為 React-DOM 包含大量瀏覽器兼容類的代碼,導致包太大。Taro 自己實現了 react 的自定義渲染器,代碼在taro-react包里。
在 React 16+ ,React 的架構如下:
最上層是 React 的核心部分 react-core ,中間是 react-reconciler,其的職責是維護 VirtualDOM 樹,內部實現了 Diff/Fiber 算法,決定什么時候更新、以及要更新什么。
而 Renderer 負責具體平臺的渲染工作,它會提供宿主組件、處理事件等等。例如 React-DOM 就是一個渲染器,負責 DOM 節點的渲染和 DOM 事件處理。
Taro實現了taro-react 包,用來連接 react-reconciler 和 taro-runtime 的 BOM/DOM API。是基于 react-reconciler 的小程序專用 React 渲染器,連接 @tarojs/runtime的DOM 實例,相當于小程序版的react-dom,暴露的 API 也和react-dom 保持一致。
這里涉及到一個問題:如何自定義 React 渲染器?
第一步: 實現宿主配置( 實現react-reconciler的hostConfig配置)
這是react-reconciler要求宿主提供的一些適配器方法和配置項。這些配置項定義了如何創建節點實例、構建節點樹、提交和更新等操作。即在 hostConfig 的方法中調用對應的 Taro BOM/DOM 的 API。
1. 創建形操作
createInstance(type,newProps,rootContainerInstance,_currentHostContext,workInProgress)。
react-reconciler 使用該方法可以創建對應目標平臺的UI Element實例。比如 document.createElement 根據不同類型來創建 div、img、h2等DOM節點,并使用 newProps參數給創建的節點賦予屬性。而在 Taro 中:
import { document } from '@tarojs/runtime'
// 在 ReactDOM 中會調用 document.createElement 來生成 dom,
// 而在小程序環境中 Taro 中模擬了 document,
// 直接返回 `document.createElement(type)` 即可
createInstance (type, props: Props, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) {
const element=document.createElement(type)
precacheFiberNode(internalInstanceHandle, element)
updateFiberProps(element, props)
return element
},
createTextInstance
如果目標平臺允許創建純文本節點。那么這個方法就是用來創建目標平臺的文本節點。
import { document } from '@tarojs/runtime'
// Taro: 模擬的 document 支持創建 text 節點, 返回 `document.createTextNode(text)` 即可.
createTextInstance (text: string, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) {
const textNode=document.createTextNode(text)
precacheFiberNode(internalInstanceHandle, textNode)
return textNode
},
2. UI樹操作
appendInitialChild(parent, child)
初始化UI樹創建。
// Taro: 直接 parentInstance.appendChild(child) 即可
appendInitialChild (parent, child) {
parent.appendChild(child)
},
appendChild(parent, child)
此方法映射為 domElement.appendChild 。
appendChild (parent, child) {
parent.appendChild(child)
},
3. 更新prop操作
finalizeInitialChildren
finalizeInitialChildren 在組件掛載到頁面中前調用,更新時不會調用。
這個方法我們下面事件注冊時還會提到。
finalizeInitialChildren (dom, type: string, props: any) {
updateProps(dom, {}, props)
// 提前執行更新屬性操作,Taro 在 Page 初始化后會立即從 dom 讀取必要信息
// ....
},
prepareUpdate(domElement, oldProps, newProps)
這里是比較oldProps,newProps的不同,用來判斷是否要更新節點。
prepareUpdate (instance, _, oldProps, newProps) {
return getUpdatePayload(instance, oldProps, newProps)
},
// ./props.ts
export function getUpdatePayload (dom: TaroElement, oldProps: Props, newProps: Props){
let i: string
let updatePayload: any[] | null=null
for (i in oldProps) {
if (!(i in newProps)) {
(updatePayload=updatePayload || []).push(i, null)
}
}
const isFormElement=dom instanceof FormElement
for (i in newProps) {
if (oldProps[i] !==newProps[i] || (isFormElement && i==='value')) {
(updatePayload=updatePayload || []).push(i, newProps[i])
}
}
return updatePayload
}
commitUpdate(domElement, updatePayload, type, oldProps, newProps)
此函數用于更新domElement屬性,下文要講的事件注冊就是在這個方法里。
// Taro: 根據 updatePayload,將屬性更新到 instance 中,
// 此時 updatePayload 是一個類似 `[prop1, value1, prop2, value2, ...]` 的數組
commitUpdate (dom, updatePayload, _, oldProps, newProps) {
updatePropsByPayload(dom, oldProps, updatePayload)
updateFiberProps(dom, newProps)
},
export function updatePropsByPayload (dom: TaroElement, oldProps: Props, updatePayload: any[]){
for(let i=0; i < updatePayload.length; i +=2){ // key, value 成對出現
const key=updatePayload[i];
const newProp=updatePayload[i+1];
const oldProp=oldProps[key]
setProperty(dom, key, newProp, oldProp)
}
}
function setProperty (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) {
name=name==='className' ? 'class' : name
if (
name==='key' ||
name==='children' ||
name==='ref'
) {
// skip
} else if (name==='style') {
const style=dom.style
if (isString(value)) {
style.cssText=value
} else {
if (isString(oldValue)) {
style.cssText=''
oldValue=null
}
if (isObject<StyleValue>(oldValue)) {
for (const i in oldValue) {
if (!(value && i in (value as StyleValue))) {
setStyle(style, i, '')
}
}
}
if (isObject<StyleValue>(value)) {
for (const i in value) {
if (!oldValue || value[i] !==(oldValue as StyleValue)[i]) {
setStyle(style, i, value[i])
}
}
}
}
} else if (isEventName(name)) {
setEvent(dom, name, value, oldValue)
} else if (name==='dangerouslySetInnerHTML') {
const newHtml=(value as DangerouslySetInnerHTML)?.__html ?? ''
const oldHtml=(oldValue as DangerouslySetInnerHTML)?.__html ?? ''
if (newHtml || oldHtml) {
if (oldHtml !==newHtml) {
dom.innerHTML=newHtml
}
}
} else if (!isFunction(value)) {
if (value==null) {
dom.removeAttribute(name)
} else {
dom.setAttribute(name, value as string)
}
}
}
上面是hostConfig里必要的回調函數的實現,源碼里還有很多回調函數的實現,詳見trao-react源碼。
第二步:實現渲染函數,類似于ReactDOM.render() 方法。可以看成是創建 Taro DOM Tree 容器的方法。
源碼實現詳見trao-react/src/render.ts。
export function render (element: ReactNode, domContainer: TaroElement, cb: Callback) {
const root=new Root(TaroReconciler, domContainer)
return root.render(element, cb)
}
export function createRoot (domContainer: TaroElement, options: CreateRootOptions={}) {
// options should be an object
const root=new Root(TaroReconciler, domContainer, options)
// ......
return root
}
class Root {
public constructor (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) {
this.renderer=renderer
this.initInternalRoot(renderer, domContainer, options)
}
private initInternalRoot (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) {
// .....
this.internalRoot=renderer.createContainer(
containerInfo,
tag,
null, // hydrationCallbacks
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
onRecoverableError,
transitionCallbacks
)
}
public render (children: ReactNode, cb: Callback) {
const { renderer, internalRoot }=this
renderer.updateContainer(children, internalRoot, null, cb)
return renderer.getPublicRootInstance(internalRoot)
}
}
而 Root 類最后調用TaroReconciler的createContainr``updateContainer和 getPublicRootInstance 方法,實際上就是react-reconciler包里面對應的方法。
渲染函數是在什么時候被調用的呢?
在編譯時,會引入插件taro-plugin-react, 插件內會調用 modifyMiniWebpackChain=> setAlias。
// taro-plugin-react/src/webpack.mini.ts
function setAlias (ctx: IPluginContext, framework: Frameworks, chain) {
if (framework==='react') {
alias.set('react-dom$', '@tarojs/react')
}
}
這樣ReactDOM.createRoot和ReactDOM.render實際上調用的就是trao-react的createRoot和render方法。
經過上面的步驟,React 代碼實際上就可以在小程序的運行時正常運行了,并且會生成 Taro DOM Tree。那么偌大的 Taro DOM Tree 怎樣更新到頁面呢?
從虛擬 Dom 到小程序頁面渲染
因為?程序并沒有提供動態創建節點的能?,需要考慮如何使?相對靜態的 wxml 來渲染相對動態的 Taro DOM 樹。Taro使?了模板拼接的?式,根據運?時提供的 DOM 樹數據結構,各 templates 遞歸地 相互引?,最終可以渲染出對應的動態 DOM 樹。
模版化處理
首先,將小程序的所有組件挨個進行模版化處理,從而得到小程序組件對應的模版。如下圖就是小程序的 view 組件模版經過模版化處理后的樣子。?先需要在 template ??寫?個 view,把它所有的屬性全部列出來(把所有的屬性都列出來是因為?程序??不能去動態地添加屬性)。
模板化處理的核心代碼在 packages/shared/src/template.ts 文件中。會在編譯工程中生成 base.wxml文件,這是我們打包產物之一。
// base.wxml
<wxs module="xs" src="./utils.wxs" />
<template name="taro_tmpl">
<block wx:for="{{root.cn}}" wx:key="sid">
// tmpl_' + 0 + '_' + 2
<template is="{{xs.a(0, item.nn, '')}}" data="{{i:item,c:1,l:''}}" />
</block>
</template>
....
<template name="tmpl_0_2">
<view style="{{i.st}}" class="{{i.cl}}" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</view>
</template>
打包產生的頁面代碼是這樣的:
// pages/index/index.wxml
<import src="../../base.wxml"/>
<template is="taro_tmpl" data="{{root:root}}" />
接下來是遍歷渲染所有?節點,基于組件的 template,動態 “遞歸” 渲染整棵樹。
具體流程為先去遍歷 Taro DOM Tree 根節點的子元素,再根據每個子元素的類型選擇對應的模板來渲染子元素,然后在每個模板中我們又會去遍歷當前元素的子元素,以此把整個節點樹遞歸遍歷出來。
hydrate Data
而動態遞歸時需要獲取到我們的 data,也就是 root。
首先,在 createPageConfig 中會對 config.data 進行初始化,賦值 {root:{cn:[]}}。
export function createPageConfig (component: any, pageName?: string, data?: Record<string, unknown>, pageConfig?: PageConfig) {
// .......
if (!isUndefined(data)) {
config.data=data
}
// .......
}
React在commit階段會調用HostConfig里的appendInitialChild方法完成頁面掛載,在Taro中則繼續調用:appendInitialChild —> appendChild —> insertBefore —> enqueueUpdate。
// taro-react/src/reconciler.ts
appendInitialChild (parent, child) {
parent.appendChild(child)
},
appendChild (parent, child) {
parent.appendChild(child)
},
// taro-runtime/src/dom/node.ts
public appendChild (newChild: TaroNode) {
return this.insertBefore(newChild)
}
public insertBefore<T extends TaroNode> (newChild: T, refChild?: TaroNode | null, isReplace?: boolean): T {
// 忽略了大部分代碼
this.enqueueUpdate({
path: newChild._path,
value: this.hydrate(newChild)
})
return newChild
}
這里看到最終調用enqueueUpdate方法,傳入一個對象,值為 path 和 value,而 value 值是hydrate方法的結果。
hydrate方法我們可以翻譯成“注水”,函數 hydrate 用于將虛擬 DOM(TaroElement 或 TaroText)轉換為小程序組件渲染所需的數據格式(MiniData)。
回想一下小程序員生的 data 里都是我們頁面需要的 state,而 taro 的hydrate方法返回的 miniData 是把 state 外面在包裹上我們頁面的 node 結構值。舉例來看,我們一個 helloword 代碼所hydrate的 miniData 如下(可以在小程序IDE中的 ”AppData“ 標簽欄中查看到完整的data數據結構):
{
"root": {
"cn": [
{
"cl": "index",
"cn": [
{
"cn": [
{
"nn": "8",
"v": "Hello world!"
}
],
"nn": "4",
"sid": "_AH"
},
{
"cn": [
{
"nn": "8",
"v": "HHHHHH"
}
],
"nn": "2",
"sid": "_AJ"
},
{
"cl": "blue",
"cn": [
{
"nn": "8",
"v": "Page bar: "
},
{
"cl": "red",
"cn": [
{
"nn": "8",
"v": "red"
}
],
"nn": "4",
"sid": "_AM"
}
],
"nn": "4",
"sid": "_AN"
}
],
"nn": "2",
"sid": "_AO"
}
],
"uid": "pages/index/index?$taroTimestamp=1691064929701"
},
"__webviewId__": 1
}
這里的字段含義解釋一下 :(我想這里縮寫是可能盡可能讓每一次setData的內容更小。)
Container='container',
Childnodes='cn',
Text='v',
NodeType='nt',
NodeName='nn',
// Attrtibutes
Style='st',
Class='cl',
Src='src
我們獲取到以上的 data 數據,去執行enqueueUpdate函數,enqueueUpdate函數內部執行performUpdate函數,performUpdate函數最終執行 ctx.setData,ctx 是小程序的實例,也就是執行我們熟悉的 setData 方法把上面hydrate的 miniData賦值給 root,這樣就渲染了小程序的頁面數據。
// taro-runtime/src/dom/root.ts
public enqueueUpdate (payload: UpdatePayload): void {
this.updatePayloads.push(payload)
if (!this.pendingUpdate && this.ctx) {
this.performUpdate()
}
}
public performUpdate (initRender=false, prerender?: Func) {
// .....
while (this.updatePayloads.length > 0) {
const { path, value }=this.updatePayloads.shift()!
if (path.endsWith(Shortcuts.Childnodes)) {
resetPaths.add(path)
}
data[path]=value
}
// .......
if (initRender) {
// 初次渲染,使用頁面級別的 setData
normalUpdate=data
}
// ........
ctx.setData(normalUpdate, cb)
}
整體流程可以概括為:當在React中調用 this.setState 時,React內部會執行reconciler,進而觸發 enqueueUpdate 方法,如下圖:
事件處理
事件注冊
在HostConfig接口中,有一個方法 finalizeInitialChildren,在這個方法里會調用updateProps。這是掛載頁面階段時間的注冊時機。updateProps 會調用 updatePropsByPayload 方法。
finalizeInitialChildren (dom, type: string, props: any) {
updateProps(dom, {}, props)
//....
},
在HostConfig接口中,有一個方法 commitUpdate,用于在react的commit階段更新屬性:
commitUpdate (dom, updatePayload, _, oldProps, newProps) {
updatePropsByPayload(dom, oldProps, updatePayload)
updateFiberProps(dom, newProps)
},
進一步的調用方法:updatePropsByPayload=> setProperty=> setEvent。
// taro-react/src/props.ts
function setEvent (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) {
const isCapture=name.endsWith('Capture')
let eventName=name.toLowerCase().slice(2)
if (isCapture) {
eventName=eventName.slice(0, -7)
}
const compName=capitalize(toCamelCase(dom.tagName.toLowerCase()))
if (eventName==='click' && compName in internalComponents) {
eventName='tap'
}
// 通過addEventListener將事件注冊到dom中
if (isFunction(value)) {
if (oldValue) {
dom.removeEventListener(eventName, oldValue as any, false)
dom.addEventListener(eventName, value, { isCapture, sideEffect: false })
} else {
dom.addEventListener(eventName, value, isCapture)
}
} else {
dom.removeEventListener(eventName, oldValue as any)
}
}
進一步的看看dom.addEventListener做了什么?addEventListener是類TaroEventTarget的方法:
export class TaroEventTarget {
public __handlers: Record<string, EventHandler[]>={}
public addEventListener (type: string, handler: EventHandler, options?: boolean | AddEventListenerOptions) {
type=type.toLowerCase()
// 省略很多代碼
const handlers=this.__handlers[type]
if (isArray(handlers)) {
handlers.push(handler)
} else {
this.__handlers[type]=[handler]
}
}
}
可以看到事件會注冊到dom對象上,最終會放入到 dom 內部變量 __handlers 中保存。
事件觸發
// base.wxml
<template name="tmpl_0_7">
<view
hover-class="{{xs.b(i.p1,'none')}}"
hover-stop-propagation="{{xs.b(i.p4,!1)}}"
hover-start-time="{{xs.b(i.p2,50)}}"
hover-stay-time="{{xs.b(i.p3,400)}}"
bindtouchstart="eh"
bindtouchmove="eh"
bindtouchend="eh"
bindtouchcancel="eh"
bindlongpress="eh"
animation="{{i.p0}}"
bindanimationstart="eh"
bindanimationiteration="eh"
bindanimationend="eh"
bindtransitionend="eh"
style="{{i.st}}"
class="{{i.cl}}"
bindtap="eh"
id="{{i.uid||i.sid}}"
data-sid="{{i.sid}}"
>
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</view>
</template>
上面是base.wxml其中的一個模板,可以看到,所有組件中的事件都會由 eh 代理。在createPageConfig時,會將 config.eh 賦值為 eventHandler。
// taro-runtime/src/dsl/common.ts
function createPageConfig(){
const config={...} // config會作為小程序 Page() 的入參
config.eh=eventHandler
config.data={root:{cn:[]}}
return config
}
eventHandler 最終會觸發 dom.dispatchEvent(e)。
// taro-runtime/src/dom/element.ts
class TaroElement extends TaroNode {
dispatchEvent(event){
const listeners=this.__handlers[event.type] // 取出回調函數數組
for (let i=listeners.length; i--;) {
result=listener.call(this, event) // event是TaroEvent實例
}
}
}
至此,react 代碼終于是可以完美運行在小程序環境中。
還要提到一點的是,Taro3 在 h5 端的實現也很有意思,Taro在 H5 端實現一套基于小程序規范的組件庫和 API 庫,在這里就不展開說了。
總結
Taro 3從之前的重編譯時,到現在的重運行時,解決了架構問題,可以用 react、vue 甚至 jQuery 來寫小程序,但也帶來了一些性能問題。
為了解決性能問題,Taro 3 也提供了預渲染和虛擬列表等功能和組件。
但從長遠來看,計算機硬件的性能越來越冗余,如果在犧牲一點可以容忍的性能的情況下換來整個框架更大的靈活性和更好的適配性,并且能夠極大的提升開發體驗,也是值得的。
作者:孟祥輝
來源:微信公眾號:哈啰技術
出處:https://mp.weixin.qq.com/s/134VAXPJczElvdYzNFcHhA
*請認真填寫需求信息,我們會在24小時內與您取得聯系。