React Native 预载 WebView 加速内容呈现
静かな森 (Innei)该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/react-native-preload-webview-to-speed-up-content-rendering
本文档仅适用于 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 吧。
import ExpoModulesCore
public class SharedWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("SharedWebViewModule")
}
}
写一个 WebViewManager 来管理 WebView。
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 吧。
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)])
}
}
我们还需要去托管一下相关的状态,例如内容高度。
import Combine
import UIKit
class WebViewState: ObservableObject {
@Published var contentHeight: CGFloat = UIWindow().bounds.height
}
然后把 View 注册到 Module 中。
public class SharedWebViewModule: Module {
public func definition() -> ModuleDefinition {
Name("SharedWebViewModule")
View(ShareWebView.self) {
Events("onContentHeightChange")
}
Function("evaluateJavaScript") { (js: String) in
WebViewManager.evaluateJavaScript(js)
}
}
}
接下来我们还需要实现一个加载 URL 的方法。这里需要注意本地路径。
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 参数:
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:
// expo-module.config.json
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["SharedWebViewModule"]
},
"android": {
"modules": []
}
}
使用
import { requireNativeView } from "expo"
const NativeView: React.ComponentType<
ViewProps & {
onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void
url?: string
}
> = requireNativeView("SharedWebViewModule")
<NativeView /> // 在任意子页面添加这个组件
在某个时机提前预载 WebView 内容:
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 中定义了如下状态,并且方法暴露到全局:
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 的响应式的更新。
例如定义下面的方法:
const setWebViewEntry = (entry: EntryModel) => {
SharedWebViewModule.evaluateJavaScript(
`setEntry(JSON.parse(${JSON.stringify(JSON.stringify(entry))}))`,
)
}
export { setWebViewEntry as preloadWebViewEntry }
在触发 Navigation 前置,提前在后台预载:
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
https://github.com/RSSNext/Follow
Generated by RSStT. The copyright belongs to the original author.