React Native 预载 WebView 加速内容呈现

Note

本文档仅适用于 Expo 52 以上版本。原生组件使用 Swift 编写，适用于 iOS 平台。安卓暂不考虑。

在使用 React Native 编写 App 中，或许会需要 WebView 的地方，例如展示一段 HTML，渲染一个 RSS 内容等。常规的使用 react-native-webview，那么很明显在页面出现之后，还要等待 WebView 加载完相应的 HTML 才能显示内容。例如下面这个简单的例子：

即便是一段简单的 Markdown 文本的渲染，也会出现短暂的空白。

那么，如果想要在页面出现时，内容就已经出现，看上去就和原生渲染一样，那么就需要预载 WebView 的内容。

想法

我们需要构建一个网页，此网页需要支持通过传递 Props 动态展示需要展示的内容。

在 App 的某个时机，或者进入某个模块时，加载 WebView 并且预加载网页，但是处于离屏状态

在某个时机，比如在触发 Navigate 时，传递需要展示的数据 Props 给 WebView 组件，处于离屏的 WebView 收到 Props 后，立即触发重渲染（耗时低）。

下一屏 View 展示 WebView 组件，WebView 从离屏移动到屏幕上。

也就是说，我们需要实现一个后台常驻的 WebView, 并且只需要通过桥传递不同的数据，即可展示不同的内容。

编写原生 WebView 组件

为了实现这个效果，我们需要使用编写原生组件。下面以 Expo Module + iOS 为例。

首先我们需要编写一个 Expo Module。就叫 SharedWebViewModule 吧。

Copy
import ExpoModulesCore
public class SharedWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("SharedWebViewModule")
}
}

写一个 WebViewManager 来管理 WebView。

Copy
import ExpoModulesCore
import SwiftUI
@preconcurrency import WebKit

private var pendingJavaScripts: [String] = []

protocol WebViewLinkDelegate: AnyObject {
func webView(_ webView: WKWebView, shouldOpenURL url: URL)
}

enum WebViewManager {
static var state = WebViewState()

public static func evaluateJavaScript(_ js: String) {
DispatchQueue.main.async {
guard let webView = SharedWebViewModule.sharedWebView else {
pendingJavaScripts.append(js)
return
}
guard webView.url != nil else {
pendingJavaScripts.append(js)
return
}

if webView.isLoading {
pendingJavaScripts.append(js)
} else {
webView.evaluateJavaScript(js)
}
}
}

static private(set) var shared: WKWebView = {
SharedWebView(frame: .zero, state: state)
}()

static func resetWebView() {
self.state = WebViewState()
self.shared = FOWebView(frame: .zero, state: state)
}
}

这里也实现了 evaluateJavaScript 方法，用于在后台执行 JavaScript 代码。

接下来编写 View， 就叫 ShareWebView 吧。

Copy
import Combine
import ExpoModulesCore
import SnapKit
import SwiftUI
import WebKit

class ShareWebView: ExpoView {
private var cancellable: AnyCancellable?
private let rctView = RCTView(frame: .zero)

required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
addSubview(rctView)

rctView.addSubview(SharedWebViewModule.sharedWebView!)

clipsToBounds = true
cancellable = WebViewManager.state.$contentHeight
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.layoutSubviews()
}
}

deinit {
cancellable?.cancel()
}

private let onContentHeightChange = ExpoModulesCore.EventDispatcher()

override func layoutSubviews() {
let rect = CGRect(
x: bounds.origin.x,
y: bounds.origin.y,
width: bounds.width,
height: WebViewManager.state.contentHeight
)
guard let webView = SharedWebViewModule.sharedWebView else { return }
webView.frame = rect
webView.scrollView.frame = rect

frame = rect
rctView.frame = rect
onContentHeightChange(["height": Float(rect.height)])

}
}

我们还需要去托管一下相关的状态，例如内容高度。

Copy
import Combine
import UIKit

class WebViewState: ObservableObject {
@Published var contentHeight: CGFloat = UIWindow().bounds.height
}

然后把 View 注册到 Module 中。

Copy
public class SharedWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("SharedWebViewModule")

View(ShareWebView.self) {
Events("onContentHeightChange")
}

Function("evaluateJavaScript") { (js: String) in
WebViewManager.evaluateJavaScript(js)
}

}
}

接下来我们还需要实现一个加载 URL 的方法。这里需要注意本地路径。

Copy
class SharedWebViewModule: Module {

private func load(urlString: String) {
guard let webView = SharedWebViewModule.sharedWebView else {
return
}

let urlProtocol = "file://"
if urlString.starts(with: urlProtocol) { // 判断本地路径
let localHtml = self.getLocalHTML(from: urlString)

if let localHtml = localHtml {
webView.loadFileURL(
localHtml,
allowingReadAccessTo: localHtml.deletingLastPathComponent()
)

debugPrint("load local html: \(localHtml.absoluteString)")

return
}
}

if let url = URL(string: urlString) {
if url == webView.url {
return
}
debugPrint("load remote html: \(url.absoluteString)")
webView.load(URLRequest(url: url))
}
}

private func getLocalHTML(from fileURL: String) -> URL? {
if let url = URL(string: fileURL), url.scheme == "file" {

let directoryPath = url.deletingLastPathComponent().absoluteString.replacingOccurrences(
of: "file://", with: ""
)
let fileName = url.lastPathComponent
let fileExtension = url.pathExtension

if let fileURL = Bundle.main
.url(
forResource: String(fileName.dropLast(Int(fileExtension.count) + 1)),
withExtension: fileExtension,
subdirectory: directoryPath
)
{

return fileURL
} else {
return nil

}
} else {
debugPrint("Invalidate url")
return nil
}
}
}

设置 View 的 url 参数：

Copy
class SharedWebViewModule: Module {
public func definition() -> ModuleDefinition {

View(WebViewView.self) {
Events("onContentHeightChange")

Prop("url") { (_: UIView, urlString: String) in // 设置 url 参数
DispatchQueue.main.async {
self.load(urlString: urlString)
}
}
}

Function("load") { (url: String) in // 加载 url 的方法
self.load(urlString: url)
}
}
}

注册 Expo Module：

Copy
// expo-module.config.json

{
"platforms": ["apple", "android"],
"apple": {
"modules": ["SharedWebViewModule"]
},
"android": {
"modules": []
}
}

使用

Copy
import { requireNativeView } from "expo"
const NativeView: React.ComponentType<
ViewProps & {
onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void
url?: string
}
> = requireNativeView("SharedWebViewModule")

<NativeView /> // 在任意子页面添加这个组件

在某个时机提前预载 WebView 内容：

Copy
import { Image, Platform } from 'react-native'

const assetPath = Image.resolveAssetSource({
uri: 'rn-web/html-renderer', // 这个路径是 XCode 的 Bundle Resources
// 可以参考: https://github.com/RSSNext/Follow/blob/995b269260541fd85beba3d050401c499463e2b1/apps/mobile/scripts/with-follow-assets.js
}).uri
export const htmlUrl = Platform.select({
ios: `file://${assetPath}/index.html`,
default: '',
})

const prepareOnce = false
export const prepareEntryRenderWebView = () => {
if (prepareOnce) return
prepareOnce = true
SharedWebViewModule.load(htmlUrl)
}

事件传递数据更新内容

在 Web app 中，我们借助任何外部状态管理库，例如 jotai，即可实现状态的传递和 UI 的响应式的更新。

例如，我们在 Web app 中定义了如下状态，并且方法暴露到全局：

Copy
const store = createStore()

Object.assign(window, {
setEntry(entry: EntryModel) {
store.set(entryAtom, entry)
bridge.measure()
},
setCodeTheme(light: string, dark: string) {
store.set(codeThemeLightAtom, light)
store.set(codeThemeDarkAtom, dark)
},
setReaderRenderInlineStyle(value: boolean) {
store.set(readerRenderInlineStyleAtom, value)
},
setNoMedia(value: boolean) {
store.set(noMediaAtom, value)
},
setShowReadability(value: boolean) {
store.set(showReadabilityAtom, value)
},
reset() {
store.set(entryAtom, null)
bridge.measure()
},
})

在 WebView 中，我们可以通过 evaluateJavaScript 方法，执行 JavaScript 代码，从而更新状态。

那么在 React Native 中我们使用 Module 暴露的 evaluateJavaScript 方法，即可实现状态的传递和 UI 的响应式的更新。

例如定义下面的方法：

Copy
const setWebViewEntry = (entry: EntryModel) => {
SharedWebViewModule.evaluateJavaScript(
`setEntry(JSON.parse(${JSON.stringify(JSON.stringify(entry))}))`,
)
}
export { setWebViewEntry as preloadWebViewEntry }

在触发 Navigation 前置，提前在后台预载：

Copy
const handlePressPreview = useCallback(() => {
preloadWebViewEntry(entry) // 预载
navigation.pushControllerView(EntryDetailScreen, { // 触发 Navigation
entryId: id,
view: view!,
})
}, [entry, id, navigation, view])

大致思路就是这样啦。大功告成。

来看看效果：

最后所有的代码都是开源的。

上面的代码具体实现，在 Follow 这个项目中，大家可以点点 Star 哦。

https://github.com/RSSNext/Follow/tree/dev/apps/mobile/native/ios/Modules/SharedWebView

此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/tech/react-native-preload-webview-to-speed-up-content-rendering