citrine 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +249 -0
- data/bin/citrine +39 -0
- data/desktop/main.swift +44 -0
- data/lib/citrine/browser.rb +11 -0
- data/lib/citrine/canvas.rb +503 -0
- data/lib/citrine/component.rb +692 -0
- data/lib/citrine/debug.rb +109 -0
- data/lib/citrine/dev_server.rb +305 -0
- data/lib/citrine/dom.rb +292 -0
- data/lib/citrine/event.rb +31 -0
- data/lib/citrine/key_event.rb +39 -0
- data/lib/citrine/list_signal.rb +354 -0
- data/lib/citrine/node.rb +36 -0
- data/lib/citrine/num.rb +75 -0
- data/lib/citrine/packager.rb +129 -0
- data/lib/citrine/reactive.rb +40 -0
- data/lib/citrine/renderer.rb +828 -0
- data/lib/citrine/signal.rb +236 -0
- data/lib/citrine/sourcemap.rb +143 -0
- data/lib/citrine/string_renderer.rb +116 -0
- data/lib/citrine/style.rb +79 -0
- data/lib/citrine/theme.rb +99 -0
- data/lib/citrine/version.rb +5 -0
- data/lib/citrine.rb +92 -0
- data/lib/rubocop/cop/citrine/no_raw_ivar_assignment.rb +75 -0
- metadata +157 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "signal"
|
|
4
|
+
require_relative "node"
|
|
5
|
+
require_relative "style"
|
|
6
|
+
|
|
7
|
+
module Citrine
|
|
8
|
+
# 组件基类:prop / state / computed 三宏(GOALS.md 第七节定案 API)。
|
|
9
|
+
#
|
|
10
|
+
# class Counter < Citrine::Component
|
|
11
|
+
# prop :title, type: String, default: "Counter"
|
|
12
|
+
# state :count, default: 0
|
|
13
|
+
# computed :double { count * 2 }
|
|
14
|
+
#
|
|
15
|
+
# def view
|
|
16
|
+
# box(direction: :column) do
|
|
17
|
+
# label { "count = #{count} (x2 = #{double})" }
|
|
18
|
+
# button(on_click: :increment) { "加一" }
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# def increment
|
|
23
|
+
# self.count += 1 # 赋值即更新
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
class Component
|
|
27
|
+
# 常用 HTML 元素词表(S2-1):方法名即元素类型;属性经属性透传(S2-2)
|
|
28
|
+
# 直达 DOM / SSR(a(href:) / img(src:, alt:) / form(action:) …)。
|
|
29
|
+
# 方法名与标签名一致,渲染层既有 TAGS[type] || type.to_s 兜底直接生效。
|
|
30
|
+
ELEMENT_TAGS = %i[
|
|
31
|
+
a span img ul ol li table thead tbody tr th td
|
|
32
|
+
form select option textarea video audio
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# 元素 DSL 方法名:prop 不能与它们重名(否则读 prop 会覆盖元素方法)
|
|
36
|
+
DSL_METHODS = (%i[box stack row label button text_input check_box
|
|
37
|
+
render children element portal suspense] + ELEMENT_TAGS).freeze
|
|
38
|
+
|
|
39
|
+
# window_key 的作用域包装(S2-3):scope: :focused 表示"焦点在本组件
|
|
40
|
+
# 子树内才响应"。用 Struct 而不是 Hash/Array 包裹,避免与 handle_key
|
|
41
|
+
# 的 Hash 键表形式冲突。
|
|
42
|
+
WindowKey = Struct.new(:handler, :scope)
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
def prop_defs
|
|
46
|
+
@prop_defs ||= superclass.respond_to?(:prop_defs) ? superclass.prop_defs.dup : {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def state_defs
|
|
50
|
+
@state_defs ||= superclass.respond_to?(:state_defs) ? superclass.state_defs.dup : {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def compute_defs
|
|
54
|
+
@compute_defs ||= superclass.respond_to?(:compute_defs) ? superclass.compute_defs.dup : {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 只读输入,来自父组件;未声明的 prop 视为错误
|
|
58
|
+
def prop(name, type: nil, default: nil)
|
|
59
|
+
ensure_no_dsl_conflict(:prop, name, hint: "请改名,或显式读 props[:#{name}]")
|
|
60
|
+
|
|
61
|
+
prop_defs[name] = { type: type, default: default }
|
|
62
|
+
define_method(name) { read_prop(name) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 可变状态:读写受追踪,写入触发订阅该状态的 block 重跑
|
|
66
|
+
def state(name, default: nil, &init)
|
|
67
|
+
ensure_no_dsl_conflict(:state, name, hint: "请改名,或显式经 signal(:#{name}) 读写")
|
|
68
|
+
|
|
69
|
+
state_defs[name] = [default, init]
|
|
70
|
+
define_method(name) { signal(name).get }
|
|
71
|
+
define_method("#{name}=") { |value| signal(name).set(value) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# 派生值:依赖自动收集,上游变化自动失效重算
|
|
75
|
+
def computed(name, &block)
|
|
76
|
+
ensure_no_dsl_conflict(:computed, name)
|
|
77
|
+
|
|
78
|
+
compute_defs[name] = block
|
|
79
|
+
define_method(name) { computation(name) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── 子组件关键字(P0-1,写法 A:小写关键字)────────────────
|
|
83
|
+
#
|
|
84
|
+
# class WatchlistPanel < Citrine::Component
|
|
85
|
+
# components WatchRow # → view 里可用 watch_row(…)
|
|
86
|
+
# components PositionRow => :pos_row # → 显式改名(与本类已有方法重名时)
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
# 关键字是与元素 DSL(box / label / stack / …)同构的小写方法,底层就是 render。
|
|
90
|
+
def components(*specs)
|
|
91
|
+
specs.each do |spec|
|
|
92
|
+
klass, keyword = spec.is_a?(Hash) ? spec.first : [spec, nil]
|
|
93
|
+
name = (keyword || default_keyword(klass)).to_sym
|
|
94
|
+
if method_defined?(name) || private_method_defined?(name)
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"components #{klass}: 关键字 #{name} 与本类已有方法重名,请显式改名:" \
|
|
97
|
+
"components #{klass.name} => :其他名字"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
component_keywords[name] = klass
|
|
101
|
+
define_method(name) do |**props, &block|
|
|
102
|
+
render(klass, **props, &block)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def component_keywords
|
|
108
|
+
@component_keywords ||=
|
|
109
|
+
superclass.respond_to?(:component_keywords) ? superclass.component_keywords.dup : {}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# PositionRow → position_row;Panels::WatchRow → watch_row
|
|
113
|
+
def default_keyword(klass)
|
|
114
|
+
name = klass.name.to_s
|
|
115
|
+
if name.empty?
|
|
116
|
+
raise ArgumentError,
|
|
117
|
+
"components 需要具名组件类;匿名类请显式给关键字(components Foo => :foo_keyword)"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
name.split("::").last
|
|
121
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
122
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
123
|
+
.downcase
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ── 生命周期与全局键盘(G-9 / G-10)─────────────────────
|
|
127
|
+
# 三者都是"挂载期间生效"的声明,随组件卸载一起失效;子类继承父类的声明。
|
|
128
|
+
|
|
129
|
+
def mount_hooks
|
|
130
|
+
@mount_hooks ||= superclass.respond_to?(:mount_hooks) ? superclass.mount_hooks.dup : []
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unmount_hooks
|
|
134
|
+
@unmount_hooks ||= superclass.respond_to?(:unmount_hooks) ? superclass.unmount_hooks.dup : []
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# window 级 keydown 处理器(键盘优先应用的导航/快捷键):挂载时注册、卸载时移除
|
|
138
|
+
def window_key_handlers
|
|
139
|
+
@window_key_handlers ||= superclass.respond_to?(:window_key_handlers) ? superclass.window_key_handlers.dup : []
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# 挂载完成后执行(DOM 已就位):适合 focus、定时器、第三方库初始化
|
|
143
|
+
#
|
|
144
|
+
# 一次可声明多个:`on_mount :a, :b` / `on_mount :a { ... }` / 只给块。
|
|
145
|
+
# 之所以用可变参数而不是单参数:**Opal 下给固定 arity 的方法多传实参不会报错**,
|
|
146
|
+
# 只是静默丢弃——CRuby 抛 ArgumentError、Opal 少跑一个钩子,是最难查的一类
|
|
147
|
+
# 平台间语义分叉(dogfooding 实测:网格 ticker 因此消失,症状是"闪烁永不清零")。
|
|
148
|
+
def on_mount(*handlers, &block)
|
|
149
|
+
mount_hooks.concat(collect_hooks(:on_mount, handlers, block))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# 组件销毁时执行:清理定时器、监听器、未完成的请求(同样可一次声明多个)
|
|
153
|
+
def on_unmount(*handlers, &block)
|
|
154
|
+
unmount_hooks.concat(collect_hooks(:on_unmount, handlers, block))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# 声明式 Effect(订阅):块 / 方法体挂载后跑一次,之后**它读到的信号**一变就重跑;
|
|
158
|
+
# 卸载时自动 dispose——不必再手写 `on_mount :setup` + `on_unmount :teardown` 这对样板。
|
|
159
|
+
#
|
|
160
|
+
# class Grid < Citrine::Component
|
|
161
|
+
# watch :refresh_selection # 方法体在组件实例上执行
|
|
162
|
+
# watch { sync_title(selected) } # 也可以直接给块
|
|
163
|
+
# end
|
|
164
|
+
#
|
|
165
|
+
# 要点:
|
|
166
|
+
# · 跑在**自己的 Effect** 里——块内读到的信号才是依赖,别在块外先读好再传进来;
|
|
167
|
+
# · 在 mount 钩子之后创建(要读 DOM 的输出放 on_mount);SSR 不建 Effect,故不创建;
|
|
168
|
+
# · 与 computed 的分工:computed 产出值,watch 做副作用(同步 DOM/存储、记日志…)。
|
|
169
|
+
def watch(*handlers, &block)
|
|
170
|
+
watch_defs.concat(collect_hooks(:watch, handlers, block))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 声明过的 watcher 体(子类继承父类的,按声明顺序)
|
|
174
|
+
def watch_defs
|
|
175
|
+
@watch_defs ||= superclass.respond_to?(:watch_defs) ? superclass.watch_defs.dup : []
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# 声明式副作用(S1-8):与 watch 同构的类宏,可在组件内多处声明;
|
|
179
|
+
# 块返回 Proc 即 cleanup——每次重跑前与组件卸载时各执行一次,
|
|
180
|
+
# Effect 内申请的资源(定时器 / 原生监听 / 订阅)有了"随重跑清理"的位置。
|
|
181
|
+
#
|
|
182
|
+
# class Ticker < Citrine::Component
|
|
183
|
+
# effect {
|
|
184
|
+
# timer = set_interval(1000) { self.tick += 1 }
|
|
185
|
+
# -> { clear_interval(timer) }
|
|
186
|
+
# }
|
|
187
|
+
# end
|
|
188
|
+
def effect(*handlers, &block)
|
|
189
|
+
effect_defs.concat(collect_hooks(:effect, handlers, block))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# 声明过的 effect 体(子类继承父类的,按声明顺序)
|
|
193
|
+
def effect_defs
|
|
194
|
+
@effect_defs ||= superclass.respond_to?(:effect_defs) ? superclass.effect_defs.dup : []
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# ── Context 依赖注入(S1-3)────────────────────────────
|
|
198
|
+
# 声明本组件向后代提供名为 name 的 context(可给默认值),并定义读写器:
|
|
199
|
+
# context :theme, default: { mode: "light" }
|
|
200
|
+
# def view
|
|
201
|
+
# self.theme = { mode: "dark" } # 提供值(深相等不变时不通知)
|
|
202
|
+
# ... # 后代经 use_context(:theme) 读取
|
|
203
|
+
# end
|
|
204
|
+
def context(name, default: nil)
|
|
205
|
+
ensure_no_dsl_conflict(:context, name)
|
|
206
|
+
|
|
207
|
+
context_defs[name] = default
|
|
208
|
+
define_method("#{name}=") { |value| context_signal(name).set(value) }
|
|
209
|
+
define_method(name) { context_signal(name).get }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def context_defs
|
|
213
|
+
@context_defs ||= superclass.respond_to?(:context_defs) ? superclass.context_defs.dup : {}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# ── 错误边界(S1-6)────────────────────────────────────
|
|
217
|
+
# 声明本组件的渲染兜底:块 / 子组件 view 在本组件的块里抛错时,
|
|
218
|
+
# 以异常对象为实参调用兜底,其输出替换该块本轮的内容;
|
|
219
|
+
# 失败那一轮不留半更新,未声明兜底的组件异常照常穿出。
|
|
220
|
+
#
|
|
221
|
+
# class Panel < Citrine::Component
|
|
222
|
+
# error_fallback :render_error
|
|
223
|
+
# def render_error(err) = label(css_class: "error") { err.message }
|
|
224
|
+
# end
|
|
225
|
+
def error_fallback(handler = nil, &block)
|
|
226
|
+
handler = block if handler.nil?
|
|
227
|
+
raise ArgumentError, "error_fallback 需要方法名或块" unless handler.is_a?(Symbol) || handler.is_a?(Proc)
|
|
228
|
+
|
|
229
|
+
@error_fallback = handler
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def error_fallback_def
|
|
233
|
+
return @error_fallback if defined?(@error_fallback) && @error_fallback
|
|
234
|
+
|
|
235
|
+
superclass.respond_to?(:error_fallback_def) ? superclass.error_fallback_def : nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# 声明一个 window 级键盘处理器(Symbol 或 Proc);卸载时自动解绑。
|
|
239
|
+
# scope: :focused(S2-3)=焦点落在组件渲染的子树内才分发——同页多个
|
|
240
|
+
# 组件都声明 window_key 时不再一起响应。
|
|
241
|
+
#
|
|
242
|
+
# class Editor < Citrine::Component
|
|
243
|
+
# window_key :global_key
|
|
244
|
+
# def global_key(ev) = move(1) if ev.key == "ArrowDown"
|
|
245
|
+
# end
|
|
246
|
+
#
|
|
247
|
+
# 带作用域时登记成 WindowKey 包装(避免与 handle_key 的 Hash 键表形式冲突)
|
|
248
|
+
def window_key(handler, scope: nil)
|
|
249
|
+
window_key_handlers << (scope ? WindowKey.new(handler, scope) : handler)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
# prop / state / computed / context 宏共用:这些宏都会定义同名读访问器,
|
|
255
|
+
# 与元素 DSL 方法(label { … } / box { … })同名会把它静默覆盖掉——
|
|
256
|
+
# 声明期就报错,而不是渲染期才发现元素丢了。
|
|
257
|
+
def ensure_no_dsl_conflict(kind, name, hint: "请改名")
|
|
258
|
+
return unless DSL_METHODS.include?(name)
|
|
259
|
+
|
|
260
|
+
raise ArgumentError,
|
|
261
|
+
"#{kind} :#{name} 与元素 DSL 方法同名:读它会把 #{name} { … } 覆盖掉。#{hint}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# on_mount / on_unmount 的参数归一:Symbol / Proc / 块;至少给一个,否则 fail fast
|
|
265
|
+
def collect_hooks(name, handlers, block)
|
|
266
|
+
hooks = block ? handlers + [block] : handlers
|
|
267
|
+
raise ArgumentError, "#{name} 需要至少一个处理器(Symbol / Proc / 块)" if hooks.empty?
|
|
268
|
+
|
|
269
|
+
hooks.each do |hook|
|
|
270
|
+
next if hook.is_a?(Symbol) || hook.is_a?(Proc)
|
|
271
|
+
|
|
272
|
+
raise ArgumentError, "#{name} 的处理器只能是 Symbol 或 Proc,收到 #{hook.inspect}"
|
|
273
|
+
end
|
|
274
|
+
hooks
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
attr_reader :props
|
|
279
|
+
|
|
280
|
+
# 组件挂载的根节点与所属渲染器(挂载时由渲染器写入;卸载时用)
|
|
281
|
+
attr_accessor :root, :renderer
|
|
282
|
+
|
|
283
|
+
# 子组件 view 的 Effect(S1-2):渲染器在子组件首次挂载时创建——view 体读到的
|
|
284
|
+
# prop/state 订阅落在这里,之后 prop 重传或自身 state 变化只重跑这个 Effect
|
|
285
|
+
# 原地调和,不再借道父块。随组件卸载一起释放。
|
|
286
|
+
attr_accessor :view_effect
|
|
287
|
+
|
|
288
|
+
# ref: :name 的元素句柄(DOM 下是元素本身);挂载时登记,卸载时清空
|
|
289
|
+
def refs
|
|
290
|
+
@refs ||= {}
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def initialize(props = {})
|
|
294
|
+
defs = self.class.prop_defs
|
|
295
|
+
unexpected = props.keys - defs.keys
|
|
296
|
+
raise ArgumentError, "未声明的 prop: #{unexpected.join(', ')}" unless unexpected.empty?
|
|
297
|
+
|
|
298
|
+
@props = {}
|
|
299
|
+
defs.each do |name, definition|
|
|
300
|
+
value = props.key?(name) ? props[name] : definition[:default]
|
|
301
|
+
validate_prop_type!(name, definition, value)
|
|
302
|
+
|
|
303
|
+
@props[name] = value
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def signals
|
|
308
|
+
@signals ||= {}
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# ── Context 依赖注入(S1-3)────────────────────────────────
|
|
312
|
+
# provider 侧:context 信号按 name 懒创建(初值来自 context 声明的默认值)
|
|
313
|
+
def context_signal(name)
|
|
314
|
+
raise ArgumentError, "未声明的 context: #{name}" unless self.class.context_defs.key?(name)
|
|
315
|
+
|
|
316
|
+
(@context_signals ||= {})[name] ||= Signal.new(self.class.context_defs[name])
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def provides_context?(name)
|
|
320
|
+
self.class.context_defs.key?(name)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# consumer 侧:use_context(:name)。首次读取在渲染遍历栈上向上解析**最近的**
|
|
324
|
+
# 提供者组件(跳过读者自己),绑定到它的 context 信号——之后的重跑(哪怕
|
|
325
|
+
# 发生在 provider 不在渲染的时机)都走缓存绑定,信号变化只重跑读它的块。
|
|
326
|
+
# 祖先链上找不到提供者时显式报错,不静默 nil。
|
|
327
|
+
#
|
|
328
|
+
# 已知约束(P3):绑定随组件实例缓存、永不重解析。keyed 复用/移动把组件挂到
|
|
329
|
+
# 另一个提供者下时,读到的仍是首次解析到的提供者。需要跟随新提供者时请换
|
|
330
|
+
# key 重建组件(或在本组件内绕开缓存直接调 resolve_context)。
|
|
331
|
+
def use_context(name)
|
|
332
|
+
(@context_bindings ||= {})[name] ||= resolve_context(name)
|
|
333
|
+
@context_bindings[name].get
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def resolve_context(name)
|
|
337
|
+
provider = Citrine.renderer&.find_context_provider(name, self)
|
|
338
|
+
unless provider
|
|
339
|
+
raise ArgumentError,
|
|
340
|
+
"use_context(:#{name}):祖先链上没有组件提供该 context(先在祖先里 context :#{name})"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
provider.context_signal(name)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# 按 key 取用的信号表(组件内):同一个 (name, key) 只会建一个信号,
|
|
347
|
+
# 用于"每行 / 每格 / 每个标的都有自己的信号"这类场景,替代到处手写
|
|
348
|
+
# `@xxx[key] ||= Citrine::Signal.new(...)`:
|
|
349
|
+
#
|
|
350
|
+
# def view_signal(row, col) = keyed_signal(:view, [row, col]) { { selected: false } }
|
|
351
|
+
#
|
|
352
|
+
# 初值函数在**本组件实例**上求值(可以读 state / 调用自己的方法);给块则在该 key
|
|
353
|
+
# 第一次被取用时求值一次。表按 name 分开,互不干扰。
|
|
354
|
+
def keyed_signal(name, key, &init)
|
|
355
|
+
table = (keyed_signals[name] ||= {})
|
|
356
|
+
table[key] ||= Signal.new(init ? instance_eval(&init) : nil)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def keyed_signals
|
|
360
|
+
@keyed_signals ||= {}
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# 父组件重传 props(嵌套复用时的就地更新,P0-1/S1):
|
|
364
|
+
# 校验口径与 initialize 完全一致(未声明 prop / 类型不符都当场报错)。
|
|
365
|
+
# 子组件侧的读法不变(prop :x 仍是只读);写入走 prop 信号(S1-2):
|
|
366
|
+
# 值变化只通知真正读过该 prop 的块,子组件实例与 state 原地保留。
|
|
367
|
+
def update_props(new_props)
|
|
368
|
+
defs = self.class.prop_defs
|
|
369
|
+
unexpected = new_props.keys - defs.keys
|
|
370
|
+
raise ArgumentError, "未声明的 prop: #{unexpected.join(', ')}" unless unexpected.empty?
|
|
371
|
+
|
|
372
|
+
new_props.each do |name, value|
|
|
373
|
+
definition = defs[name]
|
|
374
|
+
validate_prop_type!(name, definition, value)
|
|
375
|
+
|
|
376
|
+
signal = (prop_signals[name] ||= Signal.new(@props[name]))
|
|
377
|
+
@props[name] = value # props 读法保持明值(introspection / 非响应式读取不建订阅)
|
|
378
|
+
if value.is_a?(Proc) && signal.peek.is_a?(Proc)
|
|
379
|
+
signal.replace(value) # 回调每次渲染都是新对象:只换引用,不算变更
|
|
380
|
+
else
|
|
381
|
+
signal.set(value)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
self
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# ── S1-2:prop 的响应式通道 ────────────────────────────────
|
|
388
|
+
# 声明过的 prop 第一次被读取(或被父重传)时升级为信号——此后在 Effect 内
|
|
389
|
+
# 读取即订阅,父重传新值只有真正读它的块重跑;没读过的 prop 只是明值。
|
|
390
|
+
def prop_signals
|
|
391
|
+
@prop_signals ||= {}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def read_prop(name)
|
|
395
|
+
(prop_signals[name] ||= Signal.new(@props[name])).get
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def computations
|
|
399
|
+
@computations ||= {}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# computed 的值 Signal 与它的 Effect 一一对应;Effect 必须留引用,卸载时才 dispose 得掉
|
|
403
|
+
def computation_effects
|
|
404
|
+
@computation_effects ||= {}
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# 取底层 Signal(双向绑定等需要信号对象本身的场景)
|
|
408
|
+
def signal(name)
|
|
409
|
+
default, init = self.class.state_defs.fetch(name) do
|
|
410
|
+
raise ArgumentError, "未声明的 state: #{name}"
|
|
411
|
+
end
|
|
412
|
+
signals[name] ||= Signal.new(init ? instance_eval(&init) : default)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# ── 元素 DSL:在 view / block 中调用,由当前渲染器挂载 ──────────
|
|
416
|
+
|
|
417
|
+
def box(**props, &block)
|
|
418
|
+
emit(:box, props, &block)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# 布局语法糖(G-8):方向必须显式——避免"忘了写 direction 的 box"在真机上塌掉
|
|
422
|
+
# (box 的默认方向仍是 CSS 的 row;未声明方向的 box 会在开发模式下被提醒)
|
|
423
|
+
def stack(**props, &block)
|
|
424
|
+
emit_directional(:column, props, &block)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def row(**props, &block)
|
|
428
|
+
emit_directional(:row, props, &block)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def label(**props, &block)
|
|
432
|
+
emit(:label, props, &block)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def button(on_click: nil, **props, &block)
|
|
436
|
+
props = props.merge(on_click: on_click) if on_click
|
|
437
|
+
emit(:button, props, &block)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# 受控文本输入:value 传 Signal(通常用 signal(:name) 获取)
|
|
441
|
+
def text_input(value: nil, placeholder: nil, on_enter: nil, **props)
|
|
442
|
+
emit(:text_input, props.merge(value: value, placeholder: placeholder, on_enter: on_enter).compact)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def check_box(checked: false, on_change: nil, **props)
|
|
446
|
+
emit(:check_box, props.merge(checked: checked, on_change: on_change).compact)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# ── S2-1:常用 HTML 元素词表 + 任意标签逃生舱 ──────────────
|
|
450
|
+
|
|
451
|
+
ELEMENT_TAGS.each do |name|
|
|
452
|
+
define_method(name) { |**props, &block| emit(name, props, &block) }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# 逃生舱:词表之外的任意标签名(含自定义元素)——DOM 与 SSR 两侧都落到
|
|
456
|
+
# TAGS[type] || type.to_s 的既有兜底,无需改框架源码
|
|
457
|
+
def element(type, **props, &block)
|
|
458
|
+
emit(type.to_sym, props, &block)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Portal(S1-5):把块内容挂到渲染器指定的宿主节点(DOM 下默认 body,
|
|
462
|
+
# 可传 target: 选择器字符串)——弹层/下拉由此逃出父容器的 overflow 与
|
|
463
|
+
# 层叠上下文,不再堆 z-index。复用、Effect、卸载级联与原地渲染一致。
|
|
464
|
+
def portal(target: nil, **props, &block)
|
|
465
|
+
props[:portal_target] = target if target
|
|
466
|
+
emit(:portal, props, &block)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Suspense(S1-10):渲染期等待——ready 为假时渲染 loading 占位,
|
|
470
|
+
# 就绪后原地切换到块内容(组件实例与 state 全程保留,切换不重建)。
|
|
471
|
+
# suspense(ready: -> { !user.nil? },
|
|
472
|
+
# loading: -> { label { "加载中…" } }) do
|
|
473
|
+
# label { "用户:#{user[:name]}" }
|
|
474
|
+
# end
|
|
475
|
+
def suspense(ready:, loading: nil, **props, &block)
|
|
476
|
+
props = props.merge(suspense_ready: ready, suspense_loading: loading)
|
|
477
|
+
emit(:suspense, props, &block)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def view
|
|
481
|
+
raise NotImplementedError, "#{self.class} 必须实现 #view"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# 渲染子组件(P0-1 的底层原语,写法 C):
|
|
485
|
+
# render(WatchRow, code: code, key: code) # 传类 + props(推荐)
|
|
486
|
+
# render(row_instance, key: code) # 传实例(props 由实例自己持有)
|
|
487
|
+
# 需要复用实例/state 时给 key:同一层(同一父节点下)key 相同的子组件会被保留。
|
|
488
|
+
# 带块调用即插槽(S1-4):块延迟到子组件 view 里调用 children 的位置才求值,
|
|
489
|
+
# 块内 self 是父组件(词法作用域),父 state 变化只重跑 children 所在的块。
|
|
490
|
+
def render(component, **props, &block)
|
|
491
|
+
unless component.is_a?(Class)
|
|
492
|
+
extra = props.keys - [:key, :ref]
|
|
493
|
+
raise ArgumentError, "render(实例) 不能再传 props(#{extra.join(', ')}):props 由实例自己持有" unless extra.empty?
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
Citrine.renderer.render_component(self, component, props, &block)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# ── 插槽(S1-4):render(Child) { … } 的内容落位 ───────────
|
|
500
|
+
# 子组件在 view 里调用 children,把父传入的块渲染到该位置。块没有响应式读取时
|
|
501
|
+
# 只渲染一次;读了父 state/信号则由自己的块 Effect 驱动原地更新。
|
|
502
|
+
def children
|
|
503
|
+
return nil unless children_presence.get # 订阅:块出现/消失时重跑读它的块
|
|
504
|
+
|
|
505
|
+
node = Node.new(:fragment, {}, children_block, owner: children_owner)
|
|
506
|
+
Citrine.renderer.mount(node)
|
|
507
|
+
node
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# children 块由渲染器在挂载/复用子组件时写入(父组件实例 + 块)
|
|
511
|
+
attr_accessor :children_block, :children_owner
|
|
512
|
+
|
|
513
|
+
def children_presence
|
|
514
|
+
@children_presence ||= Signal.new(!children_block.nil?)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# 渲染器复用路径调用:块出现/消失才翻转 presence(Proc 身份每次重传都不同,不作变更依据)
|
|
518
|
+
def set_children_block(block, owner)
|
|
519
|
+
@children_block = block
|
|
520
|
+
@children_owner = owner
|
|
521
|
+
children_presence.set(!block.nil?)
|
|
522
|
+
self
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# ── 内部 ────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
def handle_event(handler, event = nil)
|
|
528
|
+
# 一个事件 = 一个合并窗口(S1-1):handler 里的多次写入只重渲染一轮,
|
|
529
|
+
# 中间态不进 DOM;分发返回前 flush 完毕("点完即更新"的观感不变)
|
|
530
|
+
Scheduler.batch do
|
|
531
|
+
# 事件 Proc 是回调(区别于 REACTIVE_PROPS 的求值 Proc):必须保持闭包 self——
|
|
532
|
+
# 嵌套组件传入的父级回调,其方法接收者由定义处(父)的词法作用域决定,
|
|
533
|
+
# instance_exec 重绑到 emit 的 owner(子)会让父组件回调里的方法调用全部断裂。
|
|
534
|
+
# 因此走 dispatch_callable 的 bind: false(保持闭包 self)口径。
|
|
535
|
+
Citrine.dispatch_callable(handler, self, event)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# 键盘分发(G-9):Symbol/Proc 直接调用;Hash 形式按 ev.key 查表
|
|
540
|
+
#
|
|
541
|
+
# on_key: { "Enter" => :commit, "Escape" => :cancel, else: :fallback }
|
|
542
|
+
def handle_key(handler, event)
|
|
543
|
+
case handler
|
|
544
|
+
when Hash
|
|
545
|
+
target = handler[event.key] || handler[:else]
|
|
546
|
+
return if target.nil?
|
|
547
|
+
|
|
548
|
+
handle_event(target, event)
|
|
549
|
+
else
|
|
550
|
+
handle_event(handler, event)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# ── 生命周期钩子(由渲染器调用,G-10)───────────────────
|
|
555
|
+
|
|
556
|
+
def run_mount_hooks
|
|
557
|
+
self.class.mount_hooks.each { |hook| run_hook(hook) }
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def run_unmount_hooks
|
|
561
|
+
dispose_view_effect # 先停 view 重渲染:卸载后不该再被 prop/state 打回来
|
|
562
|
+
dispose_watch_effects # 先停订阅,再跑清理钩子(清理时不该再被信号打回来)
|
|
563
|
+
dispose_effects # effect 宏的 cleanup 在各自 dispose 内、订阅释放前执行(S1-8)
|
|
564
|
+
refs.clear
|
|
565
|
+
self.class.unmount_hooks.each { |hook| run_hook(hook) }
|
|
566
|
+
dispose_computed_effects # 放在钩子之后:清理时还能读到新鲜值,跑完才释放订阅
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def dispose_view_effect
|
|
570
|
+
@view_effect&.dispose
|
|
571
|
+
@view_effect = nil
|
|
572
|
+
self
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# 声明过的 watch 体各起一个 Effect:挂载后跑一次,之后依赖变化就重跑。
|
|
576
|
+
# 由**响应式**渲染器在挂载路径上调用(SSR 不建 Effect,故不调用)。
|
|
577
|
+
def run_watch_effects
|
|
578
|
+
@watch_effects ||= []
|
|
579
|
+
return self unless @watch_effects.empty? # 重复调用(复用路径)不重复创建
|
|
580
|
+
|
|
581
|
+
self.class.watch_defs.each do |body|
|
|
582
|
+
@watch_effects << Effect.create { run_hook(body) }
|
|
583
|
+
end
|
|
584
|
+
self
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def dispose_watch_effects
|
|
588
|
+
@watch_effects&.each(&:dispose)
|
|
589
|
+
@watch_effects = nil
|
|
590
|
+
self
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# 诊断/测试:当前存活的 watcher 数
|
|
594
|
+
def watch_effect_count = (@watch_effects || []).size
|
|
595
|
+
|
|
596
|
+
# effect 宏的运行/释放(S1-8):与 watch 同一套生命周期,但启用 cleanup——
|
|
597
|
+
# 块返回 Proc 时,重跑前与卸载时各执行一次
|
|
598
|
+
def run_effects
|
|
599
|
+
@effect_instances ||= []
|
|
600
|
+
return self unless @effect_instances.empty? # 重复调用(复用路径)不重复创建
|
|
601
|
+
|
|
602
|
+
self.class.effect_defs.each do |body|
|
|
603
|
+
@effect_instances << Effect.create(track_cleanup: true) { run_hook(body) }
|
|
604
|
+
end
|
|
605
|
+
self
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def dispose_effects
|
|
609
|
+
@effect_instances&.each(&:dispose)
|
|
610
|
+
@effect_instances = nil
|
|
611
|
+
self
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# 诊断/测试:当前存活的 effect 数
|
|
615
|
+
def effect_count = (@effect_instances || []).size
|
|
616
|
+
|
|
617
|
+
# computed 的 Effect 与 watch 同理:不释放就是"卸载后还跟着上游重算"的幽灵订阅,
|
|
618
|
+
# 而且会把整条组件对象图钉在信号的订阅表上(CRuby 侧实测:卸载后改 state 仍重算)。
|
|
619
|
+
# 清掉缓存使重新挂载时按需重建。
|
|
620
|
+
def dispose_computed_effects
|
|
621
|
+
computation_effects.each_value(&:dispose)
|
|
622
|
+
@computation_effects = nil
|
|
623
|
+
computations.clear
|
|
624
|
+
self
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# 诊断/测试:当前存活的 computed Effect 数
|
|
628
|
+
def computation_effect_count = (@computation_effects || {}).size
|
|
629
|
+
|
|
630
|
+
private
|
|
631
|
+
|
|
632
|
+
# prop 类型守卫(initialize 与 update_props 同一口径):声明 type 的 prop 是
|
|
633
|
+
# **可空**的——nil 与 type 实例都合法。default 缺省为 nil,因此
|
|
634
|
+
# `prop :foo, type: String` 不再需要显式 default: nil。
|
|
635
|
+
def validate_prop_type!(name, definition, value)
|
|
636
|
+
type = definition[:type]
|
|
637
|
+
return if type.nil? || value.nil? || value.is_a?(type)
|
|
638
|
+
|
|
639
|
+
raise TypeError, "prop #{name} 应为 #{type},实际为 #{value.class}"
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def computation(name)
|
|
643
|
+
block = self.class.compute_defs.fetch(name) do
|
|
644
|
+
raise ArgumentError, "未声明的 computed: #{name}"
|
|
645
|
+
end
|
|
646
|
+
unless computations.key?(name)
|
|
647
|
+
out = Signal.new(nil)
|
|
648
|
+
computation_effects[name] = Effect.create { out.set(instance_eval(&block)) }
|
|
649
|
+
computations[name] = out
|
|
650
|
+
end
|
|
651
|
+
computations[name].get
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def emit(type, props, &block)
|
|
655
|
+
# keyed 复用:命中旧节点就沿用(DOM / 子树 / Effect 全保留)
|
|
656
|
+
if (existing = Citrine.renderer.reusable_node(props[:key], [:element, type], props, self))
|
|
657
|
+
return Citrine.renderer.refresh_node(existing, props, block)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# 样式在 API 边界归一(决策 #10);Proc 样式是响应式属性,求值后归一(Renderer#resolve_style)
|
|
661
|
+
if props[:style] && !props[:style].is_a?(Proc)
|
|
662
|
+
props = props.merge(style: Style.normalize(props[:style]))
|
|
663
|
+
end
|
|
664
|
+
node = Node.new(type, props, block, owner: self)
|
|
665
|
+
Citrine.renderer.mount(node)
|
|
666
|
+
node
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# stack / row:方向由语法糖给定,再传 direction 属于自相矛盾,直接报错(fail fast)
|
|
670
|
+
def emit_directional(direction, props, &block)
|
|
671
|
+
if props.key?(:direction)
|
|
672
|
+
raise ArgumentError, "stack / row 已隐含方向(#{direction}),不要再传 direction;" \
|
|
673
|
+
"需要自定义方向请用 box(direction: ...)"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
emit(:box, props.merge(direction: direction), &block)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# 生命周期声明(G-10):块 或 方法名(Symbol),与事件处理器同一套约定
|
|
680
|
+
# 注意不走 Citrine.dispatch_callable:钩子是恒无参声明——Symbol 不看 arity、
|
|
681
|
+
# 一律不带实参调用(dispatch_callable 会给非零 arity 方法注入 nil,改变
|
|
682
|
+
# 带默认值/必填参方法的现状行为);Proc 一律重绑 owner 且不带实参
|
|
683
|
+
# (arity ≥ 1 的钩子在 instance_exec 下照旧 ArgumentError,而不是被喂 nil)。
|
|
684
|
+
def run_hook(hook)
|
|
685
|
+
case hook
|
|
686
|
+
when Symbol then send(hook)
|
|
687
|
+
when Proc then instance_exec(&hook)
|
|
688
|
+
else raise ArgumentError, "无法执行的生命周期钩子: #{hook.inspect}"
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
end
|