[!NOTE] 本文档仅适用于 Expo 52 以上版本。原生组件使用 Swift 编写,适用于 iOS 平台。安卓暂不考虑。
在使用 React Native 编写 App 中,或许会需要 WebView 的地方,例如展示一段 HTML,渲染一个 RSS 内容等。常规的使用 react-native-webview,那么很明显在页面出现之后,还要等待 WebView 加载完相应的 HTML 才能显示内容。例如下面这个简单的例子:
react-native-webview
即便是一段简单的 Markdown 文本的渲染,也会出现短暂的空白。
那么,如果想要在页面出现时,内容就已经出现,看上去就和原生渲染一样,那么就需要预载 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 代码,从而更新状态。
evaluateJavaScript
那么在 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
此文由 Mix Space 同步更新至 xLog 原始链接为 https://innei.in/posts/tech/react-native-preload-webview-to-speed-up-content-rendering