数适配器机制不仅复杂,而且成本很高。
本文最初发表于 v8.dev(Faster JavaScript calls),基于 CC 3.0 协议分享,由 InfoQ 翻译并发布。
JavaScript 允许使用与预期形式参数数量不同的实际参数来调用一个函数,也就是传递的实参可以少于或者多于声明的形参数量。前者称为申请不足(under-application),后者称为申请过度(over-application)。
在申请不足的情况下,剩余形式参数会被分配 undefined 值。在申请过度的情况下,可以使用 rest 参数和 arguments 属性访问剩余实参,或者如果它们是多余的可以直接忽略。如今,许多 Web/Node.js 框架都使用这个 JS 特性来接受可选形参,并创建更灵活的 API。
直到最近,V8 都有一种专门的机制来处理参数大小不匹配的情况:这种机制叫做参数适配器框架。不幸的是,参数适配是有性能成本的,但在现代的前端和中间件框架中这种成本往往是必须的。但事实证明,我们可以通过一个巧妙的技巧来拿掉这个多余的框架,简化 V8 代码库并消除几乎所有的开销。
我们可以通过一个微型基准测试来计算移除参数适配器框架可以获得的性能收益。
console.time();
function f(x, y, z) {}
for (let i=0; i < N; i++) {
f(1, 2, 3, 4, 5);
}
console.timeEnd();
移除参数适配器框架的性能收益,通过一个微基准测试来得出。
上图显示,在无 JIT 模式(Ignition)下运行时,开销消失,并且性能提高了 11.2%。使用 TurboFan 时,我们的速度提高了 40%。
这个微基准测试自然是为了最大程度地展现参数适配器框架的影响而设计的。但是,我们也在许多基准测试中看到了显著的改进,例如我们内部的 JSTests/Array 基准测试(7%)和 Octane2(Richards 子项为 4.6%,EarleyBoyer 为 6.1%)。
这个项目的重点是移除参数适配器框架,这个框架在访问栈中被调用者的参数时为其提供了一个一致的接口。为此,我们需要反转栈中的参数,并在被调用者框架中添加一个包含实际参数计数的新插槽。下图显示了更改前后的典型框架示例。
移除参数适配器框架之前和之后的典型 JavaScript 栈框架。
为了讲清楚我们如何加快调用,首先我们来看看 V8 如何执行一个调用,以及参数适配器框架如何工作。
当我们在 JS 中调用一个函数调用时,V8 内部会发生什么呢?用以下 JS 脚本为例:
function add42(x) {
return x + 42;
}
add42(3);
在函数调用期间 V8 内部的执行流程。
V8 是一个多层 VM。它的第一层称为 Ignition,是一个具有累加器寄存器的字节码栈机。V8 首先会将代码编译为 Ignition 字节码。上面的调用被编译为以下内容:
0d LdaUndefined ;; Load undefined into the accumulator
26 f9 Star r2 ;; Store it in register r2
13 01 00 LdaGlobal [1] ;; Load global pointed by const 1 (add42)
26 fa Star r1 ;; Store it in register r1
0c 03 LdaSmi [3] ;; Load small integer 3 into the accumulator
26 f8 Star r3 ;; Store it in register r3
5f fa f9 02 CallNoFeedback r1, r2-r3 ;; Invoke call
调用的第一个参数通常称为接收器(receiver)。接收器是 JSFunction 中的 this 对象,并且每个 JS 函数调用都必须有一个 this。CallNoFeedback 的字节码处理器需要使用寄存器列表 r2-r3 中的参数来调用对象 r1。
在深入研究字节码处理器之前,请先注意寄存器在字节码中的编码方式。它们是负的单字节整数:r1 编码为 fa,r2 编码为 f9,r3 编码为 f8。我们可以将任何寄存器 ri 称为 fb - i,实际上正如我们所见,正确的编码是- 2 - kFixedFrameHeaderSize - i。寄存器列表使用第一个寄存器和列表的大小来编码,因此 r2-r3 为 f9 02。
Ignition 中有许多字节码调用处理器。可以在此处查看它们的列表。它们彼此之间略有不同。有些字节码针对 undefined 的接收器调用、属性调用、具有固定数量的参数调用或通用调用进行了优化。在这里我们分析 CallNoFeedback,这是一个通用调用,在该调用中我们不会积累执行过程中的反馈。
这个字节码的处理器非常简单。它是用 CodeStubAssembler 编写的,你可以在此处查看。本质上,它会尾调用一个架构依赖的内置 InterpreterPushArgsThenCall。
这个内置方法实际上是将返回地址弹出到一个临时寄存器中,压入所有参数(包括接收器),然后压回该返回地址。此时,我们不知道被调用者是否是可调用对象,也不知道被调用者期望多少个参数,也就是它的形式参数数量。
内置 InterpreterPushArgsThenCall 执行后的框架状态。
最终,执行会尾调用到内置的 Call。它会在那里检查目标是否是适当的函数、构造器或任何可调用对象。它还会读取共享 shared function info 结构以获得其形式参数计数。
如果被调用者是一个函数对象,它将对内置的 CallFunction 进行尾部调用,并在其中进行一系列检查,包括是否有 undefined 对象作为接收器。如果我们有一个 undefined 或 null 对象作为接收器,则应根据 ECMA 规范对其修补,以引用全局代理对象。
执行随后会对内置的 InvokeFunctionCode 进行尾调用。在没有参数不匹配的情况下,InvokeFunctionCode 只会调用被调用对象中字段 Code 所指向的内容。这可以是一个优化函数,也可以是内置的 InterpreterEntryTrampoline。
如果我们假设要调用的函数尚未优化,则 Ignition trampoline 将设置一个 IntepreterFrame。你可以在此处查看V8 中框架类型的简短摘要。
接下来发生的事情就不用多谈了,我们可以看一个被调用者执行期间的解释器框架快照。
我们看到框架中有固定数量的插槽:返回地址、前一个框架指针、上下文、我们正在执行的当前函数对象、该函数的字节码数组以及我们当前正在执行的字节码偏移量。最后,我们有一个专用于此函数的寄存器列表(你可以将它们视为函数局部变量)。add42 函数实际上没有任何寄存器,但是调用者具有类似的框架,其中包含 3 个寄存器。
如预期的那样,add42 是一个简单的函数:
25 02 Ldar a0 ;; Load the first argument to the accumulator
40 2a 00 AddSmi [42] ;; Add 42 to it
ab Return ;; Return the accumulator
请注意我们在 Ldar(Load Accumulator Register)字节码中编码参数的方式:参数 1(a0)用数字 02 编码。实际上,任何参数的编码规则都是[ai]=2 + parameter_count - i - 1,接收器[this]=2 + parameter_count,或者在本例中[this]=3。此处的参数计数不包括接收器。
现在我们就能理解为什么用这种方式对寄存器和参数进行编码。它们只是表示一个框架指针的偏移量。然后,我们可以用相同的方式处理参数/寄存器的加载和存储。框架指针的最后一个参数偏移量为 2(先前的框架指针和返回地址)。这就解释了编码中的 2。解释器框架的固定部分是 6 个插槽(4 个来自框架指针),因此寄存器零位于偏移量-5 处,也就是 fb,寄存器 1 位于 fa 处。很聪明是吧?
但请注意,为了能够访问参数,该函数必须知道栈中有多少个参数!无论有多少参数,索引 2 都指向最后一个参数!
Return 的字节码处理器将调用内置的 LeaveInterpreterFrame 来完成。该内置函数本质上是从框架中读取函数对象以获取参数计数,弹出当前框架,恢复框架指针,将返回地址保存在一个暂存器中,根据参数计数弹出参数并跳转到暂存器中的地址。
这套流程很棒!但是,当我们调用一个实参数量少于或多于其形参数量的函数时,会发生什么呢?这个聪明的参数/寄存器访问流程将失败,我们该如何在调用结束时清理参数?
现在,我们使用更少或更多的实参来调用 add42:
add42();
add42(1, 2, 3);
JS 开发人员会知道,在第一种情况下,x 将被分配 undefined,并且该函数将返回 undefined + 42=NaN。在第二种情况下,x 将被分配 1,函数将返回 43,其余参数将被忽略。请注意,调用者不知道是否会发生这种情况。即使调用者检查了参数计数,被调用者也可以使用 rest 参数或 arguments 对象访问其他所有参数。实际上,在 sloppy 模式下甚至可以在 add42 外部访问 arguments 对象。
如果我们执行与之前相同的步骤,则将首先调用内置的 InterpreterPushArgsThenCall。它将像这样将参数推入栈:
内置 InterpreterPushArgsThenCall 执行后的框架状态。
继续与以前相同的过程,我们检查被调用者是否为函数对象,获取其参数计数,并将接收器补到全局代理。最终,我们到达了 InvokeFunctionCode。
在这里我们不会跳转到被调用者对象中的 Code。我们检查参数大小和参数计数之间是否存在不匹配,然后跳转到 ArgumentsAdaptorTrampoline。
在这个内置组件中,我们构建了一个额外的框架,也就是臭名昭著的参数适配器框架。这里我不会解释内置组件内部发生了什么,只会向你展示内置组件调用被调用者的 Code 之前的框架状态。请注意,这是一个正确的 x64 call(不是 jmp),在被调用者执行之后,我们将返回到 ArgumentsAdaptorTrampoline。这与进行尾调用的 InvokeFunctionCode 正好相反。
我们创建了另一个框架,该框架复制了所有必需的参数,以便在被调用者框架顶部精确地包含参数的形参计数。它创建了一个被调用者函数的接口,因此后者无需知道参数数量。被调用者将始终能够使用与以前相同的计算结果来访问其参数,即[ai]=2 + parameter_count - i - 1。
V8 具有一些特殊的内置函数,它们在需要通过 rest 参数或 arguments 对象访问其余参数时能够理解适配器框架。它们始终需要检查被调用者框架顶部的适配器框架类型,然后采取相应措施。
如你所见,我们解决了参数/寄存器访问问题,但是却添加了很多复杂性。需要访问所有参数的内置组件都需要了解并检查适配器框架的存在。不仅如此,我们还需要注意不要访问过时的旧数据。考虑对 add42 的以下更改:
function add42(x) {
x +=42;
return x;
}
现在,字节码数组为:
25 02 Ldar a0 ;; Load the first argument to the accumulator
40 2a 00 AddSmi [42] ;; Add 42 to it
26 02 Star a0 ;; Store accumulator in the first argument slot
ab Return ;; Return the accumulator
如你所见,我们现在修改 a0。因此,在调用 add42(1, 2, 3)的情况下,参数适配器框架中的插槽将被修改,但调用者框架仍将包含数字 1。我们需要注意,参数对象正在访问修改后的值,而不是旧值。
从函数返回很简单,只是会很慢。还记得 LeaveInterpreterFrame 做什么吗?它基本上会弹出被调用者框架和参数,直到到达最大形参计数为止。因此,当我们返回参数适配器存根时,栈如下所示:
被调用者 add42 执行之后的框架状态。
我们需要弹出参数数量,弹出适配器框架,根据实际参数计数弹出所有参数,然后返回到调用者执行。
简单总结:参数适配器机制不仅复杂,而且成本很高。
我们可以做得更好吗?我们可以移除适配器框架吗?事实证明我们确实可以。
我们回顾一下之前的需求:
如果要消除多余的框架,则需要确定将参数放在何处:在被调用者框架中还是在调用者框架中。
假设我们将参数放在被调用者框架中。这似乎是一个好主意,因为无论何时弹出框架,我们都会一次弹出所有参数!
参数必须位于保存的框架指针和框架末尾之间的某个位置。这就要求框架的大小不会被静态地知晓。访问参数仍然很容易,它就是一个来自框架指针的简单偏移量。但现在访问寄存器要复杂得多,因为它会根据参数的数量而变化。
栈指针总是指向最后一个寄存器,然后我们可以使用它来访问寄存器而无需知道参数计数。这种方法可能行得通,但它有一个关键缺陷。它需要复制所有可以访问寄存器和参数的字节码。我们将需要 LdaArgument 和 LdaRegister,而不是简单的 Ldar。当然,我们还可以检查我们是否正在访问一个参数或寄存器(正或负偏移量),但这将需要检查每个参数和寄存器访问。显然这种方法太昂贵了!
好的,如果我们在调用者框架中放参数呢?
记住如何计算一个框架中参数 i 的偏移量:[ai]=2 + parameter_count - i - 1。如果我们拥有所有参数(不仅是形式参数),则偏移量将为[ai]=2 + parameter_count - i - 1.也就是说,对于每个参数访问,我们都需要加载实际的参数计数。
但如果我们反转参数会发生什么呢?现在可以简单地将偏移量计算为[ai]=2 + i。我们不需要知道栈中有多少个参数,但如果我们可以保证栈中至少有形参计数那么多的参数,那么我们就能一直使用这种方案来计算偏移量。
换句话说,压入栈的参数数量将始终是参数数量和形参数量之间的最大值,并且在需要时使用 undefined 对象进行填充。
这还有另一个好处!对于任何 JS 函数,接收器始终位于相同的偏移量处,就在返回地址的正上方:[this]=2。
对于我们的第 1 和第 4 条要求,这是一个干净的解决方案。另外两个要求又如何呢?我们如何构造 rest 参数和 arguments 对象?返回调用者时如何清理栈中的参数?为此,我们缺少的只是参数计数而已。我们需要将其保存在某个地方。只要可以轻松访问此信息即可,具体怎么做没那么多限制。两种基本选项分别是:将其推送到调用者框架中的接收者之后,或被调用者框架中的固定标头部分。我们实现了后者,因为它合并了 Interpreter 和 Optimized 框架的固定标头部分。
如果在 V8 v8.9 中运行前面的示例,则在 InterpreterArgsThenPush 之后将看到以下栈(请注意,现在参数已反转):
内置 InterpreterPushArgsThenCall 执行后的框架状态。
所有执行都遵循类似的路径,直到到达 InvokeFunctionCode。在这里,我们在申请不足的情况下处理参数,根据需要推送尽可能多的 undefined 对象。请注意,在申请过度的情况下,我们不会进行任何更改。最后,我们通过一个寄存器将参数数量传递给被调用者的 Code。在 x64 的情况下,我们使用寄存器 rax。
如果被调用者尚未进行优化,我们将到达 InterpreterEntryTrampoline,它会构建以下栈框架。
没有参数适配器的栈框架。
被调用者框架有一个额外的插槽,其中包含的参数计数可用于构造 rest 参数或 arguments 对象,并在返回到调用者之前清除栈中参数。
返回时,我们修改 LeaveInterpreterFrame 以读取栈中的参数计数,并弹出参数计数和形式参数计数之间的较大数字。
那么代码优化呢?我们来稍微更改一下初始脚本,以强制 V8 使用 TurboFan 对其进行编译:
function add42(x) { return x + 42; }
function callAdd42() { add42(3); }
%PrepareFunctionForOptimization(callAdd42);
callAdd42();
%OptimizeFunctionOnNextCall(callAdd42);
callAdd42();
在这里,我们使用 V8 内部函数来强制 V8 优化调用,否则 V8 仅在我们的小函数变热(经常使用)时才对其进行优化。我们在优化之前调用它一次,以收集一些可用于指导编译的类型信息。在此处阅读有关 TurboFan 的更多信息(https://v8.dev/docs/turbofan)。
这里,我只展示与主题相关的部分生成代码。
movq rdi,0x1a8e082126ad ;; Load the function object <JSFunction add42>
push 0x6 ;; Push SMI 3 as argument
movq rcx,0x1a8e082030d1 ;; <JSGlobal Object>
push rcx ;; Push receiver (the global proxy object)
movl rax,0x1 ;; Save the arguments count in rax
movl rcx,[rdi+0x17] ;; Load function object {Code} field in rcx
call rcx ;; Finally, call the code object!
尽管这段代码使用了汇编来编写,但如果你仔细看我的注释应该很容易能懂。本质上,在编译调用时,TF 需要完成之前在 InterpreterPushArgsThenCall、Call、CallFunction 和 InvokeFunctionCall 内置组件中完成的所有工作。它应该会有更多的静态信息来执行此操作并发出更少的计算机指令。
现在,让我们来看看参数数量和参数计数不匹配的情况。考虑调用 add42(1, 2, 3)。它会编译为:
movq rdi,0x4250820fff1 ;; Load the function object <JSFunction add42>
;; Push receiver and arguments SMIs 1, 2 and 3
movq rcx,0x42508080dd5 ;; <JSGlobal Object>
push rcx
push 0x2
push 0x4
push 0x6
movl rax,0x3 ;; Save the arguments count in rax
movl rbx,0x1 ;; Save the formal parameters count in rbx
movq r10,0x564ed7fdf840 ;; <ArgumentsAdaptorTrampoline>
call r10 ;; Call the ArgumentsAdaptorTrampoline
如你所见,不难为 TF 添加对参数和参数计数不匹配的支持。只需调用参数适配器 trampoline 即可!
然而这种方法成本很高。对于每个优化的调用,我们现在都需要进入参数适配器 trampoline,并像未优化的代码一样处理框架。这就解释了为什么在优化的代码中移除适配器框架的性能收益比在 Ignition 上大得多。
但是,生成的代码非常简单。从中返回非常容易(结尾):
movq rsp,rbp ;; Clean callee frame
pop rbp
ret 0x8 ;; Pops a single argument (the receiver)
我们弹出框架并根据参数计数发出一个返回指令。如果实参计数和形参计数不匹配,则适配器框架 trampoline 将对其进行处理。
生成的代码本质上与参数计数匹配的调用代码相同。考虑调用 add42(1, 2, 3)。这将生成:
movq rdi,0x35ac082126ad ;; Load the function object <JSFunction add42>
;; Push receiver and arguments 1, 2 and 3 (reversed)
push 0x6
push 0x4
push 0x2
movq rcx,0x35ac082030d1 ;; <JSGlobal Object>
push rcx
movl rax,0x3 ;; Save the arguments count in rax
movl rcx,[rdi+0x17] ;; Load function object {Code} field in rcx
call rcx ;; Finally, call the code object!
该函数的结尾如何?我们不再回到参数适配器 trampoline 了,因此结尾确实比以前复杂了一些。
movq rcx,[rbp-0x18] ;; Load the argument count (from callee frame) to rcx
movq rsp,rbp ;; Pop out callee frame
pop rbp
cmpq rcx,0x0 ;; Compare arguments count with formal parameter count
jg 0x35ac000840c6 <+0x86>
;; If arguments count is smaller (or equal) than the formal parameter count:
ret 0x8 ;; Return as usual (parameter count is statically known)
;; If we have more arguments in the stack than formal parameters:
pop r10 ;; Save the return address
leaq rsp,[rsp+rcx*8+0x8] ;; Pop all arguments according to rcx
push r10 ;; Recover the return address
retl
参数适配器框架是一个临时解决方案,用于实际参数和形式参数计数不匹配的调用。这是一个简单的解决方案,但它带来了很高的性能成本,并增加了代码库的复杂性。如今,许多 Web 框架使用这一特性来创建更灵活的 API,结果带来了更高的性能成本。反转栈中参数这个简单的想法可以大大降低实现复杂性,并消除了此类调用的几乎所有开销。
原文链接:
https://v8.dev/blog/adaptor-frame
延伸阅读:
关注我并转发此篇文章,即可获得学习资料~若想了解更多,也可移步InfoQ官网,获取InfoQ最新资讯~
阅读本文前请先阅读:Android开发:最全面、最易懂的Webview详解
Android与JS通过WebView互相调用方法,实际上是:
二者沟通的桥梁是WebView
对于Android调用JS代码的方法有2种:
1. 通过WebView的loadUrl()
2. 通过WebView的evaluateJavascript()
对于JS调用Android代码的方法有3种:
1. 通过WebView的addJavascriptInterface()进行对象映射
2. 通过 WebViewClient 的shouldOverrideUrlLoading ()方法回调拦截 url
3. 通过 WebChromeClient 的onJsAlert()、onJsConfirm()、onJsPrompt()方法回调拦截JS对话框alert()、confirm()、prompt() 消息
2.1 Android通过WebView调用 JS 代码
对于Android调用JS代码的方法有2种:
1. 通过WebView的loadUrl()
2. 通过WebView的evaluateJavascript()
方式1:通过WebView的loadUrl()
步骤1:将需要调用的JS代码以.html格式放到src/main/assets文件夹里
需要加载JS代码:javascript.html
// 文本名:javascript
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Carson_Ho</title>
// JS代码
<script>
// Android需要调用的方法
function callJS(){
alert("Android调用了JS的callJS方法");
}
</script>
</head>
</html>
步骤2:在Android里通过WebView设置调用JS代码
Android代码:MainActivity.java
注释已经非常清楚
public class MainActivity extends AppCompatActivity {
WebView mWebView;
Button button;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView=(WebView) findViewById(R.id.webview);
WebSettings webSettings=mWebView.getSettings();
// 设置与Js交互的权限
webSettings.setJavaScriptEnabled(true);
// 设置允许JS弹窗
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
// 先载入JS代码
// 格式规定为:file:///android_asset/文件名.html
mWebView.loadUrl("file:///android_asset/javascript.html");
button=(Button) findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 通过Handler发送消息
mWebView.post(new Runnable() {
@Override
public void run() {
// 注意调用的JS方法名要对应上
// 调用javascript的callJS()方法
mWebView.loadUrl("javascript:callJS()");
}
});
}
});
// 由于设置了弹窗检验调用结果,所以需要支持js对话框
// webview只是载体,内容的渲染需要使用webviewChromClient类去实现
// 通过设置WebChromeClient对象处理JavaScript的对话框
//设置响应js 的Alert()函数
mWebView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onJsAlert(WebView view, String url, String message, final JsResult result) {
AlertDialog.Builder b=new AlertDialog.Builder(MainActivity.this);
b.setTitle("Alert");
b.setMessage(message);
b.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
result.confirm();
}
});
b.setCancelable(false);
b.create().show();
return true;
}
});
}
}
特别注意:JS代码调用一定要在 onPageFinished() 回调之后才能调用,否则不会调用。
onPageFinished()属于WebViewClient类的方法,主要在页面加载结束时调用
// 只需要将第一种方法的loadUrl()换成下面该方法即可
mWebView.evaluateJavascript("javascript:callJS()", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
//此处为 js 返回的结果
}
});
}
2.1.2 方法对比
2.1.3 使用建议
两种方法混合使用,即Android 4.4以下使用方法1,Android 4.4以上方法2
// Android版本变量
final int version=Build.VERSION.SDK_INT;
// 因为该方法在 Android 4.4 版本才可使用,所以使用时需进行版本判断
if (version < 18) {
mWebView.loadUrl("javascript:callJS()");
} else {
mWebView.evaluateJavascript("javascript:callJS()", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
//此处为 js 返回的结果
}
});
}
对于JS调用Android代码的方法有3种:
1. 通过WebView的addJavascriptInterface()进行对象映射
2. 通过 WebViewClient 的shouldOverrideUrlLoading ()方法回调拦截 url
3. 通过 WebChromeClient 的onJsAlert()、onJsConfirm()、onJsPrompt()方法回调拦截JS对话框alert()、confirm()、prompt() 消息
2.2.1 方法分析
方式1:通过 WebView的addJavascriptInterface()进行对象映射
步骤1:定义一个与JS对象映射关系的Android类:AndroidtoJs
AndroidtoJs.java(注释已经非常清楚)
// 继承自Object类
public class AndroidtoJs extends Object {
// 定义JS需要调用的方法
// 被JS调用的方法必须加入@JavascriptInterface注解
@JavascriptInterface
public void hello(String msg) {
System.out.println("JS调用了Android的hello方法");
}
}
步骤2:将需要调用的JS代码以.html格式放到src/main/assets文件夹里
需要加载JS代码:javascript.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Carson</title>
<script>
function callAndroid(){
// 由于对象映射,所以调用test对象等于调用Android映射的对象
test.hello("js调用了android中的hello方法");
}
</script>
</head>
<body>
//点击按钮则调用callAndroid函数
<button type="button" id="button1" onclick="callAndroid()"></button>
</body>
</html>
步骤3:在Android里通过WebView设置Android类与JS代码的映射
详细请看注释
public class MainActivity extends AppCompatActivity {
WebView mWebView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView=(WebView) findViewById(R.id.webview);
WebSettings webSettings=mWebView.getSettings();
// 设置与Js交互的权限
webSettings.setJavaScriptEnabled(true);
// 通过addJavascriptInterface()将Java对象映射到JS对象
//参数1:Javascript对象名
//参数2:Java对象名
mWebView.addJavascriptInterface(new AndroidtoJs(), "test");//AndroidtoJS类对象映射到js的test对象
// 加载JS代码
// 格式规定为:file:///android_asset/文件名.html
mWebView.loadUrl("file:///android_asset/javascript.html");
仅将Android对象和JS对象映射即可
即JS需要调用Android的方法
以.html格式放到src/main/assets文件夹里
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Carson_Ho</title>
<script>
function callAndroid(){
/*约定的url协议为:js://webview?arg1=111&arg2=222*/
document.location="js://webview?arg1=111&arg2=222";
}
</script>
</head>
<!-- 点击按钮则调用callAndroid()方法 -->
<body>
<button type="button" id="button1" onclick="callAndroid()">点击调用Android代码</button>
</body>
</html>
当该JS通过Android的mWebView.loadUrl("file:///android_asset/javascript.html")加载后,就会回调shouldOverrideUrlLoading (),接下来继续看步骤2:
步骤2:在Android通过WebViewClient复写shouldOverrideUrlLoading ()
MainActivity.java
public class MainActivity extends AppCompatActivity {
WebView mWebView;
// Button button;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView=(WebView) findViewById(R.id.webview);
WebSettings webSettings=mWebView.getSettings();
// 设置与Js交互的权限
webSettings.setJavaScriptEnabled(true);
// 设置允许JS弹窗
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
// 步骤1:加载JS代码
// 格式规定为:file:///android_asset/文件名.html
mWebView.loadUrl("file:///android_asset/javascript.html");
// 复写WebViewClient类的shouldOverrideUrlLoading方法
mWebView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
// 步骤2:根据协议的参数,判断是否是所需要的url
// 一般根据scheme(协议格式) & authority(协议名)判断(前两个参数)
//假定传入进来的 url="js://webview?arg1=111&arg2=222"(同时也是约定好的需要拦截的)
Uri uri=Uri.parse(url);
// 如果url的协议=预先约定的 js 协议
// 就解析往下解析参数
if ( uri.getScheme().equals("js")) {
// 如果 authority=预先约定协议里的 webview,即代表都符合约定的协议
// 所以拦截url,下面JS开始调用Android需要的方法
if (uri.getAuthority().equals("webview")) {
// 步骤3:
// 执行JS所需要调用的逻辑
System.out.println("js调用了Android的方法");
// 可以在协议上带有参数并传递到Android上
HashMap<String, String> params=new HashMap<>();
Set<String> collection=uri.getQueryParameterNames();
}
return true;
}
return super.shouldOverrideUrlLoading(view, url);
}
}
);
}
}
如果JS想要得到Android方法的返回值,只能通过 WebView 的 loadUrl ()去执行 JS 方法把返回值传递回去,相关的代码如下:
// Android:MainActivity.java
mWebView.loadUrl("javascript:returnResult(" + result + ")");
// JS:javascript.html
function returnResult(result){
alert("result is" + result);
}
在JS中,有三个常用的对话框方法:
方式3的原理:Android通过 WebChromeClient 的onJsAlert()、onJsConfirm()、onJsPrompt()方法回调分别拦截JS对话框
(即上述三个方法),得到他们的消息内容,然后解析即可。
下面的例子将用拦截 JS的输入框(即prompt()方法)说明 :
步骤1:加载JS代码,如下:
javascript.html
以.html格式放到src/main/assets文件夹里
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Carson_Ho</title>
<script>
function clickprompt(){
// 调用prompt()
var result=prompt("js://demo?arg1=111&arg2=222");
alert("demo " + result);
}
</script>
</head>
<!-- 点击按钮则调用clickprompt() -->
<body>
<button type="button" id="button1" onclick="clickprompt()">点击调用Android代码</button>
</body>
</html>
当使用mWebView.loadUrl("file:///android_asset/javascript.html")加载了上述JS代码后,就会触发回调onJsPrompt(),具体如下:
步骤2:在Android通过WebChromeClient复写onJsPrompt()
public class MainActivity extends AppCompatActivity {
WebView mWebView;
// Button button;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView=(WebView) findViewById(R.id.webview);
WebSettings webSettings=mWebView.getSettings();
// 设置与Js交互的权限
webSettings.setJavaScriptEnabled(true);
// 设置允许JS弹窗
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
// 先加载JS代码
// 格式规定为:file:///android_asset/文件名.html
mWebView.loadUrl("file:///android_asset/javascript.html");
mWebView.setWebChromeClient(new WebChromeClient() {
// 拦截输入框(原理同方式2)
// 参数message:代表promt()的内容(不是url)
// 参数result:代表输入框的返回值
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
// 根据协议的参数,判断是否是所需要的url(原理同方式2)
// 一般根据scheme(协议格式) & authority(协议名)判断(前两个参数)
//假定传入进来的 url="js://webview?arg1=111&arg2=222"(同时也是约定好的需要拦截的)
Uri uri=Uri.parse(message);
// 如果url的协议=预先约定的 js 协议
// 就解析往下解析参数
if ( uri.getScheme().equals("js")) {
// 如果 authority=预先约定协议里的 webview,即代表都符合约定的协议
// 所以拦截url,下面JS开始调用Android需要的方法
if (uri.getAuthority().equals("webview")) {
//
// 执行JS所需要调用的逻辑
System.out.println("js调用了Android的方法");
// 可以在协议上带有参数并传递到Android上
HashMap<String, String> params=new HashMap<>();
Set<String> collection=uri.getQueryParameterNames();
//参数result:代表消息框的返回值(输入值)
result.confirm("js调用了Android的方法成功啦");
}
return true;
}
return super.onJsPrompt(view, url, message, defaultValue, result);
}
// 通过alert()和confirm()拦截的原理相同,此处不作过多讲述
// 拦截JS的警告框
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
return super.onJsAlert(view, url, message, result);
}
// 拦截JS的确认框
@Override
public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
return super.onJsConfirm(view, url, message, result);
}
}
);
}
}
2.2.2 三种方式的对比 & 使用场景
很长的一段时间中,Vue 官方都以简单上手作为其推广的重点。这确实给 Vue 带来了非常大的用户量,尤其是最追求需求开发效率, 往往不那么在意工程代码质量的国内中小企业中,Vue 占据的份额极速增长。但是作为开发者自身,我们必须要认清一个重点,简单易用从来不应该在技术选型中占据很大的份额,可维护性才是。
以防万一有的同学实在不看官方文档,我先提一嘴,SFC 就是写 Vue 组件的时候写的.vue文件,这一个文件就是一个 SFC,全称 Single File Component,也即单文件组件。
在开始说我个人的观点之前,我们先来看几个事实:
一是:Vue3 的定义原生支持 JSX,并且 Vue3 源码中有jsx.d.ts来便于使用 JSX。 不知道同学们看到这里会想到什么, 我的第一反应是:社区对于 JSX 的需求声音是不小的,所以会反向推动 Vue3 官方对于 JSX 的支持。
二是:AntDesign 的 vue3 版本,基本全部都是用 JSX 开发的,而且 Vue3 现在官方的 babel-jsx 插件就是阿里的人一开始维护的, 虽然我向来不喜欢阿里系的 KPI 推动技术方式,而且现在的 JSX 语法支持也不是很符合我的期望,但至少在使用 JSX 开发是更优秀的选择这点上,我还是很认可 AntDesign 团队的。
OK,说这些呢,主要是先摆出一些事实作为依据,让有些同学可以不需要拿什么:
这些观点来批斗我,首先我都会从客观的角度来分析为什么,至少是我是能讲出优劣势的理由的。
OK,前言差不多到这里,接下来咱给您分析分析,为什么你应该选择 JSX 来开发 Vue。
其实第一点就已经是杀手了,对于想要使用 TypeScript 来开发 Vue3 应用的同学来说,这简直就是 SFC 无法克服的世界难题。
一句话概括:TypeScript 原生支持 JSX 语法,而基本无望 TS 官方能支持 SFC 的 template 语法。
TS 毫无疑问在前端社区的重要性越来越大,但凡未来对于代码质量有一定要求的前端团队,都应该会选择使用 TS 来进行开发。 而且现在基本上在 NPM 上都能看到包你都能找到对应的 TS 定义,现在使用 TS 开发成本已经只剩下你是不是会 TS 语法了,在这种情况下是否支持 TS 则是开发模式在未来走不走的远的重要原因。
目前 SFC 只能通过shim让 TS 可以引入.vue文件,但是对于所有 SFC 的组件的定义都是一样的:
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, {}, any>
export default component
}
也就是说你引入的 SFC 组件,TS 是不知道这个组件的 Props 应该接收什么的。所以你无法享受到这些 TS 的优势:
当然你会说既然 Vue 官方能开发处 SFC 的语法,自然会支持这些特性。我表示这当然有可能,但是这个难度是非常大的,需要很多方面的支持,甚至可能需要 TS 官方团队愿意协助, 但是我想不到 TS 官方有什么理由来支持 SFC,因为这只是 Vue 自己创建的方言,在其他场景下是没有使用的,TS 是面向全社区的,我觉得他们不会考虑主动来支持 SFC。
那么有同学要问了,JSX 不也是非原生的 JS 语法么,他怎么就能让 TS 官方支持了呢,是不是 FB 和微硬之间有什么 PY 交易?
这就涉及第二点了,JSX 和静态模板的灵活性区别。
很多人弄错了一个问题,就是觉得 SFC 的模板语法和 JSX 是一样的,都是一种别人发明的语法,并不是 JS 原生的。这是事实,但又有一些区别,这个区别主要是体现在对于 JSX 的认知上。
一句话概括:JSX 并没有扩展 JS 的语法,他只是缩略了 JS 的写法!其本质就是 JS 的语法糖
就像 es6 给增加的语法糖,比如
const a=1
const b=2
const obj={ a, b }
// 其实就等价于
const obj={ a: a, b: b }
这种写法并没有扩展 JS 的能力,只是简便了写法,JSX 也是一样的。
JSX 其实就是方法调用,他和 JS 是有一对一对应关系的,我们来看一个例子:
const element=<div id="root">Hello World</div>
这里的 JSX 语法编译之后其实就是:
const element=createElement('div', { id: 'root' }, 'Hello World')
而 JSX 就是这些了,没有什么更多的内容,所以说 JSX 只是方便我们写嵌套的函数调用的语法糖,而其本身没有扩展任何其他的内容。
但是 SFC 就不一样了。
SFC 定义的不仅是语法,更是文件。
SFC 的具体定义是单文件组件,它本身就是把一个文件看作一个单位,所以他的约束性是要大很多的,你必须具有固定的文件结构才能使用 SFC,这做了很多的限制:
我们一点点来讲
这个说实话非常非常不方便,很多时候我们写一个页面的时候其实经常会需要把一些小的节点片段拆分到小组件里面进行复用(如果你现在没有这个习惯可能就是因为 SFC 的限制让你习惯了全部写在一个文件内)。
React 生态中丰富的 css-in-js 方案就是很好的例子,我们可以通过:
const StyledButton=styled('button', {
color: 'red',
})
如果我们这个页面需要使用特定样式的按钮,通过这种方式在页面文件里面封装一下是非常常见的。因为没必要把这个组件拆分出去,他也不是一个可复用的组件,拆分出去了还要多一次import。
Vue 生态基本没有 css-in-js 的成熟方案其实跟这个限制也很有关系。
再来一个例子,比如我们封装了一个 Input 组件,我们希望同时导出 Password 组件和 Textarea 组件来方便用户根据实际需求使用,而这两个组件本身内部就是用的 Input 组件,只是定制了一些 props:
const Input={ ... }
export default Input
export const Textarea=(props)=> <Input multiline={true} {...props} />
export const Password=(props)=> <Input type="password" {...props} />
在 JSX 中可以非常简单地实现,但是如果通过 SFC,你可能就要强行拆成三个文件,另外为了方便,你可能还要增加一个index.js来导出这三个组件,你能想象这多了多少工作量么。
我不知道有多少同学看过 Vue 的 template 编译出来之后的代码,以我的经验来说看过的可能不会超过 50%(乐观估计),建议同学们如果还不了解的,可以去尝试看一下。
为什么要看这个呢?因为你看了之后你会发现,你在 template 里面写的类似 HTMl 的内容,其实跟 HTML 根本没啥关系,他们也会被编译成类似 JSX 编译出来的结果。
{
render(h) {
return h('div', {on: {}, props: {}}, h('span'))
}
}
类似这样的结果,而这里面h函数调用的结果就是一个 VNode,是 Vue 中的节点的基础单元。那么既然这些单元就是一个对象,其实理所当然的,他们是可以作为参数传递的。 也就是说,理论上他们是可以通过props把节点当作参数传递给其他组件的。
这个做法在 React 中非常常见,叫做renderProps,并且其非常灵活:
const Comp=()=> <Layout header={<MyHeader />} footer={<MyFooter />} />
但是因为 SFC 模板的限制,我们很难在 SFC 里面的 props 上写节点:
<template>
<Layout :header="<MyHeader/>"></Layout>
</template>
这样写是不行的,因为 SFC 定义了:header绑定接受的只能是 js 表达式,而<MyHeader/>显然不是。
因为通过 props 传递不行,所以 Vue 才发明了 slot 插槽的概念
虽然我们一直再说 Vue 简单,但是事实上ScopedSlots一度成为新手理解 Vue 的噩梦,很多同学都被这个绕来绕去的作用域整的死去活来。
我们看一个ScopedSlots的例子:
<template>
<Comp>
<template v-slot:scope="ctx">
<div>{{ctx.name}}</div>
</template>
</Comp>
</template>
这里ctx是Comp里面的属性,通过这种方式传递出来,让我们在当前组件可以调用父组件里面的属性。这简直就是理解的噩梦,但是如果用 JSX 实现类似功能就非常简单:
<Comp scope={name=> <div>{name}</div>} />
我们只是给一个叫做scope的 props 传递来一个函数,这个函数接受一个name属性,在Comp里面会调用这个函数并传入name。 简单来说我们传入的就是一个构建节点片段的函数,就是这么简单。
这就是因为 SFC 的模板的限制,导致灵活性不足,Vue 需要去创造概念,创造关键字来抹平这些能力的不足,而创造的概念自然就引入了学习成本。
所以其实我一直不认可 Vue 比 React 好学的说法的,如果你真的认真研究所有用法,并且总是尝试用最合理的方式实现功能,那么 Vue 绝对不会比 React 简单。
这个体现在两个方面,一个是我们定义在全局的一些固定数据如果要在组件内使用的话,就要通过this挂载到组件上。
比如我们缓存了一份城市数据,这种数据基本上是不会改的,所以也没必要挂载到组件上让其能够响应式。但是在 SFC 里面这是做不到的, 因为模板的执行上下文是在编译时绑定。你在模板里面访问的变量,都会在编译时自动绑定到this上,因为模板需要编译,其本身也是字符串不具有作用域的概念。
而这在 JSX 中则不复存在:
const citys=[]
const Comp=()=> {
return citys.map(c=> <div>{c}</div>)
}
另外一个方面则是在组件使用上,在 SFC 中,组件必须事先注册,因为我们在模板里面写的只能是字符串而不能是具体某个组件变量。 那么模板中的组件和真实的组件对象只能通过字符串匹配来实现绑定。这带来了以下问题:
在 JSX 中则没有这些问题,因为 JSX 里面直接使用组件引用作为参数:
const Comp={...}
const App=()=> <Comp />
其实上面能看出来,除了 SFC 本身的问题之外,Vue 使用字符串模板也会带来很多的灵活性问题。 最直接的证据,就是 Vue 使用了directive来扩展功能(当然这不是 Vue 发明的,老早的模板引擎就有类似问题)。
为什么说directive是不得已的选择呢?因为静态模板缺失逻辑处理的能力。我们拿列表循环举例,在 JS 中我们可以非常方便地通过map函数来创建列表:
const list=arr.map(name=> <span key={name}>{name}</span>)
而因为 JSX 本身就是函数调用,所以上面的代码和 JSX 结合起来也非常自然:
const App=()=> (
<div>
<Header />
{arr.map(name=> (
<span key={name}>{name}</span>
))}
</div>
)
上面的例子对应到 JS 如下:
const App=()=>
createElement('div', {}, [
<Header />,
arr.map(name=> createElement('span', { key: name }, name)),
])
这仍然是因为 JSX 只是 JS 的语法糖的原因,所有能在 JS 中实现的在 JSX 里面都能实现。
而 SFC 的模板是基于字符串编译的,其本身就是一段字符串,我们不能直接在模板里面写map来循环节点,(当然我们可以在可以接收表达式的地方写,比如v-on里面)。
那么我们不能循环节点,有需要这样的功能来渲染列表,怎么办呢?就是发明一个标志来告诉编译器这里需要循环,在 Vue 中的体现就是v-for指令。
同学们可能要问了,既然 Vue 能实现v-for,为什么不直接实现表达式循环列表呢?他当然也可以实现,但是他肯定不会这么选,因为成本太高了。 他要这么做就相当于他要实现一个 JS 引擎,而其实里面很多内容又是不必须的,一个v-for其实就能够适用大部分情况了。
但有了v-for就需要v-if,那么后面还会需要其他各种能力,这就是一种方言的产生和发展的过程。
当然指令也不仅仅是 JS 表达式的代替品,其本身也是增加了一些其他能力的,比如它能够让我们更方便地访问 DOM 节点, 但是嘛,我们用框架的理由不就是为了能够尽可能的屏蔽 DOM 操作嘛~
以上就是我对应该选择使用 JSX 还是 SFC 进行开发的分析,其实归根到底 SFC 的问题在于其没有拥抱 JS, 他的语法是自己发明的,他需要有一个 JS 实现的 compiler 来让其最终能在 JS 环境中运行,这本质上就是一种发明, 我们不能否认发明确实有优点,但我们也不能只看有点不看问题,没能拥抱 JS 自然就很难完全复用 JS 社区的优势 而 JS 社区一直在蓬勃发展,好用的工具一直在涌现,而 SFC 想要使用 JS 社区的这些工具还要自己再实现一份,我们可以细数以下 SFC 做了哪些兼容
基本上常用的工具我们都需要等待 Vue 社区或者官方开发了插件之后才能运行。而 JSX 因为有 babel 和 typescript 的官方支持, 基本上所有新的 JS 生态工具原生都是支持的。
在这 Vue3 开始预备发力的阶段,我们还是希望 Vue 社区能够使用更优秀更规范的方式来进行开发, 其实如果我们直接使用 JSX 开发 Vue3,我们会发现很多时候我们都不需要用到emit、attrs这些概念, 甚至如果 Vue3 的 JSX 插件支持,我们甚至能够抛弃slots。
但是因为 Vue3 一定要考虑兼容 Vue2,导致本身潜力很好的 Vue3 总是显得缩手缩脚,这不得不说是一种遗憾。
*请认真填写需求信息,我们会在24小时内与您取得联系。