document.getElementById找不到节点
在flutter1.x版本的dev分支上可以使用flutter web,但是我们在使用第三方js sdk的时候会出现问题,比如AgoraRtc、lottie等。
问题都是出现在document.getElementById,因为这些sdk中或者使用的时候需要通过这个方法获取节点来操作,比如lottie,我们封装的代码如下:
... class LottieWidget extends StatefulWidget{ String path; bool isLoop; bool isAutoPlay; double width; double height; ... LottieWidget(this.path, this.width, this.height, this.isAutoPlay, this.isLoop, this._animationListener); @override State<StatefulWidget> createState() { return _lottieWidget; } void lottiePlay() { _lottieWidget.lottiePlay(); } void lottieStop() { _animationListener = null; _lottieWidget.lottieStop(); } } class _LottieWidget extends State<LottieWidget> { @override Widget build(BuildContext context) { js.context["lottieLoaded"] = lottieLoaded; DivElement divElement = DivElement(); divElement.id = "lottie_anim"; StyleElement styleElement = StyleElement(); styleElement.type = "text/css"; styleElement.innerHtml = """ html, body { } """; divElement.append(styleElement); var script = """ var lottieAnim = document.getElementById("lottie_anim"); var lottieObj = lottie.loadAnimation({ container:lottieAnim, renderer: 'svg', loop:${widget.isLoop}, autoplay:${widget.isAutoPlay}, path:"assets/${widget.path}" }); // 动画播放完成触发 lottieObj.addEventListener('complete', lottieLoaded); var lottiePlay = function(){ lottieObj.play(); } var lottiePause = function(){ lottieObj.pause(); } var lottieStop = function() { lottieObj.stop(); } """; ScriptElement scriptElement = new ScriptElement(); scriptElement.innerHtml = script; divElement.append(scriptElement); String _divId = "lottieanim" + DateTime.now().toIso8601String(); ui.platformViewRegistry.registerViewFactory( _divId, (int viewId) => divElement, ); Widget _iframeWidget = HtmlElementView( key: UniqueKey(), viewType: _divId, ); return SizedBox(child: _iframeWidget, width: widget.width, height: widget.height,); } void lottiePlay() { js.context.callMethod("lottiePlay"); } void lottieStop() { js.context.callMethod("lottieStop"); } void lottiePause() { js.context.callMethod("lottiePause"); } // 动画播放完成触发 void lottieLoaded() { print("loaded"); widget._animationListener?.call(); } @override void dispose() { super.dispose(); lottieStop(); widget._animationListener = null; } } 复制代码
可以看到我们将一个id为lottie_anim的div添加到页面中,然后在js代码中通过document.getElementById获取这个节点,并设置到lottie中,这样lottie的sdk中就会在这个div上绘制动画。
但是执行的时候发现动画根本没显示,而且没有报错。通过在js中打印日志逐行测试发现document.getElementById("lottie_anim")获取到的是null。但是为什么是null的呢?
我们运行后打开chrome开发者工具,在Elements栏中查找lottie_anim,发现可以找到,但是它的位置如下:
可以看到这个div是在一个shadow-root下。
那么这个是做什么用的?
Shadow Dom
shadow dom简单来说就是封装,就是将一个组件封装起来,同时设置了隔离,外界无法访问内部的节点。比如video,我们使用的时候非常简单:
<video src="" id='test'></video> 复制代码
但是当我们打开开发者工具,在设置中将show user agent shadow DOM选中后,在回头看Elements中的节点,就会发现video下面存在一个shadow-root,在下面有很多节点,包括播放按钮、播放时长、进度条等等。
关于Shadow DOM,我参考的是quanzhan.applemei.com/webStack/Tk…
正是因为Shadow DOM隐藏的这种特性,导致了上面的问题。因为在flutter中,我们用HtmlElementView来展示html组件,这些组件都会被放在Shadow DOM中,所以导致了在js中通过document.getElementById获取的都是null,也就导致了很多第三方sdk无法正常使用。
解决问题
我们发现了问题,但是如何去解决呢?
其实可以获取Shadow DOM中的节点,只不过要复杂一点。首先我们看上面的节点信息,在Shadow DOM外一层是一个flt-platform-view的节点,这个我们是可以直接获取到的,通过getElementsByTagName,因为页面上可以会有多个flt-platform-view,所以这是一个array,,如下:
我们这里其实只有一个flt-platform-view,所以array里只有一个flt-platform-view节点。然后我们获取它的shadowRoot就可以得到Shadow节点,再通过getElementById来获取我们需要的节点即可,如下:
var sroot = document.getElementsByTagName("flt-platform-view")[0].shadowRoot; var lootiedom = sroot.getElementById('lottie_anim'); 复制代码
这样就可以得到相应的节点,替换上面js第一行代码就可以正常显示了。
其实在网上也有很多人遇到了同样的问题,比如blog.csdn.net/thunder_sz/… ,官方也创建了一个对应的issues:github.com/flutter/flu… 。
里面有人提到了通过slot来解决这个问题,目前我还没有研究明白怎么处理。另外还提到了在flutter2.0上已经解决了该问题,下面我们来聊聊。
Flutter2.0上的Shadow DOM
其实issues也说了,在flutter2.0上只有Canvas Kit解决了这个问题。那么这又是什么?
之前我们在解决image跨域的问题时提到过,flutter有两种渲染模式:CanvasKit和Html(详细介绍见【flutter web:网络图片(圆形图片)、HTML renderer(解决大量图片卡)】)。
在flutter2.0之后,在浏览器中默认使用的就是CanvasKit这种渲染模式,而这种模式就不存在Shadow DOM的问题。运行后节点如下:
可以看到整体结构变化了,没有了Shadow DOM,所以可以直接获取到该节点,这样就不存在问题了。
但是这种模式下存在Image加载网络图片跨域的问题(同样见上面提到的文章),官方给出的解决方案是用html来代替Image,通过图片过多时要使用html render。这样就又回到了之前的问题上了,还需要通过上面的处理来解决。
其实只要解决了Image跨域的问题,还是建议最好使用Canvas Kit来渲染,因为Html Render存在不少问题,比如在debug下不停的打印日志导致非常卡等问题。
同时加载多个HtmlElementView导致失效
因为在项目中,我们可能会同时显示两个HtmlElementView,比如在直播过程(AgoraRtc)中显示动画(lottie),这样如果通过上面的处理就会出现问题。因为上面我们得到的array中都取第一个,这样其实第二个获取的节点是错误的。但是我们又无法确定哪个是第几个,怎么处理?
目前我想到的方法就是遍历,因为我们设置的每个div的id是不同的,所以通过遍历来找到一个即可,代码如下:
var lottieAnim = document.getElementById("lottie_anim"); //渲染模式如果是html,则不能直接这么获取;如果是canvas则可以 var roots = document.getElementsByTagName("flt-platform-view"); for(var i = 0; i < roots.length; i++){ var tmp = roots[i].shadowRoot.getElementById("lottie_anim"); if(tmp){ lottieAnim = tmp; } } 复制代码
这里第一步先正常获取,如果获取不到再通过flt-platform-view获取,并遍历找到节点即可。
AgoraRtc和aliplayer的处理
lottie处理起来比较简单,因为获取节点的操作是我们自己的代码,但是其他sdk就不一样了,比如AgoraRtc和aliplayer是在sdk内部获取节点的,这样我们就需要修改他们的sdk。
AgoraRtc的代码大约在4000多行,如下:
void 0 !== t.elementID ? (document.getElementById(t.elementID).appendChild(t.div), t.container = document.getElementById(t.elementID)) : (document.body.appendChild(t.div), t.container = document.body), t.parentNode = t.div.parentNode; var a = { video: { playerId: t.playerId, stateId: 0, playDeferTimeout: null, error: !1, status: "init", reason: null, updatedAt: Date.now() }, audio: { playerId: t.playerId, stateId: 0, playDeferTimeout: null, error: !1, status: "init", reason: null, updatedAt: Date.now() } }; 复制代码
这里document.getElementById(t.elementID)就无法正常获取,导致进入房间且已经订阅成功,但是一直不显示直播流。
通过上面的方法处理一下即可,如下:
var element = document.getElementsByTagName("flt-platform-view")[0].shadowRoot; void 0 !== t.elementID ? (element.getElementById(t.elementID).appendChild(t.div), t.container = element.getElementById(t.elementID)) : (document.body.appendChild(t.div), t.container = document.body), t.parentNode = t.div.parentNode; console.log(t.elementID); var a = { video: { .. }, audio: { ... } }; 复制代码
这里没有遍历,因为我们进入直播场景第一个加载的一定是直播控件,所以它一定是第一个。但是其实保险起见,还是遍历一下最好。
aliplayer就更复杂了,里面大量的使用了document,比如:
document.activeElement && document.activeElement !== e && document.activeElement.blur() 复制代码
或
r = document.createEvent("MouseEvents") 复制代码
这些都是无法正常执行的,因为我们的player被shadow封装,所以document访问不了。这就需要将所有的document替换成document.getElementsByTagName("flt-platform-view")[0].shadowRoot
,为了方便可以新建一个全局变量方便替换。 这里也没有使用遍历,同样因为在播放场景播放器一定是第一个加载。
总结
通过最后对两个sdk的修改可以感觉到,每次这样处理并不是很好的方法,如果要更新这些sdk就需要重新维护一次。所以最好的办法就是在flutter2.0上使用CanvasKit渲染,当然前提是先解决Image跨域的问题,这个还需要后续研究。如果你的项目不涉及Image加载网络图片跨域,那么直接使用CanvasKit吧,这些js sdk不需要任何处理可以直接使用。