diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a71ab27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +node_modules +.DS_Store +dist +dist-ssr +.zip + +# local env files +# .env.local +# .env.*.local +*.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +yarn.lock + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..9ba90d5 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/preset-typescript', + ], +}; \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..91a2d2c --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..edc495f --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "guide-mini-vue", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "test": "jest" + }, + "devDependencies": { + "@babel/core": "^7.18.0", + "@babel/preset-env": "^7.18.0", + "@babel/preset-typescript": "^7.17.12", + "@types/jest": "^27.5.1", + "babel-jest": "^28.1.0", + "jest": "^28.1.0", + "ts-jest": "^28.0.3", + "typescript": "^4.7.2" + } +} diff --git a/src/reactivity/baseHandles.ts b/src/reactivity/baseHandles.ts new file mode 100644 index 0000000..12dd7e7 --- /dev/null +++ b/src/reactivity/baseHandles.ts @@ -0,0 +1,63 @@ +import { extend, isObject } from "../shared" +import { track, trigger } from "./effect" +import { reactive, ReactiveFlags, readonly } from "./reactive" + +const get = createGetter() +const set = createSetter() +const readonlyGet = createGetter(true) +const shallowReadonlyGet = createGetter(true, true) + +function createGetter(isReadonly = false, isShallow = false) { + return function get(target, key) { + if (key === ReactiveFlags.IS_REACTIVE) { + return !isReadonly + } else if (key === ReactiveFlags.IS_READONLY) { + return isReadonly + } + + const res = Reflect.get(target, key) + + if (isShallow) { + return res + } + + if (isObject(res)) { + return isReadonly ? readonly(res) : reactive(res) + } + + if (!isReadonly) { + // TODO 依赖收集 + track(target, key) + } + + return res + } +} + +function createSetter() { + return function set(target, key, value) { + const res = Reflect.set(target, key, value) + + // TODO 触发依赖 + trigger(target, key) + + return res + } +} + +export const mutableHandles = { + get, + set, +} + +export const readonlyHandles = { + get: readonlyGet, + set(target, key, value) { + console.warn(`key:${key}set 失败,因为target是readonly:${target}`) + return true + }, +} + +export const shallowReadonlyHandles = extend({}, readonlyHandles, { + get: shallowReadonlyGet, +}) diff --git a/src/reactivity/effect.ts b/src/reactivity/effect.ts new file mode 100644 index 0000000..bd0922c --- /dev/null +++ b/src/reactivity/effect.ts @@ -0,0 +1,110 @@ +import { extend } from "../shared" + +class ReactiveEffect { + private _fn: any + deps = [] + active = true + onStop?: () => void + public scheduler: Function | undefined + constructor(fn, scheduler?: Function) { + this._fn = fn + this.scheduler = scheduler + } + run() { + if (!this.active) { + return this._fn() + } + + shouldTrack = true + activeEffect = this + + const result = this._fn() + shouldTrack = false + activeEffect = undefined + + return result + } + stop() { + // this.deps.forEach((dep: any) => dep.delete(this)) + if (this.active) { + if (this.onStop) { + this.onStop() + } + cleanupEffect(this) + this.active = false + } + } +} + +function cleanupEffect(effect) { + effect.deps.forEach((dep: any) => { + dep.delete(effect) + }) +} + +const targetMap = new Map() +export function track(target, key) { + if (!isTracking()) { + return + } + // target => key => dep + let depsMap = targetMap.get(target) + if (!depsMap) { + depsMap = new Map() + targetMap.set(target, depsMap) + } + let dep = depsMap.get(key) + if (!dep) { + dep = new Set() + depsMap.set(key, dep) + } + trackEffect(dep) +} + +export function trackEffect(dep) { + if (dep.has(activeEffect)) return + dep.add(activeEffect) + activeEffect.deps.push(dep) +} + +export function trigger(target, key) { + let depsMap = targetMap.get(target) + let dep = depsMap.get(key) + triggerEffect(dep) +} + +export function triggerEffect(dep) { + for (const effect of dep) { + if (effect.scheduler) { + effect.scheduler() + } else { + effect.run() + } + } +} + +let activeEffect, + shouldTrack = false +export function effect(fn, options: any = {}) { + const _effect = new ReactiveEffect(fn, options.scheduler) + // _effect.onStop = options.onStop + // options + // Object.assign(_effect, options) + // extend + extend(_effect, options) + + _effect.run() + + const runner: any = _effect.run.bind(_effect) + + runner.effect = _effect + return runner +} + +export function stop(runner) { + runner.effect.stop() +} + +export function isTracking() { + return shouldTrack && activeEffect != undefined +} diff --git a/src/reactivity/index.ts b/src/reactivity/index.ts new file mode 100644 index 0000000..95beb6f --- /dev/null +++ b/src/reactivity/index.ts @@ -0,0 +1,3 @@ +export function add(a, b) { + return a + b +} diff --git a/src/reactivity/reactive.ts b/src/reactivity/reactive.ts new file mode 100644 index 0000000..2ed00d8 --- /dev/null +++ b/src/reactivity/reactive.ts @@ -0,0 +1,38 @@ +import { + mutableHandles, + readonlyHandles, + shallowReadonlyHandles, +} from "../reactivity/baseHandles" + +export const enum ReactiveFlags { + IS_REACTIVE = "__v_isReactive", + IS_READONLY = "__v_isReadonly", +} + +export function reactive(raw) { + return createActiveObject(raw, mutableHandles) +} + +export function readonly(raw) { + return createActiveObject(raw, readonlyHandles) +} + +export function shallowReadonly(raw) { + return createActiveObject(raw, shallowReadonlyHandles) +} + +export function isReactive(value): Boolean { + return !!value[ReactiveFlags.IS_REACTIVE] +} + +export function isReadonly(value): Boolean { + return !!value[ReactiveFlags.IS_READONLY] +} + +export function isProxy(value): Boolean { + return isReactive(value) || isReadonly(value) +} + +function createActiveObject(raw: any, baseHandles) { + return new Proxy(raw, baseHandles) +} diff --git a/src/reactivity/ref.ts b/src/reactivity/ref.ts new file mode 100644 index 0000000..25077f9 --- /dev/null +++ b/src/reactivity/ref.ts @@ -0,0 +1,31 @@ +import { hasChange } from "./../shared/index" +import { isTracking, trackEffect, triggerEffect } from "./effect" + +class RefImpl { + private _value: any + public dep + constructor(value) { + this._value = value + this.dep = new Set() + } + get value() { + trackRefValue(this) + return this._value + } + set value(newValue) { + if (hasChange(newValue, this._value)) { + this._value = newValue + triggerEffect(this.dep) + } + } +} + +function trackRefValue(ref) { + if (isTracking()) { + trackEffect(ref.dep) + } +} + +export function ref(value) { + return new RefImpl(value) +} diff --git a/src/reactivity/tests/effect.spec.ts b/src/reactivity/tests/effect.spec.ts new file mode 100644 index 0000000..33e5f98 --- /dev/null +++ b/src/reactivity/tests/effect.spec.ts @@ -0,0 +1,97 @@ +import { reactive } from "../reactive" +import { effect, stop } from "../effect" + +describe("effect", () => { + it("happy path", () => { + const user: any = reactive({ + age: 10, + }) + + let nextAge: any + effect(() => { + nextAge = user.age + 1 + }) + + expect(nextAge).toBe(11) + + // update + user.age++ + expect(nextAge).toBe(12) + }) + + it("should return runner when call effect", () => { + let foo = 10 + const runner = effect(() => { + foo++ + return "foo" + }) + expect(foo).toBe(11) + const r = runner() + expect(foo).toBe(12) + expect(r).toBe("foo") + }) + + it("scheduler", () => { + let dummy + let run + const scheduler = jest.fn(() => { + run = runner + }) + const obj = reactive({ foo: 1 }) + const runner = effect( + () => { + dummy = obj.foo + }, + { scheduler } + ) + + expect(scheduler).not.toHaveBeenCalled() + + expect(dummy).toBe(1) + + obj.foo++ + + expect(scheduler).toHaveBeenCalledTimes(1) + + expect(dummy).toBe(1) + + run() + + expect(dummy).toBe(2) + }) + + it("stop", () => { + let dummy + const obj = reactive({ a: 1 }) + const runner = effect(() => { + dummy = obj.a + }) + obj.a = 2 + expect(dummy).toBe(2) + stop(runner) + obj.a = 3 + expect(dummy).toBe(2) + + runner() + expect(dummy).toBe(3) + }) + + it("onStop", () => { + const obj = reactive({ foo: 1 }) + + const onStop = jest.fn() + + let dummy + + const runner = effect( + () => { + dummy = obj.foo + }, + { onStop } + ) + + stop(runner) + + expect(onStop).toBeCalledTimes(1) + }) +}) diff --git a/src/reactivity/tests/index.spec.ts b/src/reactivity/tests/index.spec.ts new file mode 100644 index 0000000..750021b --- /dev/null +++ b/src/reactivity/tests/index.spec.ts @@ -0,0 +1,5 @@ +import { add } from "../index" + +it("init", () => { + expect(add(1, 1)).toBe(2) +}) diff --git a/src/reactivity/tests/reactive.spec.ts b/src/reactivity/tests/reactive.spec.ts new file mode 100644 index 0000000..77300e3 --- /dev/null +++ b/src/reactivity/tests/reactive.spec.ts @@ -0,0 +1,24 @@ +import { reactive, isReactive, isProxy } from "../reactive" + +describe("reactive", () => { + it("happy path", () => { + const original = { foo: 1 } + const observed = reactive(original) + expect(observed).not.toBe(original) + expect(original.foo).toBe(1) + + expect(isReactive(observed)).toBe(true) + + expect(isReactive(original)).toBe(false) + + expect(isProxy(observed)).toBe(true) + }) + + test("nested reactive", () => { + const original = { nested: { foo: 1 }, array: [{ bar: 2 }] } + const observed = reactive(original) + expect(isReactive(observed.nested)).toBe(true) + expect(isReactive(observed.array)).toBe(true) + expect(isReactive(observed.array[0])).toBe(true) + }) +}) diff --git a/src/reactivity/tests/readonly.spec.ts b/src/reactivity/tests/readonly.spec.ts new file mode 100644 index 0000000..7a57309 --- /dev/null +++ b/src/reactivity/tests/readonly.spec.ts @@ -0,0 +1,24 @@ +import { readonly, isReadonly, isProxy } from "../reactive" +describe("readonly", () => { + it("should make nested values readonly", () => { + const original = { foo: 1, bar: { baz: 2 } } + const wrapped = readonly(original) + + expect(wrapped).not.toBe(original) + expect(isReadonly(wrapped)).toBe(true) + expect(isReadonly(original)).toBe(false) + + expect(isReadonly(wrapped.bar)).toBe(true) + expect(isReadonly(original.bar)).toBe(false) + expect(isProxy(wrapped)).toBe(true) + + expect(wrapped.foo).toBe(1) + }) + + it("warn then call set", () => { + console.warn = jest.fn() + const user = readonly({ age: 10 }) + user.age = 11 + expect(console.warn).toHaveBeenCalled() + }) +}) diff --git a/src/reactivity/tests/ref.spec.ts b/src/reactivity/tests/ref.spec.ts new file mode 100644 index 0000000..7c9611a --- /dev/null +++ b/src/reactivity/tests/ref.spec.ts @@ -0,0 +1,40 @@ +import { effect } from "../effect" +import { ref } from "../ref" + +describe("ref", () => { + it("happy path", () => { + const a = ref(1) + expect(a.value).toBe(1) + }) + + it("should be reactive", () => { + const a = ref(1) + let dummy + let calls = 0 + effect(() => { + calls++ + dummy = a.value + }) + expect(calls).toBe(1) + expect(dummy).toBe(1) + + a.value = 2 + expect(calls).toBe(2) + expect(dummy).toBe(2) + + // a.value = 2 + // expect(calls).toBe(2) + // expect(dummy).toBe(2) + }) + + it.skip("should make nested properties reactive", () => { + const a = ref({ count: 1 }) + let dummy + effect(() => { + dummy = a.value.count + }) + expect(dummy).toBe(1) + a.value.count = 2 + expect(dummy).toBe(2) + }) +}) diff --git a/src/reactivity/tests/shallowReadonly.spec.ts b/src/reactivity/tests/shallowReadonly.spec.ts new file mode 100644 index 0000000..a0980c2 --- /dev/null +++ b/src/reactivity/tests/shallowReadonly.spec.ts @@ -0,0 +1,16 @@ +import { isReadonly, shallowReadonly } from "../reactive" + +describe("shallowReadonly", () => { + test("should not make non-reactive properties reactive", () => { + const props = shallowReadonly({ n: { foo: 1 } }) + expect(isReadonly(props)).toBe(true) + expect(isReadonly(props.n)).toBe(false) + }) + + it("warn then call set", () => { + console.warn = jest.fn() + const user = shallowReadonly({ age: 10 }) + user.age = 11 + expect(console.warn).toHaveBeenCalled() + }) +}) diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..aebd387 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,9 @@ +export const extend = Object.assign + +export const isObject = (value) => { + return value !== null && typeof value === "object" +} + +export const hasChange = (oldVal, newVal) => { + return Object.is(oldVal, newVal) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1d92181 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,104 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "lib": [ + "DOM", + "ES6" + ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + "types": ["jest"], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "resolveJsonModule": true, /* Enable importing .json files */ + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}